mirror of
https://github.com/dromara/hertzbeat.git
synced 2026-09-17 18:19:02 +00:00
Compare commits
96
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a53e1b58e | ||
|
|
2f1fa48bb3 | ||
|
|
aee4fcc19b | ||
|
|
34940db118 | ||
|
|
0d3f142849 | ||
|
|
37623cbc37 | ||
|
|
2106bbb074 | ||
|
|
30076e6ea6 | ||
|
|
cce7b7ac46 | ||
|
|
1980d9a611 | ||
|
|
cc8206062f | ||
|
|
8b6d0d0056 | ||
|
|
7d14ed80ba | ||
|
|
2c376356ef | ||
|
|
f69c0f7b10 | ||
|
|
4cf968a3f4 | ||
|
|
9bd2796119 | ||
|
|
0734013a2a | ||
|
|
6ec4958997 | ||
|
|
e57c728364 | ||
|
|
5d523ddce3 | ||
|
|
1c0c881c58 | ||
|
|
46cce89d7e | ||
|
|
45848ecdb5 | ||
|
|
216e3f9d29 | ||
|
|
9f22a1cdd5 | ||
|
|
a899c7c518 | ||
|
|
265c3f1872 | ||
|
|
a9222b2a78 | ||
|
|
d5cb2e6660 | ||
|
|
1c03f830e5 | ||
|
|
90f4c28b86 | ||
|
|
577ab10c5f | ||
|
|
ef2672b06c | ||
|
|
c2c3095505 | ||
|
|
09a8bd809a | ||
|
|
3c87b977ed | ||
|
|
375ee5494e | ||
|
|
a4dbf0b9bc | ||
|
|
2fd363dfdd | ||
|
|
2389db7b3b | ||
|
|
1b7f7f9733 | ||
|
|
ec5564b54f | ||
|
|
87062df97d | ||
|
|
a54be93ff5 | ||
|
|
660bda3d0f | ||
|
|
45416c475e | ||
|
|
31aa9b5168 | ||
|
|
c785f6042d | ||
|
|
bf6c451c94 | ||
|
|
d5cf683f4f | ||
|
|
6959441f84 | ||
|
|
42f2365e29 | ||
|
|
3496ebf497 | ||
|
|
70ef12d086 | ||
|
|
9d8924f9e4 | ||
|
|
ecb238804b | ||
|
|
5cb6ee2eb8 | ||
|
|
675238b1dd | ||
|
|
fcb5a9f08f | ||
|
|
4a2ddd2506 | ||
|
|
35cc62b844 | ||
|
|
42eec4d559 | ||
|
|
e57d617db9 | ||
|
|
34dad832c3 | ||
|
|
50d7548748 | ||
|
|
87d55d8ad2 | ||
|
|
4680ae547a | ||
|
|
c8209f18ad | ||
|
|
c8021dc8d7 | ||
|
|
e0145f6a4f | ||
|
|
6d459957fb | ||
|
|
b73f824e3c | ||
|
|
2bb4f43494 | ||
|
|
695be421f2 | ||
|
|
75d6dee367 | ||
|
|
a819451011 | ||
|
|
cdb6411b83 | ||
|
|
cdb913c472 | ||
|
|
a4d2630320 | ||
|
|
b9e07d81f9 | ||
|
|
affccb7fb7 | ||
|
|
3e6a7962f6 | ||
|
|
922aa311e0 | ||
|
|
2016b18249 | ||
|
|
0297f0d4f8 | ||
|
|
5a98b56a5e | ||
|
|
bed2e4b3a0 | ||
|
|
d1e28e7621 | ||
|
|
61c9b050b5 | ||
|
|
f4858d5fcc | ||
|
|
07fd5b25c3 | ||
|
|
d16a95d8d6 | ||
|
|
39567816e9 | ||
|
|
af56746de1 | ||
|
|
d99e567494 |
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "Hertzbeat DevContainer",
|
||||
"name": "HertzBeat DevContainer",
|
||||
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/java:1": {
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"portsAttributes": {
|
||||
"4200": {
|
||||
"label": "Hertzbeat Web",
|
||||
"label": "HertzBeat Web",
|
||||
"onAutoForward": "notify"
|
||||
}
|
||||
},
|
||||
|
||||
+4
-2
@@ -48,6 +48,8 @@ indent_size = 2
|
||||
indent_size = 4
|
||||
tab_width = 4
|
||||
|
||||
[*.{js,jsx,mjs,cjs,ts,tsx}]
|
||||
[*.js]
|
||||
indent_size = 2
|
||||
|
||||
[*.ts]
|
||||
indent_size = 2
|
||||
tab_width = 2
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
|
||||
/home export-ignore
|
||||
/hip export-ignore
|
||||
/template-marketplace export-ignore
|
||||
/.github export-ignore
|
||||
/.idea export-ignore
|
||||
/.devcontainer export-ignore
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
# contributor license agreements. See the NOTICE file distributed with
|
||||
# this work for additional information regarding copyright ownership.
|
||||
# The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
# (the "License"); you may not use this file except in compliance with
|
||||
# the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
set -eu
|
||||
|
||||
common_hook="$(git rev-parse --git-common-dir)/hooks/post-commit"
|
||||
if [ -x "$common_hook" ]; then
|
||||
exec "$common_hook" "$@"
|
||||
fi
|
||||
@@ -1,24 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
# contributor license agreements. See the NOTICE file distributed with
|
||||
# this work for additional information regarding copyright ownership.
|
||||
# The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
# (the "License"); you may not use this file except in compliance with
|
||||
# the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
set -eu
|
||||
|
||||
repo_root=$(git rev-parse --show-toplevel)
|
||||
cd "$repo_root/web-app"
|
||||
|
||||
node scripts/check-toolchain.mjs
|
||||
exec pnpm format:staged
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 194 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 315 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 282 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 262 KiB |
@@ -20,83 +20,88 @@
|
||||
|
||||
name: Backend CI
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: backend-ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master, dev, action*, '2.*' ]
|
||||
branches: [ master, dev, action* ]
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'home/**'
|
||||
- 'web-app/**'
|
||||
- 'script/**'
|
||||
- 'material/**'
|
||||
pull_request:
|
||||
branches: [ master, dev, '2.*' ]
|
||||
branches: [ master, dev ]
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'home/**'
|
||||
- 'web-app/**'
|
||||
- 'script/**'
|
||||
- 'material/**'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
backend-build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./script/ci/github-actions/setup-deps
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Enable pnpm
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.9.0 --activate
|
||||
|
||||
- name: Resolve pnpm store
|
||||
id: pnpm-store
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: pnpm-${{ runner.os }}-${{ hashFiles('web-app/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
pnpm-${{ runner.os }}-
|
||||
|
||||
- name: Verify server release layout
|
||||
run: |
|
||||
./script/ci/verify-server-release-layout.sh
|
||||
python3 -m unittest script.ci.test_verify_server_release_package
|
||||
|
||||
- name: Build frontend release assets
|
||||
working-directory: web-app
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build
|
||||
|
||||
- name: Build with Maven
|
||||
run: mvnd clean -B package -Prelease -Dmaven.test.skip=false --file pom.xml
|
||||
|
||||
- name: Verify server release package
|
||||
run: |
|
||||
release_version=$(sed -n 's:.*<hzb.version>\([^<][^<]*\)</hzb.version>.*:\1:p' pom.xml | head -1)
|
||||
server_archive="dist/apache-hertzbeat-${release_version}-bin.tar.gz"
|
||||
test -f "$server_archive"
|
||||
python3 script/ci/verify-server-release-package.py "$server_archive"
|
||||
run: mvnd clean -B package -pl '!hertzbeat-e2e' -Prelease -Dmaven.test.skip=false --file pom.xml
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
uses: codecov/codecov-action@v4.0.1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
verbose: true
|
||||
|
||||
- name: Upload backend distribution
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: backend-dist-${{ github.run_id }}
|
||||
path: dist/
|
||||
retention-days: 1
|
||||
compression-level: 0
|
||||
|
||||
backend-maven-e2e:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./script/ci/github-actions/setup-deps
|
||||
|
||||
- name: Build backend Maven E2E modules
|
||||
run: |
|
||||
mvnd clean -B package \
|
||||
-pl hertzbeat-e2e/hertzbeat-collector-common-e2e,hertzbeat-e2e/hertzbeat-collector-kafka-e2e,hertzbeat-e2e/hertzbeat-collector-basic-e2e,hertzbeat-e2e/hertzbeat-collector-mysql-r2dbc-e2e,hertzbeat-e2e/hertzbeat-log-e2e \
|
||||
-am \
|
||||
-Dmaven.test.skip=false \
|
||||
--file pom.xml
|
||||
|
||||
- name: Upload Maven E2E coverage reports to Codecov
|
||||
uses: codecov/codecov-action@v4.0.1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
verbose: true
|
||||
|
||||
backend-image-e2e:
|
||||
needs: backend-build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download backend distribution
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: backend-dist-${{ github.run_id }}
|
||||
path: dist/
|
||||
|
||||
- name: Build Image
|
||||
uses: docker/build-push-action@v3
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: ./dist
|
||||
file: ./script/docker/server/Dockerfile
|
||||
@@ -105,12 +110,9 @@ jobs:
|
||||
|
||||
- name: Run E2E
|
||||
run: |
|
||||
sudo curl -L https://github.com/docker/compose/releases/download/v2.23.0/docker-compose-linux-x86_64 -o /usr/local/bin/docker-compose
|
||||
sudo chmod u+x /usr/local/bin/docker-compose
|
||||
|
||||
cd e2e
|
||||
sudo docker-compose version
|
||||
sudo docker-compose up --exit-code-from testing --remove-orphans
|
||||
docker compose version
|
||||
docker compose up --exit-code-from testing --remove-orphans
|
||||
|
||||
# upload application logs
|
||||
- name: Upload logs & API test reports
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
name: Collector Native Release
|
||||
|
||||
run-name: Native collector release build (${{ github.ref_name }})
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-native-collector:
|
||||
name: Native collector (${{ matrix.platform }})
|
||||
permissions:
|
||||
contents: read
|
||||
timeout-minutes: 120
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux-amd64
|
||||
runner: ubuntu-24.04
|
||||
archive_ext: tar.gz
|
||||
- platform: linux-arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
archive_ext: tar.gz
|
||||
- platform: macos-amd64
|
||||
runner: macos-13
|
||||
archive_ext: tar.gz
|
||||
- platform: macos-arm64
|
||||
runner: macos-14
|
||||
archive_ext: tar.gz
|
||||
- platform: windows-amd64
|
||||
runner: windows-latest
|
||||
archive_ext: zip
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up GraalVM JDK 25
|
||||
uses: graalvm/setup-graalvm@186d0493a2df5eb62df5ecc498883d18fd58c303 # v1.6.2
|
||||
with:
|
||||
distribution: graalvm-community
|
||||
java-version: '25'
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
cache: maven
|
||||
native-image-job-reports: 'true'
|
||||
|
||||
- name: Verify toolchain
|
||||
shell: pwsh
|
||||
run: |
|
||||
java -version
|
||||
native-image --version
|
||||
mvn -version
|
||||
|
||||
- name: Build native collector package
|
||||
run: mvn -B -pl hertzbeat-collector/hertzbeat-collector-collector -am -Pnative -DskipTests package
|
||||
|
||||
- name: Locate native collector package
|
||||
id: package
|
||||
shell: pwsh
|
||||
run: |
|
||||
$package = Get-ChildItem -Path "dist/apache-hertzbeat-collector-native-*-${{ matrix.platform }}-bin.${{ matrix.archive_ext }}" | Select-Object -First 1
|
||||
if (-not $package) {
|
||||
throw "Native collector package not found for ${{ matrix.platform }}"
|
||||
}
|
||||
"archive=$($package.FullName)" >> $env:GITHUB_OUTPUT
|
||||
|
||||
- name: Upload native collector package
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: apache-hertzbeat-collector-native-${{ matrix.platform }}
|
||||
path: ${{ steps.package.outputs.archive }}
|
||||
retention-days: 14
|
||||
@@ -27,6 +27,10 @@ on:
|
||||
paths:
|
||||
- 'home/**'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
docs-build:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -34,14 +38,27 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: pnpm
|
||||
cache-dependency-path: home/pnpm-lock.yaml
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.8'
|
||||
|
||||
- name: Check Markdown
|
||||
uses: DavidAnson/markdownlint-cli2-action@992badcdf24e3b8eb7e87ff9287fe931bcb00c6e
|
||||
uses: DavidAnson/markdownlint-cli2-action@8de2aa07cae85fd17c0b35642db70cf5495f1d25 # v24.0.0
|
||||
with:
|
||||
globs: './home/**/*.md'
|
||||
globs: |
|
||||
./home/README.md
|
||||
./home/blog/**/*.md
|
||||
./home/docs/**/*.md
|
||||
./home/i18n/zh-cn/docusaurus-plugin-content-blog/**/*.md
|
||||
./home/i18n/zh-cn/docusaurus-plugin-content-docs/current/**/*.md
|
||||
|
||||
- name: Check filenames
|
||||
run: python ./script/ci/docs/check_file_name.py ./script/ci/docs/check_file_name.json
|
||||
@@ -49,14 +66,15 @@ jobs:
|
||||
- name: Dead Link Check
|
||||
run: |
|
||||
sudo npm install -g markdown-link-check@3.8.7
|
||||
find ./home -name "*.md" > all_md_files.txt
|
||||
find ./home/docs ./home/blog ./home/i18n/zh-cn/docusaurus-plugin-content-docs/current ./home/i18n/zh-cn/docusaurus-plugin-content-blog -name "*.md" > all_md_files.txt
|
||||
printf '%s\n' ./home/README.md >> all_md_files.txt
|
||||
grep -vFf ./script/ci/exclude_files.txt all_md_files.txt > to_check.txt
|
||||
xargs -P 8 -a to_check.txt -I{} markdown-link-check -c ./script/ci/link_check.json -q "{}"
|
||||
|
||||
- name: NPM INSTALL
|
||||
- name: PNPM INSTALL
|
||||
working-directory: home
|
||||
run: npm install
|
||||
run: pnpm install
|
||||
|
||||
- name: NPM BUILD
|
||||
- name: PNPM BUILD
|
||||
working-directory: home
|
||||
run: npm run build
|
||||
run: pnpm run build
|
||||
|
||||
@@ -27,6 +27,10 @@ on:
|
||||
# Allow manual workflow trigger
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Set permissions for deployment
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -48,6 +52,8 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: pnpm
|
||||
cache-dependency-path: home/pnpm-lock.yaml
|
||||
|
||||
# Setup pnpm
|
||||
- name: Setup pnpm
|
||||
|
||||
@@ -15,54 +15,41 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
name: Frontend CI
|
||||
# This workflow will build a Java project with Maven
|
||||
# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-maven
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: frontend-ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
name: Frontend CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master, dev, 'action*', 'codex/**', 'feature/**', 'release/**', '2.*' ]
|
||||
branches: [ master, dev, action* ]
|
||||
paths:
|
||||
- 'web-app/**'
|
||||
- '.github/workflows/frontend-build-test.yml'
|
||||
pull_request:
|
||||
branches: [ master, dev, 'codex/**', 'feature/**', 'release/**', '2.*' ]
|
||||
branches: [ master, dev ]
|
||||
paths:
|
||||
- 'web-app/**'
|
||||
- '.github/workflows/frontend-build-test.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
version: 10
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Enable pnpm
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.9.0 --activate
|
||||
- name: Resolve pnpm store
|
||||
id: pnpm-store
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: pnpm-${{ runner.os }}-${{ hashFiles('web-app/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
pnpm-${{ runner.os }}-
|
||||
node-version: '20'
|
||||
cache: pnpm
|
||||
cache-dependency-path: web-app/pnpm-lock.yaml
|
||||
- name: Install
|
||||
working-directory: web-app
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Verify
|
||||
run: pnpm install
|
||||
- name: EsLint Test
|
||||
working-directory: web-app
|
||||
run: pnpm verify
|
||||
run: pnpm lint:ts
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
name: Hybrid Collector Release Gate
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/hybrid-collector-release.yml'
|
||||
- '.github/workflows/nightly-build.yml'
|
||||
- 'pom.xml'
|
||||
- 'hertzbeat-collector/**'
|
||||
- 'hertzbeat-common-core/**'
|
||||
- 'hertzbeat-manager/**'
|
||||
- 'hertzbeat-observability/**'
|
||||
# Negative paths: run the module-boundary contract if a legacy module reappears.
|
||||
- 'hertzbeat-log/**'
|
||||
- 'hertzbeat-otel/**'
|
||||
- 'hertzbeat-otel-runtime/**'
|
||||
- 'hertzbeat-startup/**'
|
||||
- 'script/assembly/collector/**'
|
||||
- 'script/ci/*hybrid-collector*'
|
||||
- 'script/ci/test_observability_module_boundary.py'
|
||||
- 'script/ci/*otel-runtime*'
|
||||
- 'script/docker/collector/**'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: hybrid-collector-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
contract:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: hertzbeat-otel-runtime/go.mod
|
||||
cache-dependency-path: hertzbeat-otel-runtime/go.mod
|
||||
- uses: graalvm/setup-graalvm@v1
|
||||
with:
|
||||
java-version: 25
|
||||
distribution: graalvm
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
cache: maven
|
||||
- name: Verify source and dependency contracts
|
||||
run: |
|
||||
python3 script/ci/test_observability_module_boundary.py -v
|
||||
python3 script/ci/test_verify_hybrid_collector_release_content.py -v
|
||||
python3 script/ci/test_verify_hybrid_collector_jvm_package.py -v
|
||||
python3 script/ci/test_verify_otel_runtime_sbom_platform.py -v
|
||||
python3 script/ci/test_prepare_hybrid_collector_native_container_context.py -v
|
||||
python3 script/ci/test_hybrid_collector_systemd_install.py -v
|
||||
mkdir -p target/release-policy
|
||||
git archive --format=tar.gz --output target/release-policy/hertzbeat-source.tar.gz HEAD
|
||||
python3 script/ci/verify-hybrid-collector-release-content.py \
|
||||
--source target/release-policy/hertzbeat-source.tar.gz
|
||||
./script/ci/verify-hybrid-collector-release-layout.sh
|
||||
./script/ci/verify-otel-runtime-layout.sh
|
||||
./script/ci/verify-hybrid-collector-native-layout.sh
|
||||
make -C hertzbeat-otel-runtime validate
|
||||
make -C hertzbeat-otel-runtime license-check
|
||||
- name: Verify Go binary reproducibility
|
||||
run: ./script/ci/verify-otel-runtime-reproducibility.sh
|
||||
- name: Verify Java runtime and Spring AOT
|
||||
run: |
|
||||
./script/ci/verify-hybrid-collector-java.sh
|
||||
make -C hertzbeat-otel-runtime release-assets
|
||||
./mvnw -pl hertzbeat-collector/hertzbeat-collector-collector -am \
|
||||
-Pruntime -DskipTests package
|
||||
set -- dist/apache-hertzbeat-collector-*-bin.tar.gz
|
||||
test "$#" -eq 1 && test -f "$1"
|
||||
sh ./script/ci/verify-hybrid-collector-jvm-package.sh "$1" generic
|
||||
set -- dist/apache-hertzbeat-collector-*-bin-linux_amd64.tar.gz
|
||||
test "$#" -eq 1 && test -f "$1"
|
||||
sh ./script/ci/verify-hybrid-collector-jvm-package.sh "$1" linux-amd64
|
||||
|
||||
source-policy:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
runner: [ubuntu-24.04, macos-15, windows-2025]
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: graalvm/setup-graalvm@v1
|
||||
with:
|
||||
java-version: 25
|
||||
distribution: graalvm
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
cache: maven
|
||||
- name: Verify cross-platform source policy
|
||||
shell: bash
|
||||
run: |
|
||||
./mvnw -pl hertzbeat-collector/hertzbeat-collector-collector -am \
|
||||
-Dtest='ManagedOtelRuntimeConfigTest,OtelRuntimeConfigRendererTest,OtelRuntimeSourcePolicyTest' \
|
||||
test -DskipITs -Dsurefire.failIfNoSpecifiedTests=false -DfailIfNoTests=false
|
||||
|
||||
native-release:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: ubuntu-24.04
|
||||
platform: linux-amd64
|
||||
- runner: ubuntu-24.04-arm
|
||||
platform: linux-arm64
|
||||
- runner: macos-15-intel
|
||||
platform: macos-amd64
|
||||
- runner: macos-15
|
||||
platform: macos-arm64
|
||||
- runner: windows-2025
|
||||
platform: windows-amd64
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: hertzbeat-otel-runtime/go.mod
|
||||
cache-dependency-path: hertzbeat-otel-runtime/go.mod
|
||||
- uses: graalvm/setup-graalvm@v1
|
||||
with:
|
||||
java-version: 25
|
||||
distribution: graalvm
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
cache: maven
|
||||
- name: Install make on Windows
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: choco install make --no-progress -y
|
||||
- name: Build release assets
|
||||
shell: bash
|
||||
run: make -C hertzbeat-otel-runtime release-assets
|
||||
- name: Build native Collector
|
||||
shell: bash
|
||||
run: ./mvnw -pl hertzbeat-collector/hertzbeat-collector-collector -am -Pnative -DskipTests package
|
||||
- name: Verify native package
|
||||
shell: bash
|
||||
run: |
|
||||
archive=$(find dist -maxdepth 1 -type f -name "*-${{ matrix.platform }}-bin.*" -print -quit)
|
||||
test -n "$archive"
|
||||
./script/ci/verify-hybrid-collector-native-package.sh "$archive" "${{ matrix.platform }}"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: hybrid-collector-package-${{ matrix.platform }}
|
||||
path: dist/*-${{ matrix.platform }}-bin.*
|
||||
if-no-files-found: error
|
||||
|
||||
native-container:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
needs: native-release
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: hybrid-collector-package-linux-*
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
- name: Prepare verified native container inputs
|
||||
run: ./script/ci/prepare-hybrid-collector-native-container-context.sh dist target/native-container-context
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- name: Build runnable amd64 native image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: target/native-container-context
|
||||
file: script/docker/collector/Dockerfile.native
|
||||
platforms: linux/amd64
|
||||
load: true
|
||||
tags: apache/hertzbeat-collector:native-test
|
||||
- name: Verify native image runtime
|
||||
run: ./script/ci/verify-hybrid-collector-native-image.sh apache/hertzbeat-collector:native-test linux/amd64
|
||||
- name: Build multi-platform native image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: target/native-container-context
|
||||
file: script/docker/collector/Dockerfile.native
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: false
|
||||
outputs: type=oci,dest=/tmp/hybrid-collector.oci.tar
|
||||
tags: apache/hertzbeat-collector:native-test
|
||||
- name: Verify multi-platform native image layers
|
||||
run: |
|
||||
python3 script/ci/verify-hybrid-collector-release-content.py \
|
||||
--container-image /tmp/hybrid-collector.oci.tar
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: hybrid-collector-native-oci
|
||||
path: /tmp/hybrid-collector.oci.tar
|
||||
if-no-files-found: error
|
||||
|
||||
native-checksums:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
needs: native-release
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: hybrid-collector-package-*
|
||||
path: release
|
||||
merge-multiple: true
|
||||
- name: Generate archive checksums
|
||||
working-directory: release
|
||||
run: find . -maxdepth 1 -type f -print0 | sort -z | xargs -0 sha512sum > SHA512SUMS
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: hybrid-collector-native-checksums
|
||||
path: release/SHA512SUMS
|
||||
if-no-files-found: error
|
||||
@@ -26,6 +26,10 @@ on:
|
||||
pull_request:
|
||||
branches: [ master, dev ]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-license-header:
|
||||
name: check-license-header
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable
|
||||
with:
|
||||
toolchain: ${{ env.RUST_VERSION }}
|
||||
components: rustfmt, clippy
|
||||
|
||||
@@ -27,6 +27,11 @@ on:
|
||||
- cron: '0 1 * * *'
|
||||
push:
|
||||
branches: [ action* ]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-license-header:
|
||||
name: Monitor E2E Test
|
||||
@@ -36,9 +41,9 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./script/ci/github-actions/setup-deps
|
||||
- name: Build with Maven
|
||||
run: mvn clean -B package -Prelease -Dmaven.test.skip=true --file pom.xml
|
||||
run: mvnd -B clean package -Prelease -Dmaven.test.skip=true --file pom.xml
|
||||
- name: Build Image
|
||||
uses: docker/build-push-action@v3
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: ./dist
|
||||
file: ./script/docker/server/Dockerfile
|
||||
@@ -46,8 +51,6 @@ jobs:
|
||||
tags: apache/hertzbeat:test
|
||||
- name: Run K8s Monitor E2E Test
|
||||
run: |
|
||||
sudo curl -L https://github.com/docker/compose/releases/download/v2.23.0/docker-compose-linux-x86_64 -o /usr/local/bin/docker-compose
|
||||
sudo chmod u+x /usr/local/bin/docker-compose
|
||||
cd e2e/k8s
|
||||
sudo docker-compose version
|
||||
sudo docker-compose up --exit-code-from testing --remove-orphans
|
||||
docker compose version
|
||||
docker compose up --exit-code-from testing --remove-orphans
|
||||
|
||||
@@ -17,13 +17,6 @@
|
||||
|
||||
name: Nightly CI
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: nightly-ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# trigger at 00:00 everyday
|
||||
@@ -31,93 +24,58 @@ on:
|
||||
push:
|
||||
branches: [ action* ]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 180
|
||||
if: ${{ github.repository == 'apache/hertzbeat' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./script/ci/github-actions/setup-deps
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: hertzbeat-otel-runtime/go.mod
|
||||
cache-dependency-path: hertzbeat-otel-runtime/go.mod
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: zulu
|
||||
java-version: 25
|
||||
node-version: '20'
|
||||
cache: pnpm
|
||||
cache-dependency-path: web-app/pnpm-lock.yaml
|
||||
|
||||
- name: Build the Frontend
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.9.0 --activate
|
||||
cd web-app
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build
|
||||
|
||||
- name: Build and verify managed Collector runtimes
|
||||
run: |
|
||||
make -C hertzbeat-otel-runtime release-assets
|
||||
./script/ci/verify-otel-runtime-package-layout.sh
|
||||
pnpm install
|
||||
pnpm package
|
||||
|
||||
- name: Build the Backend
|
||||
run: |
|
||||
mvn clean install
|
||||
mvn clean package -Prelease -DskipTests
|
||||
./mvnw -pl hertzbeat-collector/hertzbeat-collector-collector -am \
|
||||
-Pruntime -DskipTests package
|
||||
mvnd -B clean package -Prelease,cluster -Dmaven.test.skip=false --file pom.xml
|
||||
|
||||
- name: Verify Linux Collector release content
|
||||
run: |
|
||||
set -- dist/apache-hertzbeat-collector-*-bin-linux_amd64.tar.gz
|
||||
if [ "$#" -ne 1 ] || [ ! -f "$1" ]; then
|
||||
echo "expected exactly one generated linux-amd64 Hybrid Collector archive" >&2
|
||||
exit 1
|
||||
fi
|
||||
./script/ci/verify-hybrid-collector-jvm-package.sh "$1" linux-amd64
|
||||
set -- dist/apache-hertzbeat-collector-*-bin-linux_arm64.tar.gz
|
||||
if [ "$#" -ne 1 ] || [ ! -f "$1" ]; then
|
||||
echo "expected exactly one generated linux-arm64 Hybrid Collector archive" >&2
|
||||
exit 1
|
||||
fi
|
||||
./script/ci/verify-hybrid-collector-jvm-package.sh "$1" linux-arm64
|
||||
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a
|
||||
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USER }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
|
||||
- name: Build and Push Server
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: ./dist
|
||||
file: ./script/docker/server/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: apache/hertzbeat:nightly
|
||||
|
||||
- name: Build Collector image for verification
|
||||
uses: docker/build-push-action@v6
|
||||
|
||||
- name: Build and Push Collector
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: ./dist
|
||||
file: ./script/docker/collector/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: apache/hertzbeat-collector:nightly
|
||||
outputs: type=oci,dest=/tmp/hybrid-collector-nightly.oci.tar
|
||||
|
||||
- name: Verify final Collector image content
|
||||
run: python3 script/ci/verify-hybrid-collector-release-content.py --container-image /tmp/hybrid-collector-nightly.oci.tar
|
||||
|
||||
- name: Push verified Collector image
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y skopeo
|
||||
skopeo copy --all --authfile "$HOME/.docker/config.json" \
|
||||
oci-archive:/tmp/hybrid-collector-nightly.oci.tar \
|
||||
docker://apache/hertzbeat-collector:nightly
|
||||
|
||||
-16
@@ -46,18 +46,6 @@ nbdist/
|
||||
node_modules
|
||||
.docusaurus
|
||||
|
||||
# codex / browser / screenshot noise
|
||||
.codex-artifacts/
|
||||
.playwright-mcp/
|
||||
progress.md
|
||||
test-results/
|
||||
playwright-report/
|
||||
reports/
|
||||
screenshots/
|
||||
|
||||
# local temp runtime data
|
||||
.tmp/
|
||||
|
||||
# h2 data
|
||||
*.mv.db
|
||||
*.trace.db
|
||||
@@ -68,7 +56,3 @@ application-mysql.yml
|
||||
application-pg.yml
|
||||
/package.json
|
||||
/yarn.lock
|
||||
.omx/
|
||||
|
||||
# legacy frontend build output
|
||||
/web-app/dist/
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ vscode:
|
||||
|
||||
ports:
|
||||
- port: 4200
|
||||
name: Hertzbeat
|
||||
name: HertzBeat
|
||||
onOpen: open-browser
|
||||
|
||||
- port: 1157
|
||||
|
||||
@@ -15,7 +15,13 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
{
|
||||
"globs": ["home/**/*.md"],
|
||||
"globs": [
|
||||
"home/README.md",
|
||||
"home/blog/**/*.md",
|
||||
"home/docs/**/*.md",
|
||||
"home/i18n/zh-cn/docusaurus-plugin-content-blog/**/*.md",
|
||||
"home/i18n/zh-cn/docusaurus-plugin-content-docs/current/**/*.md"
|
||||
],
|
||||
"config": {
|
||||
"default": true,
|
||||
"MD001": true,
|
||||
@@ -38,7 +44,8 @@
|
||||
"MD045": true,
|
||||
"MD046": true,
|
||||
"MD047": true,
|
||||
"MD052": true
|
||||
"MD052": true,
|
||||
"MD060": false
|
||||
},
|
||||
"ignores": [
|
||||
"**/node_modules/**",
|
||||
|
||||
+81
-97
@@ -1,10 +1,10 @@
|
||||
## Contributor Guide [中文贡献者指南](#贡献者指南)
|
||||
## Contributor Guide [中文贡献者指南](#贡献者指南)
|
||||
|
||||
> We are committed to maintaining a happy community that helps each other, welcome every contributor to join us!
|
||||
> We are committed to maintaining a happy community that helps each other, welcome every contributor to join us!
|
||||
|
||||
### Kinds of Contributions
|
||||
### Kinds of Contributions
|
||||
|
||||
> In the HertzBeat community, there are many ways to contribute:
|
||||
> In the HertzBeat community, there are many ways to contribute:
|
||||
|
||||
- 💻**Code**: Can help the community complete some tasks, write new features or fix some bugs;
|
||||
|
||||
@@ -20,11 +20,11 @@
|
||||
|
||||
- 💬**Suggestion**: You can also make some suggestions to the project or community to promote the healthy development of the community;
|
||||
|
||||
More see [Contribution Types](https://allcontributors.org/emoji-key)
|
||||
More see [Contribution Types](https://allcontributors.org/en/reference/emoji-key/)
|
||||
|
||||
Even small corrections to typos are very welcome :)
|
||||
Even small corrections to typos are very welcome :)
|
||||
|
||||
### Getting HertzBeat up and running
|
||||
### Getting HertzBeat up and running
|
||||
|
||||
> To get HertzBeat code running on your development tools, and able to debug with breakpoints.
|
||||
> This is a front-end and back-end separation project. To start the local code, the back-end [startup](hertzbeat-startup) and the front-end [web-app](web-app) must be started separately.
|
||||
@@ -32,7 +32,7 @@ Even small corrections to typos are very welcome :)
|
||||
|
||||
#### Backend start
|
||||
|
||||
1. Requires `maven3+`, Java 25 and `lombok` environments
|
||||
1. Requires `maven3+`, `java25` and `lombok` environments
|
||||
|
||||
2. (Optional) Modify the configuration file: `hertzbeat-startup/src/main/resources/application.yml`
|
||||
|
||||
@@ -44,72 +44,64 @@ Even small corrections to typos are very welcome :)
|
||||
|
||||
#### Frontend start
|
||||
|
||||
1. Need `Node.js 22` and `pnpm 10`
|
||||
1. Need `Node pnpm` Environment, Make sure `Node.js >= 18`
|
||||
|
||||
2. Cd to the `web-app` directory: `cd web-app`
|
||||
|
||||
3. Install dependencies: `corepack pnpm@10.9.0 install --frozen-lockfile`
|
||||
3. Install pnpm if not existed `npm install -g pnpm`
|
||||
|
||||
4. After the local backend is started, start the local frontend in the `web-app` directory: `corepack pnpm@10.9.0 dev`
|
||||
4. Install Dependencies: `pnpm install` or `pnpm install --registry=https://registry.npmmirror.com` in `web-app`
|
||||
|
||||
5. Browser access to localhost:4200 to start, default account/password is *admin/hertzbeat*
|
||||
5. After the local backend is started, start the local frontend in the web-app directory: `pnpm start`
|
||||
|
||||
6. Browser access to localhost:4200 to start, default account/password is *admin/hertzbeat*
|
||||
|
||||
|
||||
### Find tasks
|
||||
### Find tasks
|
||||
|
||||
Find the issue you are interested in! On our GitHub repo issue list, we often publish some issues with the label good first issue or status: volunteer wanted.
|
||||
These issues welcome the help of contributors. Among them, good first issues tend to have low thresholds and are suitable for novices.
|
||||
Find the issue you are interested in! On our GitHub repo issue list, we often publish some issues with the label good first issue or status: volunteer wanted.
|
||||
These issues welcome the help of contributors. Among them, good first issues tend to have low thresholds and are suitable for novices.
|
||||
|
||||
Of course, if you have a good idea, you can also propose it directly on GitHub Discussion or contact with community.
|
||||
Of course, if you have a good idea, you can also propose it directly on GitHub Discussion or contact with community.
|
||||
|
||||
### Submit Pull Request
|
||||
### Submit Pull Request
|
||||
|
||||
1. First you need to fork your target [hertzbeat repository](https://github.com/apache/hertzbeat).
|
||||
1. First you need to fork your target [hertzbeat repository](https://github.com/apache/hertzbeat).
|
||||
2. Then download the code locally with git command:
|
||||
```shell
|
||||
git clone git@github.com:${YOUR_USERNAME}/hertzbeat.git #Recommended
|
||||
git clone git@github.com:${YOUR_USERNAME}/hertzbeat.git #Recommended
|
||||
```
|
||||
3. After the download is complete, please refer to the getting started guide or README file of the target repository to initialize the project.
|
||||
3. After the download is complete, please refer to the getting started guide or README file of the target repository to initialize the project.
|
||||
4. Then, you can refer to the following command to submit the code:
|
||||
```shell
|
||||
git checkout -b a-feature-branch #Recommended
|
||||
git checkout -b a-feature-branch #Recommended
|
||||
```
|
||||
5. Submit the coed as a commit, the commit message format specification required: [module name or type name]feature or bugfix or doc: custom message.
|
||||
5. Submit the code as a commit, the commit message format specification required: [module name or type name]feature or bugfix or doc: custom message.
|
||||
```shell
|
||||
git add <modified file/path>
|
||||
git commit -m '[docs]feature: necessary instructions' #Recommended
|
||||
git add <modified file/path>
|
||||
git commit -m '[docs]feature: necessary instructions' #Recommended
|
||||
```
|
||||
6. Push to the remote repository
|
||||
6. Push to the remote repository
|
||||
```shell
|
||||
git push origin a-feature-branch
|
||||
git push origin a-feature-branch
|
||||
```
|
||||
7. Then you can initiate a new PR (Pull Request) on GitHub.
|
||||
7. Then you can initiate a new PR (Pull Request) on GitHub.
|
||||
|
||||
Please note that the title of the PR needs to conform to our spec, and write the necessary description in the PR to facilitate code review by Committers and other contributors.
|
||||
Please note that the title of the PR needs to conform to our spec, and write the necessary description in the PR to facilitate code review by Committers and other contributors.
|
||||
|
||||
### AI-assisted contributions
|
||||
### Wait for the code to be merged
|
||||
|
||||
AI-assisted code is welcome when it is reviewed and owned by a human contributor. Please follow [AI-Assisted Development Guidelines](AGENTS.md):
|
||||
After submitting the PR, the Committee or the community's friends will review the code you submitted (Code Review), and will propose some modification suggestions or conduct some discussions. Please pay attention to your PR in time.
|
||||
|
||||
1. Treat AI output as a draft and keep each PR narrowly scoped.
|
||||
2. Do not include proprietary, private, license-incompatible, or secret material from AI prompts or outputs.
|
||||
3. Do not invent product behavior, API responses, topology data, metrics, health states, screenshots, or release claims.
|
||||
4. Write or update the smallest useful test/contract first for risky changes, then include the validation commands and results in the PR description.
|
||||
5. If AI helped, add a short PR note: `AI assistance`, `Human validation`, and `Risk notes`.
|
||||
If subsequent changes are required, there is no need to initiate a new PR. After submitting a commit on the original branch and pushing it to the remote repository, the PR will be automatically updated.
|
||||
|
||||
### Wait for the code to be merged
|
||||
In addition, our project has a relatively standardized and strict CI inspection process. After submitting PR, CI will be triggered. Please pay attention to whether it passes the CI inspection.
|
||||
|
||||
After submitting the PR, the Committee or the community's friends will review the code you submitted (Code Review), and will propose some modification suggestions or conduct some discussions. Please pay attention to your PR in time.
|
||||
Finally, the Committers can merge the PR into the master branch.
|
||||
|
||||
If subsequent changes are required, there is no need to initiate a new PR. After submitting a commit on the original branch and pushing it to the remote repository, the PR will be automatically updated.
|
||||
### After the code is merged
|
||||
|
||||
In addition, our project has a relatively standardized and strict CI inspection process. After submitting PR, CI will be triggered. Please pay attention to whether it passes the CI inspection.
|
||||
|
||||
Finally, the Committers can merge the PR into the master branch.
|
||||
|
||||
### After the code is merged
|
||||
|
||||
After the code has been merged, you can delete the development branch on both the local and remote repositories:
|
||||
After the code has been merged, you can delete the development branch on both the local and remote repositories:
|
||||
|
||||
```shell
|
||||
git branch -d a-dev-branch
|
||||
@@ -120,18 +112,18 @@ On the master/main branch, you can do the following to sync the upstream reposit
|
||||
|
||||
```shell
|
||||
git remote add upstream https://github.com/apache/hertzbeat.git #Bind the remote warehouse, if it has been executed, it does not need to be executed again
|
||||
git checkout master
|
||||
git checkout master
|
||||
git pull upstream master
|
||||
```
|
||||
|
||||
### HertzBeat Improvement Proposal (HIP)
|
||||
If you have major new features(e.g., support metrics push gateway, support logs monitoring), you need to write a design document known as a HertzBeat Improvement Proposal (HIP). Before starting to write a HIP, make sure you follow the process [here](https://github.com/apache/hertzbeat/tree/master/hip).
|
||||
|
||||
### How to become a Committer?
|
||||
### How to become a Committer?
|
||||
|
||||
With the above steps, you are a contributor to HertzBeat. Repeat the previous steps to stay active in the community, keep at, you can become a Committer!
|
||||
With the above steps, you are a contributor to HertzBeat. Repeat the previous steps to stay active in the community, keep at, you can become a Committer!
|
||||
|
||||
### Join Discussion
|
||||
### Join Discussion
|
||||
|
||||
[Join the Mailing Lists](https://lists.apache.org/list.html?dev@hertzbeat.apache.org) : Mail to `dev-subscribe@hertzbeat.apache.org` to subscribe mailing lists.
|
||||
|
||||
@@ -141,19 +133,19 @@ Add WeChat account `ahertzbeat` to pull you into the WeChat group.
|
||||
|
||||

|
||||
|
||||
<br>
|
||||
<br>
|
||||
|
||||
------
|
||||
|
||||
<br>
|
||||
|
||||
## 贡献者指南
|
||||
## 贡献者指南
|
||||
|
||||
> 非常欢迎参与项目贡献,我们致力于维护一个互相帮助的快乐社区。
|
||||
> 非常欢迎参与项目贡献,我们致力于维护一个互相帮助的快乐社区。
|
||||
|
||||
### 贡献方式
|
||||
### 贡献方式
|
||||
|
||||
> 在 HertzBeat 社区,贡献方式有很多:
|
||||
> 在 HertzBeat 社区,贡献方式有很多:
|
||||
|
||||
- 💻**代码**:可以帮助社区完成一些任务、编写新的feature或者是修复一些bug;
|
||||
|
||||
@@ -167,20 +159,20 @@ Add WeChat account `ahertzbeat` to pull you into the WeChat group.
|
||||
|
||||
- 💡**布道**:可以帮助宣传或推广 HertzBeat 社区,在 meetup 或 summit 中演讲;
|
||||
|
||||
- 💬**建议**:也可以对项目或者社区提出一些建议,促进社区的良性发展;
|
||||
- 💬**建议**:也可以对项目或者社区提出一些建议,促进社区的良性发展;
|
||||
|
||||
更多贡献方式参见 [Contribution Types](https://allcontributors.org/emoji-key)
|
||||
更多贡献方式参见 [Contribution Types](https://allcontributors.org/en/reference/emoji-key/)
|
||||
|
||||
即便是小到错别字的修正我们也都非常欢迎 :)
|
||||
即便是小到错别字的修正我们也都非常欢迎 :)
|
||||
|
||||
### 让 HertzBeat 运行起来
|
||||
### 让 HertzBeat 运行起来
|
||||
|
||||
> 让 HertzBeat 的代码在您的开发工具上运行起来,并且能够断点调试。
|
||||
> 让 HertzBeat 的代码在您的开发工具上运行起来,并且能够断点调试。
|
||||
> 此为前后端分离项目,本地代码启动需将后端[hertzbeat-startup](hertzbeat-startup)和前端[web-app](web-app)分别启动生效。
|
||||
|
||||
#### 后端启动
|
||||
|
||||
1. 需要 `maven3+`、Java 25 和 `lombok` 环境
|
||||
1. 需要 `maven3+`, `java25` 和 `lombok` 环境
|
||||
|
||||
2. (可选)修改配置文件配置信息-`hertzbeat-startup/src/main/resources/application.yml`
|
||||
|
||||
@@ -193,91 +185,83 @@ Add WeChat account `ahertzbeat` to pull you into the WeChat group.
|
||||
|
||||
#### 前端启动
|
||||
|
||||
1. 需要 `Node.js 22` 和 `pnpm 10`
|
||||
1. 需要 `nodejs pnpm` 环境, Make sure `Node.js >= 18`
|
||||
|
||||
2. 进入 `web-app` 目录: `cd web-app`
|
||||
|
||||
3. 安装依赖: `corepack pnpm@10.9.0 install --frozen-lockfile`
|
||||
3. 安装pnpm: `npm install -g pnpm`
|
||||
|
||||
4. 待本地后端启动后,在 `web-app` 目录下启动本地前端: `corepack pnpm@10.9.0 dev`
|
||||
4. 在前端工程目录 `web-app` 下执行: `pnpm install` or `pnpm install --registry=https://registry.npmmirror.com` in `web-app`
|
||||
|
||||
5. 浏览器访问 localhost:4200 即可开始,默认账号密码 *admin/hertzbeat*
|
||||
5. 待本地后端启动后,在web-app目录下启动本地前端 `pnpm start`
|
||||
|
||||
### 寻找任务
|
||||
6. 浏览器访问 localhost:4200 即可开始,默认账号密码 *admin/hertzbeat*
|
||||
|
||||
### 寻找任务
|
||||
|
||||
寻找您感兴趣的Issue!在我们的GitHub仓库和邮件列表中,我们经常会发布一些带有 good first issue 或者 status: volunteer wanted 标签的issue,这些issue都欢迎贡献者的帮助。
|
||||
其中good first issue往往门槛较低、适合新手。
|
||||
其中good first issue往往门槛较低、适合新手。
|
||||
|
||||
当然,如果您有好的想法,也可以直接在GitHub Discussion 中提出或者联系社区。
|
||||
当然,如果您有好的想法,也可以直接在GitHub Discussion 中提出或者联系社区。
|
||||
|
||||
### 提交 Pull Request
|
||||
|
||||
1. 首先您需要 Fork 目标仓库 [hertzbeat repository](https://github.com/apache/hertzbeat).
|
||||
2. 然后 用git命令 将代码下载到本地:
|
||||
2. 然后 用git命令 将代码下载到本地:
|
||||
```shell
|
||||
git clone git@github.com:${YOUR_USERNAME}/hertzbeat.git #Recommended
|
||||
git clone git@github.com:${YOUR_USERNAME}/hertzbeat.git #Recommended
|
||||
```
|
||||
3. 下载完成后,请参考目标仓库的入门指南或者 README 文件对项目进行初始化。
|
||||
4. 接着,您可以参考如下命令进行代码的提交, 切换新的分支, 进行开发:
|
||||
4. 接着,您可以参考如下命令进行代码的提交, 切换新的分支, 进行开发:
|
||||
```shell
|
||||
git checkout -b a-feature-branch #Recommended
|
||||
git checkout -b a-feature-branch #Recommended
|
||||
```
|
||||
5. 提交 commit , commit 描述信息需要符合约定格式: [module name or type name]feature or bugfix or doc: custom message.
|
||||
5. 提交 commit , commit 描述信息需要符合约定格式: [module name or type name]feature or bugfix or doc: custom message.
|
||||
```shell
|
||||
git add <modified file/path>
|
||||
git commit -m '[docs]feature: necessary instructions' #Recommended
|
||||
git add <modified file/path>
|
||||
git commit -m '[docs]feature: necessary instructions' #Recommended
|
||||
```
|
||||
6. 推送到远程仓库
|
||||
6. 推送到远程仓库
|
||||
```shell
|
||||
git push origin a-feature-branch
|
||||
git push origin a-feature-branch
|
||||
```
|
||||
7. 然后您就可以在 GitHub 上发起新的 PR (Pull Request)。
|
||||
|
||||
请注意 PR 的标题需要符合我们的规范,并且在 PR 中写上必要的说明,来方便 Committer 和其他贡献者进行代码审查。
|
||||
请注意 PR 的标题需要符合我们的规范,并且在 PR 中写上必要的说明,来方便 Committer 和其他贡献者进行代码审查。
|
||||
|
||||
### AI 辅助贡献
|
||||
### 等待PR代码被合并
|
||||
|
||||
欢迎使用 AI 辅助写代码,但 PR 必须由人类贡献者负责、审查和验证。请遵循 [AI-Assisted Development Guidelines](AGENTS.md):
|
||||
|
||||
1. 把 AI 输出当作草稿,每个 PR 保持清晰、收敛的范围。
|
||||
2. 不要提交来自 AI 提示词或输出中的私有、专有、许可证不兼容内容,也不要包含密钥或凭据。
|
||||
3. 不要编造产品行为、API 响应、拓扑数据、指标、健康状态、截图或发布结论。
|
||||
4. 对高风险或用户可见改动,先写或更新最小测试/合约,再在 PR 描述中写明验证命令和结果。
|
||||
5. 如果使用了 AI,请在 PR 描述中简短说明:`AI assistance`、`Human validation`、`Risk notes`。
|
||||
|
||||
### 等待PR代码被合并
|
||||
|
||||
在提交了 PR 后,Committer 或者社区的小伙伴们会对您提交的代码进行审查(Code Review),会提出一些修改建议,或者是进行一些讨论,请及时关注您的PR。
|
||||
在提交了 PR 后,Committer 或者社区的小伙伴们会对您提交的代码进行审查(Code Review),会提出一些修改建议,或者是进行一些讨论,请及时关注您的PR。
|
||||
|
||||
若后续需要改动,不需要发起一个新的 PR,在原有的分支上提交 commit 并推送到远程仓库后,PR会自动更新。
|
||||
|
||||
另外,我们的项目有比较规范和严格的 CI 检查流程,在提交 PR 之后会触发 CI,请注意是否通过 CI 检查。
|
||||
|
||||
最后,Committer 可以将 PR 合并入 master 主分支。
|
||||
最后,Committer 可以将 PR 合并入 master 主分支。
|
||||
|
||||
### 代码被合并后
|
||||
### 代码被合并后
|
||||
|
||||
在代码被合并后,您就可以在本地和远程仓库删除这个开发分支了:
|
||||
在代码被合并后,您就可以在本地和远程仓库删除这个开发分支了:
|
||||
|
||||
```shell
|
||||
git branch -d a-dev-branch
|
||||
git push origin --delete a-dev-branch
|
||||
```
|
||||
|
||||
在主分支上,您可以执行以下操作来同步上游仓库:
|
||||
在主分支上,您可以执行以下操作来同步上游仓库:
|
||||
|
||||
```shell
|
||||
git remote add upstream https://github.com/apache/hertzbeat.git #Bind the remote warehouse, if it has been executed, it does not need to be executed again
|
||||
git checkout master
|
||||
git checkout master
|
||||
git pull upstream master
|
||||
```
|
||||
|
||||
### HertzBeat 改进提案 (HIP)
|
||||
如果您有重大的新特性(例如,支持指标推送网关,日志监控等),您需要编写一个被称为 HertzBeat 改进提案(HertzBeat Improvement Proposal,HIP)的设计文档。在开始编写 HIP 之前,请确保您遵循了[这里](https://github.com/apache/hertzbeat/tree/master/hip)的流程。
|
||||
|
||||
### 如何成为Committer?
|
||||
### 如何成为Committer?
|
||||
|
||||
通过上述步骤,您就是 HertzBeat 的贡献者了。重复前面的步骤,在社区中保持活跃,坚持下去,您就能成为 Committer!
|
||||
通过上述步骤,您就是 HertzBeat 的贡献者了。重复前面的步骤,在社区中保持活跃,坚持下去,您就能成为 Committer!
|
||||
|
||||
### 加入讨论交流
|
||||
|
||||
@@ -287,4 +271,4 @@ Add WeChat account `ahertzbeat` to pull you into the WeChat group.
|
||||
|
||||
### 模块
|
||||
|
||||

|
||||

|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
</p>
|
||||
|
||||
[](https://discord.gg/Fb6M73htGr)
|
||||
[](https://www.reddit.com/r/hertzbeat/)
|
||||
[](https://x.com/hertzbeat1024)
|
||||
[](https://www.bestpractices.dev/projects/8139)
|
||||
[](https://app.codecov.io/gh/apache/hertzbeat)
|
||||
@@ -21,13 +20,13 @@
|
||||
[](https://deepwiki.com/apache/hertzbeat)
|
||||
[](https://zread.ai/apache/hertzbeat)
|
||||
|
||||
**Home: [hertzbeat.apache.org](https://hertzbeat.apache.org)**
|
||||
**Home: [hertzbeat.apache.org](https://hertzbeat.apache.org)**
|
||||
**Email: <a href="mailto:dev-subscribe@hertzbeat.apache.org">Mail to `dev-subscribe@hertzbeat.apache.org`</a> to subscribe mailing lists**
|
||||
|
||||
|
||||
## 🎡 <font color="green">Introduction</font>
|
||||
|
||||
[Apache HertzBeat™](https://github.com/apache/hertzbeat) is an AI-powered next-generation open source real-time observability system. Unified metrics and logs collection, centralized alerting distribution, intelligent management and analysis. No Agent required, high performance cluster, provides powerful custom monitoring and status page building capabilities.
|
||||
[Apache HertzBeat™](https://github.com/apache/hertzbeat) is an AI-powered next-generation open source real-time observability system. Unified metrics and logs collection, centralized alerting distribution, intelligent management and analysis. No Agent required, high performance cluster, provides powerful custom monitoring and status page building capabilities.
|
||||
|
||||
### Features
|
||||
|
||||
@@ -40,17 +39,17 @@
|
||||
- High performance, supports horizontal expansion of multi-collector clusters, multi-isolated network monitoring and cloud-edge collaboration.
|
||||
- Provides powerful status page building capabilities, easily communicate the real-time status of your service to users.
|
||||
|
||||
> HertzBeat's unified platform, AI intelligence, powerful customization, multi-type support, high performance, and easy expansion, aims to help users quickly and conveniently achieve observability requirements.
|
||||
> HertzBeat's unified platform, AI intelligence, powerful customization, multi-type support, high performance, and easy expansion, aims to help users quickly and conveniently achieve observability requirements.
|
||||
|
||||
----
|
||||
|
||||
----
|
||||
|
||||
## 🥐 Architecture
|
||||
## 🥐 Architecture
|
||||
|
||||

|
||||

|
||||
|
||||
## ⛄ Supported
|
||||
## ⛄ Supported
|
||||
|
||||
> We define all metrics collection types such as `mysql`, `jvm`, and `k8s` as `YML` templates, allowing users to import them to support corresponding types of monitoring.
|
||||
> Welcome everyone to contribute your customized general metrics type YML template during use.
|
||||
@@ -93,21 +92,18 @@
|
||||
[HuaweiSwitch](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-huawei_switch.yml), [TpLinkSwitch](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-tplink_switch.yml),
|
||||
[H3cSwitch](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-h3c_switch.yml)
|
||||
- And More Your Custom Template.
|
||||
- Notified Support `Discord` `Slack` `Telegram` `Email` `Dingtalk` `WeChat` `FeiShu` `Webhook` `SMS` `ServerChan`.
|
||||
- Notified Support `Discord` `Slack` `Telegram` `Email` `DingTalk` `WeChat` `FeiShu` `Webhook` `SMS` `ServerChan`.
|
||||
|
||||
|
||||
## 🐕 Quick Start
|
||||
|
||||
- Community alpha preview users can start from [docs/alpha-preview.md](docs/alpha-preview.md) for the current
|
||||
Next.js frontend, entity-centered observability, local smoke, and known alpha limitations.
|
||||
## 🐕 Quick Start
|
||||
|
||||
- If you wish to deploy HertzBeat locally, please refer to the following Deployment Documentation for instructions.
|
||||
|
||||
### 🍞 Install HertzBeat
|
||||
### 🍞 Install HertzBeat
|
||||
|
||||
> HertzBeat supports installation through source code, docker or package, cpu support x86/arm64.
|
||||
> HertzBeat supports installation through source code, docker or package, cpu support x86/arm64.
|
||||
|
||||
##### 1:Install quickly via docker
|
||||
##### 1:Install quickly via docker
|
||||
|
||||
1. Just one command to get started
|
||||
|
||||
@@ -115,7 +111,7 @@
|
||||
docker run -d -p 1157:1157 -p 1158:1158 --name hertzbeat apache/hertzbeat
|
||||
```
|
||||
|
||||
2. Access `http://localhost:1157` to start, default account: `admin/hertzbeat`
|
||||
2. Access `http://localhost:1157` to start, default account: `admin/hertzbeat`
|
||||
|
||||
3. Deploy collector clusters (Optional)
|
||||
|
||||
@@ -129,16 +125,16 @@
|
||||
- `-e MANAGER_PORT=1158` : set the main hertzbeat server port, default 1158.
|
||||
|
||||
|
||||
Detailed config refer to [Install HertzBeat via Docker](https://hertzbeat.apache.org/docs/start/docker-deploy)
|
||||
Detailed config refer to [Install HertzBeat via Docker](https://hertzbeat.apache.org/docs/start/docker-deploy)
|
||||
|
||||
##### 2:Install via package
|
||||
##### 2:Install via package
|
||||
|
||||
1. Download the release package `hertzbeat-xx.tar.gz` [Download](https://hertzbeat.apache.org/docs/download)
|
||||
2. Configure the HertzBeat configuration yml file `hertzbeat/config/application.yml` (optional)
|
||||
3. Run command `$ ./bin/startup.sh ` or `bin/startup.bat`
|
||||
4. Access `http://localhost:1157` to start, default account: `admin/hertzbeat`
|
||||
1. Download the release package `apache-hertzbeat-xx-bin.tar.gz` [Download](https://hertzbeat.apache.org/docs/download)
|
||||
2. Configure the HertzBeat configuration yml file `hertzbeat/config/application.yml` (optional)
|
||||
3. Run command `$ ./bin/startup.sh ` or `bin/startup.bat`
|
||||
4. Access `http://localhost:1157` to start, default account: `admin/hertzbeat`
|
||||
5. Deploy collector clusters (Optional)
|
||||
- Download the release package `hertzbeat-collector-xx.tar.gz` to new machine [Download](https://hertzbeat.apache.org/docs/download)
|
||||
- Download the release package `apache-hertzbeat-collector-xx-bin.tar.gz` (JVM collector) or the native collector package for your platform, such as `apache-hertzbeat-collector-native-xx-linux-amd64-bin.tar.gz` or `apache-hertzbeat-collector-native-xx-windows-amd64-bin.zip`, to the new machine [Download](https://hertzbeat.apache.org/docs/download)
|
||||
- Configure the collector configuration yml file `hertzbeat-collector/config/application.yml`: unique `identity` name, running `mode` (public or private), hertzbeat `manager-host`, hertzbeat `manager-port`
|
||||
```yaml
|
||||
collector:
|
||||
@@ -151,25 +147,27 @@ Detailed config refer to [Install HertzBeat via Docker](https://hertzbeat.apache
|
||||
manager-host: ${MANAGER_HOST:127.0.0.1}
|
||||
manager-port: ${MANAGER_PORT:1158}
|
||||
```
|
||||
- Run command `$ ./bin/startup.sh ` or `bin/startup.bat`
|
||||
- Access `http://localhost:1157` and you will see the registered new collector in dashboard
|
||||
- If you do not provide JDBC drivers in `ext-lib`, MySQL, MariaDB, and OceanBase can use the built-in query engine and run on the native collector package as well. TiDB follows the same rule for its SQL query metric set.
|
||||
- If `mysql-connector-j` is present in `ext-lib`, the built-in server collector or JVM collector automatically prefers JDBC after restart for MySQL, MariaDB, and OceanBase. TiDB follows the same rule for its SQL query metric set, while its HTTP metrics are unchanged. Oracle and DB2 still require the JVM collector package because they depend on external JDBC drivers.
|
||||
- Run `$ ./bin/startup.sh ` or `bin/startup.bat` for the JVM collector package. Run `$ ./bin/startup.sh ` for Linux or macOS native collector packages, and `bin\\startup.bat` for the Windows native collector package.
|
||||
- Access `http://localhost:1157` and you will see the registered new collector in dashboard
|
||||
|
||||
Detailed config refer to [Install HertzBeat via Package](https://hertzbeat.apache.org/docs/start/package-deploy)
|
||||
Detailed config refer to [Install HertzBeat via Package](https://hertzbeat.apache.org/docs/start/package-deploy)
|
||||
|
||||
##### 3:Start via source code
|
||||
##### 3:Start via source code
|
||||
|
||||
1. Local source code debugging needs to start the back-end project `hertzbeat-startup` and the front-end project `web-app`.
|
||||
2. Backend: need `maven3+`, Java 25, and `lombok`; add VM options in IDE: ` --add-opens=java.base/java.nio=org.apache.arrow.memory.core,ALL-UNNAMED `, then start the `hertzbeat-startup` service.
|
||||
3. Web: need `Node.js 22` and `pnpm 10`, run `corepack pnpm@10.9.0 install --frozen-lockfile && corepack pnpm@10.9.0 dev` in `web-app` after backend startup.
|
||||
4. Access `http://localhost:4200` to start, default account: `admin/hertzbeat`
|
||||
2. Backend:need `maven3+`, `java25`, `lombok`, add VM options in IDE: ` --add-opens=java.base/java.nio=org.apache.arrow.memory.core,ALL-UNNAMED `, then start the `hertzbeat-startup` service.
|
||||
3. Web:need `nodejs` and `pnpm` environment, run `pnpm install` then `pnpm start` in `web-app` directory after backend startup.
|
||||
4. Access `http://localhost:4200` to start, default account: `admin/hertzbeat`
|
||||
|
||||
Detailed steps refer to [CONTRIBUTING](CONTRIBUTING.md)
|
||||
Detailed steps refer to [CONTRIBUTING](CONTRIBUTING.md)
|
||||
|
||||
##### 4:Install All(hertzbeat+postgresql+tsdb) via Docker-compose
|
||||
##### 4:Install All(hertzbeat+postgresql+tsdb) via Docker-compose
|
||||
|
||||
Install the postgresql/mysql database, victoria-metrics/iotdb/tdengine database and hertzbeat at one time through [docker-compose deployment script](script/docker-compose).
|
||||
|
||||
Detailed steps refer to [Install via Docker-Compose](script/docker-compose/README.md)
|
||||
Detailed steps refer to [Install via Docker-Compose](script/docker-compose/README.md)
|
||||
|
||||
##### 5: Install All(hertzbeat+collector+postgresql+tsdb) via kubernetes helm charts
|
||||
|
||||
@@ -181,8 +179,8 @@ Detailed steps refer to [Artifact Hub](https://artifacthub.io/packages/helm/hert
|
||||
|
||||
## ✨ Contributors
|
||||
|
||||
Thanks to these wonderful people, welcome to join us:
|
||||
[Contributor Guide](CONTRIBUTING.md)
|
||||
Thanks to these wonderful people, welcome to join us:
|
||||
[Contributor Guide](CONTRIBUTING.md)
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
|
||||
<!-- prettier-ignore-start -->
|
||||
@@ -560,7 +558,7 @@ Thanks to these wonderful people, welcome to join us:
|
||||
<!-- markdownlint-restore -->
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-LIST:END -->
|
||||
<!-- ALL-CONTRIBUTORS-LIST:END -->
|
||||
|
||||
## 💬 Join discussion
|
||||
|
||||
@@ -572,14 +570,10 @@ Thanks to these wonderful people, welcome to join us:
|
||||
|
||||
WeChat Group : Add friend `ahertzbeat` and invite to the group.
|
||||
|
||||
WeChat Public : Search ID `usthecom`.
|
||||
|
||||
[QQ Group](https://qm.qq.com/q/xxqecSC2cw) : Group num `1035688434`
|
||||
WeChat Official Account : Search ID `usthecom`.
|
||||
|
||||
[Github Discussion](https://github.com/apache/hertzbeat/discussions)
|
||||
|
||||
[Reddit Community](https://www.reddit.com/r/hertzbeat/)
|
||||
|
||||
[Follow Us Twitter](https://x.com/hertzbeat1024)
|
||||
|
||||
[Subscribe YouTube](https://www.youtube.com/channel/UCri75zfWX0GHqJFPENEbLow)
|
||||
@@ -589,7 +583,7 @@ WeChat Public : Search ID `usthecom`.
|
||||
HertzBeat is built on so many great open source projects, thanks to them!
|
||||
|
||||
- `Java Spring SpringBoot Jpa Maven Assembly Netty Lombok Sureness Protobuf HttpClient Guava SnakeYaml JsonPath ...`
|
||||
- `TypeScript Next.js React Tailwind CSS Node.js Npm ECharts G6 MonacoEditor Docusaurus ...`
|
||||
- `TypeScript Angular NG-ZORRO NG-ALAIN NodeJs Npm Html Less Echarts Rxjs ZoneJs MonacoEditor SlickCarousel Docusaurus ...`
|
||||
|
||||
## Landscape
|
||||
|
||||
|
||||
+8
-11
@@ -10,7 +10,6 @@
|
||||
</p>
|
||||
|
||||
[](https://discord.gg/Fb6M73htGr)
|
||||
[](https://www.reddit.com/r/hertzbeat/)
|
||||
[](https://x.com/hertzbeat1024)
|
||||
[](https://www.bestpractices.dev/projects/8139)
|
||||
[](https://app.codecov.io/gh/apache/hertzbeat)
|
||||
@@ -127,12 +126,12 @@
|
||||
|
||||
##### 方式二:通过安装包安装
|
||||
|
||||
1. 下载您系统环境对应的安装包 `hertzbeat-xx.tar.gz` [Download](https://hertzbeat.apache.org/docs/download)
|
||||
1. 下载您系统环境对应的安装包 `apache-hertzbeat-xx-bin.tar.gz` [Download](https://hertzbeat.apache.org/docs/download)
|
||||
2. 配置 HertzBeat 的配置文件 `hertzbeat/config/application.yml` (可选)
|
||||
3. 部署启动 `$ ./bin/startup.sh ` 或 `bin/startup.bat`
|
||||
4. 浏览器访问 `http://localhost:1157` 即可开始,默认账号密码 `admin/hertzbeat`
|
||||
5. 部署采集器集群(可选)
|
||||
- 下载采集器安装包 `hertzbeat-collector-xx.tar.gz` 到规划的另一台部署主机上 [Download](https://hertzbeat.apache.org/docs/download)
|
||||
- 下载采集器安装包 `apache-hertzbeat-collector-xx-bin.tar.gz`(JVM 采集器)或与你目标平台匹配的 Native 采集器安装包,例如 `apache-hertzbeat-collector-native-xx-linux-amd64-bin.tar.gz`、`apache-hertzbeat-collector-native-xx-windows-amd64-bin.zip`,到规划的另一台部署主机上 [Download](https://hertzbeat.apache.org/docs/download)
|
||||
- 配置采集器的配置文件 `hertzbeat-collector/config/application.yml` 里面的连接主 HertzBeat 服务的对外 IP,端口,当前采集器名称(需保证唯一性)等参数 `identity` `mode` (public or private) `manager-host` `manager-port`
|
||||
```yaml
|
||||
collector:
|
||||
@@ -145,7 +144,9 @@
|
||||
manager-host: ${MANAGER_HOST:127.0.0.1}
|
||||
manager-port: ${MANAGER_PORT:1158}
|
||||
```
|
||||
- 启动 `$ ./bin/startup.sh ` 或 `bin/startup.bat`
|
||||
- 如果没有在 `ext-lib` 中提供 JDBC 驱动,MySQL、MariaDB、OceanBase 可以直接使用内置查询引擎,也可以使用 Native 采集器安装包;TiDB 的 SQL 查询指标也遵循同样规则。
|
||||
- 如果在 `ext-lib` 中放入了 `mysql-connector-j`,主程序内置采集器或 JVM 采集器会在重启后自动优先走 JDBC;这一点现在适用于 MySQL、MariaDB、OceanBase,TiDB 的 SQL 查询指标也遵循同样规则,而它的 HTTP 指标不受影响。Oracle、DB2 仍然必须使用 JVM 采集器安装包,因为它们依赖外置 JDBC 驱动。
|
||||
- JVM 采集器安装包使用 `$ ./bin/startup.sh ` 或 `bin/startup.bat` 启动。Linux 或 macOS 的 Native 采集器安装包使用 `$ ./bin/startup.sh ` 启动,Windows 的 Native 采集器安装包使用 `bin\\startup.bat` 启动
|
||||
- 浏览器访问主 HertzBeat 服务 `http://localhost:1157` 查看概览页面即可看到注册上来的新采集器
|
||||
|
||||
更多配置详细步骤参考 [通过安装包安装HertzBeat](https://hertzbeat.apache.org/docs/start/package-deploy)
|
||||
@@ -153,8 +154,8 @@
|
||||
##### 方式三:本地代码启动
|
||||
|
||||
1. 此为前后端分离项目,本地代码调试需要分别启动后端工程 `hertzbeat-startup` 和前端工程 `web-app`
|
||||
2. 后端:需要 `maven3+`、Java 25 和 `lombok` 环境,修改 `YML` 配置信息,添加JVM参数`--add-opens=java.base/java.nio=org.apache.arrow.memory.core,ALL-UNNAMED`后启动 `hertzbeat-startup` 服务即可。
|
||||
3. 前端:需要 `Node.js 22` 和 `pnpm 10` 环境,待本地后端启动后,在 `web-app` 目录下执行 `corepack pnpm@10.9.0 install --frozen-lockfile && corepack pnpm@10.9.0 dev`
|
||||
2. 后端:需要 `maven3+`, `java25` 和 `lombok` 环境,修改 `YML` 配置信息,添加JVM参数`--add-opens=java.base/java.nio=org.apache.arrow.memory.core,ALL-UNNAMED`后启动 `hertzbeat-startup` 服务即可。
|
||||
3. 前端:需要 `nodejs` 和 `pnpm` 环境,待本地后端启动后,在 `web-app` 目录下执行 `pnpm install` 再执行 `pnpm start`
|
||||
4. 浏览器访问 `http://localhost:4200` 即可开始,默认账号密码 `admin/hertzbeat`
|
||||
|
||||
详细步骤参考 [参与贡献之本地代码启动](CONTRIBUTING.md)
|
||||
@@ -568,12 +569,8 @@ Thanks these wonderful people, welcome to join us:
|
||||
|
||||
微信公众号 : 搜索 ID `usthecom`.
|
||||
|
||||
[QQ交流群](https://qm.qq.com/q/xxqecSC2cw) : 群号 `1035688434`
|
||||
|
||||
[Github Discussion](https://github.com/apache/hertzbeat/discussions)
|
||||
|
||||
[Reddit Community](https://www.reddit.com/r/hertzbeat/)
|
||||
|
||||
[Follow Us Twitter](https://x.com/hertzbeat1024)
|
||||
|
||||
[Subscribe YouTube](https://www.youtube.com/channel/UCri75zfWX0GHqJFPENEbLow)
|
||||
@@ -584,7 +581,7 @@ Thanks these wonderful people, welcome to join us:
|
||||
HertzBeat is built on so many great open source projects, thanks to them!
|
||||
|
||||
- `Java Spring SpringBoot Jpa Maven Assembly Netty Lombok Sureness Protobuf HttpClient Guava SnakeYaml JsonPath ...`
|
||||
- `TypeScript Next.js React Tailwind CSS Node.js Npm ECharts G6 MonacoEditor Docusaurus ...`
|
||||
- `TypeScript Angular NG-ZORRO NG-ALAIN NodeJs Npm Html Less Echarts Rxjs ZoneJs MonacoEditor SlickCarousel Docusaurus ...`
|
||||
|
||||
|
||||
## Landscape
|
||||
|
||||
+19
-22
@@ -10,7 +10,6 @@
|
||||
</p>
|
||||
|
||||
[](https://discord.gg/Fb6M73htGr)
|
||||
[](https://www.reddit.com/r/hertzbeat/)
|
||||
[](https://x.com/hertzbeat1024)
|
||||
[](https://www.bestpractices.dev/projects/8139)
|
||||
[](https://app.codecov.io/gh/apache/hertzbeat)
|
||||
@@ -91,7 +90,7 @@
|
||||
[HuaweiSwitch](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-huawei_switch.yml), [TpLinkSwitch](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-tplink_switch.yml),
|
||||
[H3cSwitch](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-h3c_switch.yml)
|
||||
- その他、カスタマイズされたモニターテンプレート。
|
||||
- 通知利用可能 `Discord` `Slack` `Telegram` `Email` `Dingtalk` `WeChat` `FeiShu` `Webhook` `SMS` `ServerChan`。
|
||||
- 通知利用可能 `Discord` `Slack` `Telegram` `Email` `DingTalk` `WeChat` `FeiShu` `Webhook` `SMS` `ServerChan`。
|
||||
|
||||
## 🐕 クイックスタート
|
||||
|
||||
@@ -108,7 +107,7 @@
|
||||
docker run -d -p 1157:1157 -p 1158:1158 --name hertzbeat apache/hertzbeat
|
||||
```
|
||||
|
||||
2. スタート:`http://localhost:4200`にアクセスします。デフォルトのアカウントとパスワード:`admin/hertzbeat`。
|
||||
2. スタート:`http://localhost:1157`にアクセスします。デフォルトのアカウントとパスワード:`admin/hertzbeat`。
|
||||
|
||||
3. コレクタークラスタのデプロイメント(オプション)
|
||||
|
||||
@@ -122,17 +121,17 @@
|
||||
- `-e MANAGER_PORT=1158` :メインhertzbeatサーバポート。
|
||||
|
||||
|
||||
詳細ステップ [通过 Docker 方式安装 HertzBeat](https://hertzbeat.apache.org/docs/start/docker-deploy)
|
||||
詳細ステップ [Dockerで HertzBeat をインストール](https://hertzbeat.apache.org/docs/start/docker-deploy)
|
||||
|
||||
##### 方式2:インストールパッケージ
|
||||
|
||||
1. リリースパッケージをダウンロード `hertzbeat-xx.tar.gz` [Download](https://hertzbeat.apache.org/docs/download)
|
||||
2. HertzBeatのymlファイルを設定 `hertzbeat/config/application.yml` (オプション)
|
||||
3. コマンド`$ ./bin/startup.sh`または`bin/startup.bat`を実行
|
||||
4. スタート:`http://localhost:4200`にアクセスします。デフォルトのアカウントとパスワード:`admin/hertzbeat`
|
||||
1. リリースパッケージ `apache-hertzbeat-xx-bin.tar.gz` をダウンロードします [Download](https://hertzbeat.apache.org/docs/download)
|
||||
2. HertzBeat の設定ファイル `hertzbeat/config/application.yml` を編集します(任意)
|
||||
3. コマンド `$ ./bin/startup.sh` または `bin/startup.bat` を実行します
|
||||
4. ブラウザで `http://localhost:1157` にアクセスします。デフォルトのアカウントとパスワードは `admin/hertzbeat` です
|
||||
5. コレクタークラスタのデプロイメント(オプション)
|
||||
- コレクターパッケージを別のホストにダウンロード `hertzbeat-collector-xx.tar.gz` [Download](https://hertzbeat.apache.org/docs/download)
|
||||
- コレクターのymlファイルを設定 `hertzbeat-collector/config/application.yml`
|
||||
- 別ホストにコレクターのインストールパッケージ `apache-hertzbeat-collector-xx-bin.tar.gz`(JVM コレクター)または対象プラットフォーム向けの Native コレクターパッケージ(例: `apache-hertzbeat-collector-native-xx-linux-amd64-bin.tar.gz`、`apache-hertzbeat-collector-native-xx-windows-amd64-bin.zip`)をダウンロードします [Download](https://hertzbeat.apache.org/docs/download)
|
||||
- コレクターの設定ファイル `hertzbeat-collector/config/application.yml` を編集します
|
||||
```yaml
|
||||
collector:
|
||||
dispatch:
|
||||
@@ -148,25 +147,27 @@
|
||||
- `mode: ${MODE:public}`:実行モード(パブリッククラスタまたはプライベートクラウドエッジ)。
|
||||
- `manager-host: ${MANAGER_HOST:127.0.0.1}`:メインhertzbeatサーバーのIP。
|
||||
- `manager-port: ${MANAGER_PORT:1158}`:メインhertzbeatサーバポート。
|
||||
- コマンド`$ ./bin/startup.sh`または`bin/startup.bat`を実行。
|
||||
- `http://localhost:1157`にアクセスし、登録された新しいコレクターを見ることがでます。
|
||||
- `ext-lib` に JDBC ドライバーを置かない場合、MySQL、MariaDB、OceanBase は組み込みのクエリエンジンを使って Native コレクターパッケージでも監視できます。TiDB も SQL クエリのメトリクスセットについては同じルールです。
|
||||
- `ext-lib` に `mysql-connector-j` を置いた場合は、再起動後に組み込みサーバーコレクターまたは JVM コレクターが MySQL、MariaDB、OceanBase で自動的に JDBC を優先します。TiDB も SQL クエリのメトリクスセットについては同じルールで、HTTP メトリクスは影響を受けません。Oracle と DB2 は引き続き外部 JDBC ドライバーに依存するため、JVM コレクターパッケージを使用してください。
|
||||
- JVM コレクターのインストールパッケージは `$ ./bin/startup.sh` または `bin/startup.bat`、Linux/macOS の Native コレクターパッケージは `$ ./bin/startup.sh`、Windows の Native コレクターパッケージは `bin\\startup.bat` で起動します。
|
||||
- メインの HertzBeat サービス `http://localhost:1157` にアクセスすると、登録された新しいコレクターを確認できます。
|
||||
|
||||
詳細ステップ [通过安装包安装HertzBeat](https://hertzbeat.apache.org/docs/start/package-deploy)
|
||||
詳細ステップ [インストールパッケージで HertzBeat をインストール](https://hertzbeat.apache.org/docs/start/package-deploy)
|
||||
|
||||
##### 方式3:ローカルの実行
|
||||
|
||||
1. ローカルの実行には、バックエンドのプロジェクト`hertzbeat-startup`とフロントエンドのプロジェクト`web-app`を起動する必要があります。
|
||||
2. バックエンド:`maven3+`、Java 25、`lombok`の環境が必要です。`YML` 設定を修正し、Java仮想マシンパラメータに`--add-opens=java.base/java.nio=org.apache.arrow.memory.core,ALL-UNNAMED` を追加し、`hertzbeat-startup` を起動します。
|
||||
3. フロントエンド:`Node.js 22` と `pnpm 10` の環境が必要です。ローカルのバックエンドが立ち上がったら、`web-app` ディレクトリで `corepack pnpm@10.9.0 install --frozen-lockfile && corepack pnpm@10.9.0 dev` を実行します。
|
||||
2. バックエンド:`maven3+`、`Java25`、`lombok` の環境が必要です。`YML` 設定を修正し、Java 仮想マシンパラメータに `--add-opens=java.base/java.nio=org.apache.arrow.memory.core,ALL-UNNAMED` を追加して `hertzbeat-startup` を起動します。
|
||||
3. フロントエンド:`nodejs` と `pnpm` の環境が必要です。ローカルのバックエンドが立ち上がったら、`web-app` ディレクトリで `pnpm install` を実行し、続けて `pnpm start` を実行します。
|
||||
4. スタート:`http://localhost:4200`にアクセスします。デフォルトのアカウントとパスワード:`admin/hertzbeat`。
|
||||
|
||||
詳細ステップ [貢献ガイド](CONTRIBUTING.md)
|
||||
|
||||
##### 方式4:Docker-Compose
|
||||
|
||||
[Docker-Compose 部署脚本](script/docker-compose)でpostgresql/mysqlデータベース、victoria-metrics、iotdb、またはtdengine時系列データベースとHertzbeat一括デプロイ。
|
||||
[Docker-Compose 部署脚本](script/docker-compose)でpostgresql/mysqlデータベース、victoria-metrics、iotdb、またはtdengine時系列データベースとHertzBeat一括デプロイ。
|
||||
|
||||
詳細ステップ [通过 Docker-Compose 安装 HertzBeat](script/docker-compose/README.md)
|
||||
詳細ステップ [Docker-Compose で HertzBeat をインストール](script/docker-compose/README.md)
|
||||
|
||||
##### 方式5:Kubernetes Helm Charts
|
||||
|
||||
@@ -571,12 +572,8 @@ WeChatグループ : `ahertzbeat` を検索.
|
||||
|
||||
WeChat公式アカウント : `usthecom`を検索.
|
||||
|
||||
[QQグループ](https://qm.qq.com/q/xxqecSC2cw) : グループ番号 `1035688434`
|
||||
|
||||
[Github Discussion](https://github.com/apache/hertzbeat/discussions)
|
||||
|
||||
[Reddit Community](https://www.reddit.com/r/hertzbeat/)
|
||||
|
||||
[Follow Us Twitter](https://x.com/hertzbeat1024)
|
||||
|
||||
[Subscribe YouTube](https://www.youtube.com/channel/UCri75zfWX0GHqJFPENEbLow)
|
||||
@@ -587,7 +584,7 @@ WeChat公式アカウント : `usthecom`を検索.
|
||||
HertzBeat is built on so many great open source projects, thanks to them!
|
||||
|
||||
- `Java Spring SpringBoot Jpa Maven Assembly Netty Lombok Sureness Protobuf HttpClient Guava SnakeYaml JsonPath ...`
|
||||
- `TypeScript Next.js React Tailwind CSS Node.js Npm ECharts G6 MonacoEditor Docusaurus ...`
|
||||
- `TypeScript Angular NG-ZORRO NG-ALAIN NodeJs Npm Html Less Echarts Rxjs ZoneJs MonacoEditor SlickCarousel Docusaurus ...`
|
||||
|
||||
|
||||
## Landscape
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
# HertzBeat Community Alpha Preview
|
||||
|
||||
This document is for the GitHub community alpha preview branch. It is not a
|
||||
stable Apache HertzBeat release checklist. Use it to try the new entity-centered
|
||||
observability and Vite React frontend, report issues, and help harden the project
|
||||
toward a beta.
|
||||
|
||||
## What Is In Scope
|
||||
|
||||
- Entity-centered observability read models for metrics, logs, traces, topology,
|
||||
alerts, owners, runbooks, and handoff context.
|
||||
- Vite React `web-app` workflows for the operational shell, entity management,
|
||||
topology, instrumentation, and the three-signal query workspace.
|
||||
- OTLP trace/log/metric query paths backed by the existing warehouse and
|
||||
Greptime integrations.
|
||||
- Local demo topology relation seeding for the small Checkout API -> Payment API
|
||||
-> Orders DB example when the `local` Spring profile is active.
|
||||
|
||||
## Local Source Quickstart
|
||||
|
||||
1. Start the backend from `hertzbeat-startup` with Java 25.
|
||||
|
||||
Keep the Arrow JVM open option when running locally:
|
||||
|
||||
```shell
|
||||
--add-opens=java.base/java.nio=ALL-UNNAMED
|
||||
```
|
||||
|
||||
The local backend listens on `http://127.0.0.1:1157` by default.
|
||||
|
||||
2. Start the Vite React frontend:
|
||||
|
||||
```shell
|
||||
cd web-app
|
||||
corepack pnpm@10.9.0 install --frozen-lockfile
|
||||
corepack pnpm@10.9.0 dev
|
||||
```
|
||||
|
||||
The preview frontend listens on `http://127.0.0.1:4200`.
|
||||
|
||||
3. Open `http://127.0.0.1:4200` and sign in with the local development account
|
||||
configured for your environment.
|
||||
|
||||
## Alpha Validation Checklist
|
||||
|
||||
Before opening an alpha issue or pull request, run the smallest check that
|
||||
matches the area you changed.
|
||||
|
||||
Backend entity, topology, observability, and Greptime query changes:
|
||||
|
||||
```shell
|
||||
./mvnw -pl hertzbeat-manager,hertzbeat-observability,hertzbeat-warehouse \
|
||||
-Dtest=EntityDetailObservabilityReadModelServiceTest,EntityTopologyQueryServiceTest,EntityWorkspaceAccessServiceTest,EntityWorkspaceQueryServiceTest,TraceCallTopologyQueryServiceTest,LocalTopologyDemoRelationSeederTest,LogQueryControllerTest,EntityObservabilityGatewayImplTest,EntityTraceQueryServiceImplTest,GreptimeTraceQueryRepositoryTest,GreptimeDbDataStorageTest \
|
||||
test -DskipITs -Dsurefire.failIfNoSpecifiedTests=false -DfailIfNoTests=false
|
||||
```
|
||||
|
||||
Startup source package proof:
|
||||
|
||||
```shell
|
||||
./mvnw -pl hertzbeat-startup -am -DskipTests package
|
||||
```
|
||||
|
||||
Topology, entity, observability, shell, session, and shared UI changes:
|
||||
|
||||
```shell
|
||||
cd web-app
|
||||
corepack pnpm@10.9.0 test -- \
|
||||
src/features/entity src/features/topology src/features/instrumentation \
|
||||
src/features/explore
|
||||
```
|
||||
|
||||
Complete frontend release gate:
|
||||
|
||||
```shell
|
||||
cd web-app
|
||||
corepack pnpm@10.9.0 verify
|
||||
```
|
||||
|
||||
Visible changes also require a production build, a real backend, and a Browser
|
||||
check of the affected workflow. Do not use mocked topology or signal data as
|
||||
release evidence.
|
||||
|
||||
## Three-Signal SigNoz-Alignment Alpha Cutoff
|
||||
|
||||
HertzBeat 2.0 treats SigNoz as a product-shape reference for the alpha
|
||||
observability workspace, not as a full parity claim. The alpha cutoff is the
|
||||
operator workflow where metrics, logs, and traces can stay attached to the same
|
||||
HertzBeat entity context, be saved as reusable views, be composed into
|
||||
dashboards, and drill from service or operation metrics into related traces and
|
||||
logs.
|
||||
|
||||
This alpha scope includes:
|
||||
|
||||
- OTLP metrics, logs, and traces carrying HertzBeat entity context through
|
||||
`entityId`, `entityType`, `entityName`, service, namespace, environment,
|
||||
collector, template, and source query parameters.
|
||||
- Saved query views, dashboard panel drafts, dashboard variables, and persisted
|
||||
dashboards for metrics, logs, and traces.
|
||||
- A service overview dashboard with RED-style metrics, Apdex, database calls,
|
||||
external calls, key operations, logs, log errors, traces, trace errors,
|
||||
exceptions, exception messages, and firing alerts.
|
||||
- An operation drilldown dashboard from `operationName` context, with metrics
|
||||
filtered by `operation`, logs filtered by `http.route`, traces filtered by
|
||||
`operationName`, and exceptions grouped by `exception.type`.
|
||||
- Runtime dashboard evidence flows from metric/log/trace points into related
|
||||
signal handoffs, evidence panel drafts, and breakout panel drafts.
|
||||
- Stable entity binding from OpenTelemetry resource identity such as
|
||||
`service.name`, host, and Kubernetes pod attributes.
|
||||
|
||||
This alpha scope does not claim full SigNoz parity. In particular, it does not
|
||||
claim public dashboard sharing, Terraform-managed dashboards, a prebuilt
|
||||
dashboard template marketplace, a ClickHouse SQL dashboard builder, a log
|
||||
pipeline builder, cost-meter dashboards, full flamegraph parity, or automatic
|
||||
APM RED metric derivation from traces beyond the explicit telemetry/query data
|
||||
seeded or stored in the current HertzBeat runtime.
|
||||
|
||||
Runtime signal dimensions such as `trace_id`, `span.name`, `http.route`,
|
||||
`operation`, `operationName`, `exception.type`, and `exception.message` are
|
||||
filter, group-by, handoff, and drilldown dimensions. They must not be promoted
|
||||
into long-lived `ObserveEntity` identities unless a later design explicitly
|
||||
adds an endpoint or operation entity model.
|
||||
|
||||
The focused live proof for this cutoff is:
|
||||
|
||||
```shell
|
||||
TRACE_ID=6b6b6b6b6b6b6b6b6b6b6b6b6b6b6b6b bash script/dev/run-three-signal-live-proof.sh
|
||||
```
|
||||
|
||||
That proof starts a non-persistent H2 backend, seeds OTLP metrics/logs/traces,
|
||||
verifies entity binding and signal query breakouts, starts `web-app`, and checks
|
||||
that the active React routes are served. Product-level browser acceptance remains
|
||||
a separate real-backend validation step.
|
||||
|
||||
## Local Scale Proof Data
|
||||
|
||||
Large topology scale proof data is not seeded by default. The local demo seeder
|
||||
only creates the small demo relation repair during normal `local` profile
|
||||
startup.
|
||||
|
||||
To explicitly seed the local mixed scale proof entity catalog, start the backend
|
||||
with:
|
||||
|
||||
```shell
|
||||
--hertzbeat.topology.local-scale-proof-seed=true
|
||||
```
|
||||
|
||||
This is intended for local performance investigation only. Do not treat it as
|
||||
required alpha setup.
|
||||
|
||||
## Known Alpha Limitations
|
||||
|
||||
- The Vite React frontend is still an alpha preview. Parity claims require
|
||||
route, action, API read/write, refresh, context-handoff, and Browser evidence.
|
||||
- Three-signal work is scoped to the alpha cutoff above. Do not describe it as
|
||||
full SigNoz parity unless a later release adds and proves the omitted product
|
||||
capabilities.
|
||||
- Topology large-graph behavior is optimized for inspection with render
|
||||
windows, table drilldown, and optional browser smoke. Continue filing cases
|
||||
where real data feels confusing or slow.
|
||||
- Runtime verification may depend on local H2 or Greptime data shape. Greptime
|
||||
scale proof fixtures are local-only and should not be committed as generated
|
||||
proof artifacts.
|
||||
- Source package rebuilds are expected to pass for the alpha candidate. If a
|
||||
local module or dependency warning blocks packaging, include the focused
|
||||
Maven command and output with the issue report.
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
When filing alpha feedback, include:
|
||||
|
||||
- The route or API endpoint.
|
||||
- The storage mode, for example H2-only or Greptime-backed traces.
|
||||
- The browser viewport and whether the in-app refresh was used.
|
||||
- Expected versus actual node, edge, table, or handoff behavior.
|
||||
- Any focused test, Maven, Vitest, ESLint, or browser-smoke command that
|
||||
reproduces the issue.
|
||||
@@ -1,340 +0,0 @@
|
||||
# HertzBeat Hybrid Collector 完整交付计划
|
||||
|
||||
> 状态:执行基线
|
||||
> 最近更新:2026-07-15
|
||||
> 实施分支:`codex/hybrid-collector-phase0`
|
||||
> 实施工作树:`/Users/zhaoqingran/IdeaProjects/hertzbeat-hybrid-collector`
|
||||
|
||||
## 1. 计划用途
|
||||
|
||||
本文件是 Hybrid Collector 后续实现、评审和验收的唯一范围基线。后续会话不得依赖聊天记忆决定范围:
|
||||
|
||||
1. 每次只推进一个里程碑;
|
||||
2. 开始前先读取本文件和 `progress.md`;
|
||||
3. 新增或删除能力必须先修改本文件并说明原因;
|
||||
4. 验证失败时停在当前里程碑修复,不带失败进入下一项;
|
||||
5. 所有里程碑和最终验收全部通过后,才能声明 Hybrid Collector 完成。
|
||||
|
||||
本计划已经吸收 2026-07-10 Hybrid Collector 架构评审的结论和架构红线,不重新发明另一套采集器。
|
||||
|
||||
## 2. 最终产品定义
|
||||
|
||||
Hybrid Collector 必须同时具备两类能力:
|
||||
|
||||
- **主动采集**:采集主机指标、Prometheus 指标、批准的本地日志,并通过随包提供的官方自动埋点采集 Java 应用链路和 JVM 指标;
|
||||
- **接收转发**:通过标准 OTLP/HTTP 和 OTLP/gRPC 接收应用或其他 OTel SDK/Agent 的 Metrics、Logs、Traces,经过保护和补充后直接发送 HertzBeat Server。
|
||||
|
||||
对用户保持以下产品边界:
|
||||
|
||||
- 一个安装包;
|
||||
- 一个启动入口和系统服务;
|
||||
- 一个 Collector ID;
|
||||
- Java Collector 是唯一注册、配置和生命周期主控;
|
||||
- Go OTel Runtime 不作为第二个 Collector 注册;
|
||||
- Go Runtime 直接向 HertzBeat Server 发送遥测,Java 不代理数据;
|
||||
- 不要求用户另装第三方 OpenTelemetry Collector;
|
||||
- 默认配置可工作,高级能力通过受控语义配置开启,不允许远程下发任意 YAML。
|
||||
|
||||
## 3. 完成后的固定组件清单
|
||||
|
||||
使用 OCB 构建裁剪后的官方组件集合。除下表外,不增加组件。
|
||||
|
||||
### 3.1 Receiver
|
||||
|
||||
| 组件 | 最终用途 | 状态 |
|
||||
| --- | --- | --- |
|
||||
| `otlpreceiver` | OTLP HTTP/gRPC 接收 Metrics、Logs、Traces | 已实现 |
|
||||
| `hostmetricsreceiver` | 主动采集 CPU、内存、磁盘、文件系统、网络、负载、进程指标 | 已实现 |
|
||||
| `prometheusreceiver` | 主动抓取受管 Prometheus/OpenMetrics 端点 | 已实现 |
|
||||
| `filelogreceiver` | 主动采集本地策略批准的日志文件并处理轮转 | 已实现 |
|
||||
|
||||
### 3.2 Processor
|
||||
|
||||
| 组件 | 最终用途 | 状态 |
|
||||
| --- | --- | --- |
|
||||
| `memorylimiterprocessor` | 有界内存和快速拒绝 | 已实现 |
|
||||
| `batchprocessor` | 三信号有界批处理 | 已实现 |
|
||||
| `resourceprocessor` | 写入 HertzBeat Collector/环境等固定资源属性 | 已实现 |
|
||||
| `resourcedetectionprocessor` | 检测 host、OS、process、container 和获准的云环境资源 | 已实现 |
|
||||
| `attributesprocessor` | 删除禁止属性并补充固定低基数属性 | 已实现 |
|
||||
| `filterprocessor` | 使用产品预置规则过滤健康探测等明确噪声;不开放任意远程 OTTL | 已实现 |
|
||||
|
||||
### 3.3 Exporter、Extension 和 Provider
|
||||
|
||||
| 组件 | 最终用途 | 状态 |
|
||||
| --- | --- | --- |
|
||||
| `otlphttpexporter` | 直接发送 HertzBeat OTLP HTTP 接口 | 已实现 |
|
||||
| `filestorage` | 日志 offset 和三信号持久发送队列 | 已实现 |
|
||||
| `healthcheckextension` | Java Supervisor 的本地健康判断 | 已实现 |
|
||||
| `bearertokenauthextension` | 非 loopback OTLP Gateway 模式的入站认证 | 已实现 |
|
||||
| `envprovider` / `fileprovider` / `yamlprovider` | 读取 Java 生成配置和环境 secret | 已实现 |
|
||||
|
||||
### 3.4 自动埋点
|
||||
|
||||
自动埋点不是 Collector Contrib Receiver,但属于完整交付范围:
|
||||
|
||||
| 能力 | 实现方式 | 状态 |
|
||||
| --- | --- | --- |
|
||||
| Java 应用 Trace | 随包提供官方 OpenTelemetry Java Agent | 待实现 |
|
||||
| JVM Runtime Metrics | 官方 Java Agent runtime instrumentation | 待实现 |
|
||||
| 日志 Trace/Span 关联 | Java Agent 注入日志上下文,日志由 File Log 或 OTLP Logs 进入 | 待实现 |
|
||||
| 其他语言 | 提供标准本地 OTLP 地址、Header 和 Resource 配置,不捆绑其他语言 Agent | 待实现文档和 smoke |
|
||||
|
||||
不得通过伪造 Span 或把普通日志推断成 Trace 来满足“三信号”。链路必须来自真实应用执行或标准 OTLP 上游。
|
||||
|
||||
## 4. 明确不在本次交付范围
|
||||
|
||||
以下能力不进入本计划,也不能在实现过程中顺手加入:
|
||||
|
||||
- 完整 Contrib Distribution;
|
||||
- 任意 Raw OpenTelemetry YAML;
|
||||
- 自研 OTLP Receiver、Exporter、队列、存储或日志 tail;
|
||||
- Java 中转遥测数据;
|
||||
- JNI、JNA、cgo 或 fork OpenTelemetry Collector Core;
|
||||
- 自动扫描并注入任意本机进程;
|
||||
- 捆绑 Python、Node.js、.NET 和 Go 自动埋点发行物;
|
||||
- 边缘 Tail Sampling、Span Metrics 和 Service Graph;这些需要全局 Trace 视角,属于 HertzBeat Server;
|
||||
- Kubernetes 全家桶、Docker Stats、云厂商、数据库和中间件 Contrib Receiver;现有 HertzBeat Java Agentless 能力继续负责这些目标,独立需求另行评审;
|
||||
- OpAMP;Manager 到 Java Collector 的现有控制通道继续作为唯一控制面;
|
||||
- 自定义 Go control extension、热重载和 Runtime 在线升级;
|
||||
- pprof、zPages 等默认生产诊断端口;
|
||||
- 远程下发任意绝对日志路径、明文 Token 或证书私钥。
|
||||
|
||||
## 5. 固定里程碑
|
||||
|
||||
### M1:服务端 Desired Config 和安全切换
|
||||
|
||||
状态:**已完成**(`353aeadcd`)
|
||||
|
||||
交付内容:
|
||||
|
||||
- Server 持久化版本化 `ManagedOtelRuntimeConfig`;
|
||||
- 复用现有 Collector 心跳响应下发,不增加新协议和消息类型;
|
||||
- Collector 只接受严格递增 revision;
|
||||
- Java 渲染与随包 Runtime 版本匹配的 YAML;
|
||||
- 新配置先生成 candidate、实际 Runtime validate,再原子激活;
|
||||
- validate 失败不停止当前 Runtime;
|
||||
- readiness 失败只回滚一次 last-known-good;
|
||||
- 配置存储或解析失败不影响普通 Collector 心跳和 Agentless 任务;
|
||||
- 管理 API 支持查询和更新语义配置;
|
||||
- H2、MySQL、PostgreSQL 迁移齐全。
|
||||
|
||||
完成门禁:
|
||||
|
||||
- Manager 配置服务、API、心跳新旧版本兼容测试;
|
||||
- Collector 下发解析、revision、candidate/LKG 和进程切换测试;
|
||||
- Java 25 Checkstyle、Spring Boot 4 AOT;
|
||||
- `git diff --check`。
|
||||
|
||||
### M2:资源识别、归属和固定数据治理
|
||||
|
||||
状态:**已完成**(`652f2a836`)
|
||||
|
||||
交付内容:
|
||||
|
||||
- 加入 `resourcedetectionprocessor`、`attributesprocessor`、`filterprocessor`;
|
||||
- 检测 host、OS、process、container;云环境 detector 仅在显式启用时运行;
|
||||
- 统一三信号上的 Collector ID、runtime version、service/environment 属性;
|
||||
- 明确用户属性、检测属性和 HertzBeat 固定属性的覆盖顺序;
|
||||
- 过滤规则使用产品枚举,不接受任意 OTTL;
|
||||
- 删除认证 Header、Token、cookie 等禁止属性;
|
||||
- 高基数属性不自动提升为资源维度;
|
||||
- 三信号保持相同的资源归属语义。
|
||||
|
||||
完成门禁:
|
||||
|
||||
- 三信号资源属性一致性测试;
|
||||
- 属性覆盖、脱敏、过滤和错误配置测试;
|
||||
- OCB validate 和真实 Runtime export smoke;
|
||||
- 资源识别关闭时无额外云 metadata 请求。
|
||||
|
||||
### M3:主动指标与日志采集闭环
|
||||
|
||||
状态:**已完成**(`f27b2f184`)
|
||||
|
||||
交付内容:
|
||||
|
||||
- Host Metrics 支持采集间隔和 scraper 白名单;
|
||||
- Prometheus 支持最多 32 个受管端点、HTTPS、抓取间隔、超时和静态 Header secret 引用;
|
||||
- File Log 只使用本机管理员定义的 path profile;
|
||||
- realpath、符号链接逃逸、deny path、glob 数、文件数、单行大小和读取速率均有上限;
|
||||
- 日志默认 `start_at: end`,轮转和 copytruncate 行为明确;
|
||||
- 文件 offset 和 exporter queue 共用 owner-only storage;
|
||||
- Manager 展示 source 的 desired、active、rejected 状态及最后错误;
|
||||
- 删除或修改 source 后不会留下失效 pipeline。
|
||||
|
||||
完成门禁:
|
||||
|
||||
- Linux/macOS/Windows 路径策略单元测试;
|
||||
- Prometheus 超时、TLS、错误响应和高基数样本测试;
|
||||
- File Log 轮转、截断、重启 offset、拒绝敏感路径测试;
|
||||
- Host Metrics、Prometheus、File Log 同时运行的真实 Runtime 测试。
|
||||
|
||||
### M4:安全 OTLP 接收和 Gateway 模式
|
||||
|
||||
状态:**已完成**(`323f34b83`,许可证制品修复 `4eab2fa3e`)
|
||||
|
||||
交付内容:
|
||||
|
||||
- Agent 模式默认只监听 `127.0.0.1:4317/4318`;
|
||||
- HTTP/gRPC 三信号、Protobuf/JSON 协议覆盖;
|
||||
- 单请求大小、并发、队列和超时有界;
|
||||
- Gateway 模式必须显式开启,非 loopback 监听必须配置 TLS;
|
||||
- Gateway 模式加入官方 Bearer Token Auth,可选 mTLS;
|
||||
- 认证 secret 只通过环境或本地 secret 文件传递;
|
||||
- 认证失败、非法 payload、超限分别返回明确协议错误;
|
||||
- 入站流量与主动采集共享总资源预算,不能互相饿死。
|
||||
|
||||
完成门禁:
|
||||
|
||||
- OTLP HTTP JSON/Protobuf 和 gRPC 三信号真实客户端测试;
|
||||
- 无鉴权、错误 Token、过期/轮换 Token、TLS 和 mTLS 测试;
|
||||
- 4 MiB 边界、并发拒绝和停止接收时的优雅排空测试;
|
||||
- 默认配置确认没有公网监听。
|
||||
|
||||
### M5:随包 Java 自动埋点
|
||||
|
||||
状态:**进行中**
|
||||
|
||||
交付内容:
|
||||
|
||||
- 固定并审核官方 OpenTelemetry Java Agent 版本;
|
||||
- Agent 作为独立可选制品放入安装包,不进入 Native Java Collector 可达闭包;
|
||||
- 提供生成器输出 `-javaagent`、OTLP endpoint、protocol、resource attributes 和日志关联参数;
|
||||
- Agent 默认发送到本机 Hybrid Collector,不直接绕过 Collector;
|
||||
- 支持用户显式启用/禁用 instrumentation;
|
||||
- 不自动修改用户启动脚本,不 attach 任意运行中 JVM;
|
||||
- 提供 Spring Boot、普通 JAR 和容器三种可复制启动方式;
|
||||
- File Log 与 Agent 注入的 trace/span 字段能够关联;
|
||||
- Agent 缺失、版本不兼容或端口不可用时给出明确诊断。
|
||||
|
||||
完成门禁:
|
||||
|
||||
- 一个真实 Java 示例应用产生 HTTP、数据库、异常和异步 Span;
|
||||
- 同一应用产生 JVM 指标、关联日志和 Trace;
|
||||
- HertzBeat Server 可查询三信号并按 traceId 跳转;
|
||||
- Agent 关闭时应用行为不受 Collector 影响;
|
||||
- Agent LICENSE、NOTICE、SBOM 和漏洞扫描通过。
|
||||
|
||||
### M6:运行状态、内部遥测和故障诊断
|
||||
|
||||
状态:**待实现**
|
||||
|
||||
交付内容:
|
||||
|
||||
- Java 心跳报告 desired revision、active revision、进程状态、PID、重启次数和最后错误;
|
||||
- Go Runtime internal telemetry 直接上报 HertzBeat Server;
|
||||
- 展示 accepted、refused、sent、failed、queue size/capacity 和 file consumer 状态;
|
||||
- 区分配置错误、端口冲突、后端不可用、认证失败、队列满、进程崩溃;
|
||||
- 后端不可用只触发 exporter retry/queue,不重启 Runtime;
|
||||
- Java Agentless 采集在 Go Runtime 降级或失败时继续工作;
|
||||
- 日志中不打印 Token、Authorization、完整用户日志正文或证书内容。
|
||||
|
||||
完成门禁:
|
||||
|
||||
- 每类故障的状态收敛测试;
|
||||
- 心跳负载和 Server 查询不会携带遥测正文;
|
||||
- Runtime crash/restart circuit 和人工恢复测试;
|
||||
- 缺数据、不可用和真实零值能够区分。
|
||||
|
||||
### M7:容量、背压和长期故障恢复
|
||||
|
||||
状态:**待实现**
|
||||
|
||||
交付内容:
|
||||
|
||||
- 三信号共享内存上限和独立 pipeline 可观测性;
|
||||
- exporter 磁盘队列固定容量、owner-only 权限和明确满盘行为;
|
||||
- HertzBeat/Greptime 暂停后不丢已确认进入持久队列的数据;
|
||||
- Runtime 被强杀、Java 被重启、机器重启后均能恢复队列和日志 offset;
|
||||
- 无无限重试线程、无限队列或后台泄漏;
|
||||
- 停止压力后自动恢复,不要求重启 Greptime 或 Collector;
|
||||
- 记录真实 CPU、RSS、磁盘、吞吐和延迟,不伪造容量结论。
|
||||
|
||||
完成门禁:
|
||||
|
||||
- Metrics、Logs、Traces 独立和混合负载;
|
||||
- 后端断开、恢复、慢响应、429、503 和连接重置;
|
||||
- 队列满、磁盘满和损坏存储;
|
||||
- 24 小时 soak;
|
||||
- 停压恢复与无残留长任务证明。
|
||||
|
||||
### M8:Java Native、多平台发行和真实端到端验收
|
||||
|
||||
状态:**待最终复验;基础打包已实现**
|
||||
|
||||
交付内容:
|
||||
|
||||
- Java 25 + Spring Boot 4 Collector;
|
||||
- macOS/Linux arm64/amd64、Windows amd64 Native Java 制品;
|
||||
- 对应平台的 no-CGO Go Runtime;
|
||||
- Java Agent 作为可选独立文件随包发布;
|
||||
- 单服务启动、停止、信号转发、子进程清理和崩溃恢复;
|
||||
- Docker Native 镜像非 root 运行且无 JVM;
|
||||
- 组件 inventory、LICENSE、NOTICE、SBOM、漏洞报告、校验和与可复现构建;
|
||||
- 真实 HertzBeat Server + GreptimeDB 完成用户闭环。
|
||||
|
||||
最终浏览器/API 闭环:
|
||||
|
||||
1. 注册一个 Collector;
|
||||
2. 配置主机指标、Prometheus 和日志 profile;
|
||||
3. 生成 Java Agent 启动参数并启动真实应用;
|
||||
4. 查询主动采集指标和日志;
|
||||
5. 查询应用 Trace、Span 和事件;
|
||||
6. 日志按 traceId 跳转 Trace;
|
||||
7. Trace 查看同时间窗口日志和指标;
|
||||
8. 断开 HertzBeat/Greptime 后恢复,积压自动发送;
|
||||
9. 强杀 Go Runtime,Java Agentless 不停且 Go 自动恢复;
|
||||
10. 重启整个 Collector,offset、队列、配置 revision 和身份保持正确。
|
||||
|
||||
最终发布门禁:
|
||||
|
||||
- 全部聚焦单测、集成测试和真实 Runtime 测试;
|
||||
- Java 25 Checkstyle、Spring Boot 4 AOT、Native Image;
|
||||
- 五个平台布局与启动合同;
|
||||
- OCB inventory、license、SBOM、`govulncheck`、校验和和可复现构建;
|
||||
- HertzBeat/Greptime 三信号真实写入和查询;
|
||||
- 24 小时 soak 和故障恢复;
|
||||
- `git diff --check`、无硬编码 CJK、无本地产物进入提交;
|
||||
- 人工代码评审确认没有重复抽象、自研替代官方组件或未使用代码。
|
||||
|
||||
## 6. 实施顺序和停止规则
|
||||
|
||||
固定顺序为 `M1 → M2 → M3 → M4 → M5 → M6 → M7 → M8`,不得跳过失败门禁并继续。
|
||||
|
||||
每个里程碑执行循环:
|
||||
|
||||
1. 写最小失败合同;
|
||||
2. 做满足合同的最小实现;
|
||||
3. 运行聚焦验证;
|
||||
4. 修复全部失败;
|
||||
5. 运行该里程碑的真实 Runtime 或端到端证明;
|
||||
6. 更新本文件状态和 `progress.md`;
|
||||
7. 只提交本里程碑相关源码、测试和文档;
|
||||
8. 进入下一里程碑。
|
||||
|
||||
只有以下情况可以暂停并询问维护者:
|
||||
|
||||
- 需要改变本文件的固定交付范围;
|
||||
- 需要新增官方组件或自研组件;
|
||||
- 出现许可证、安全、跨平台或后端契约阻塞;
|
||||
- 需要外部 CI、签名或发布权限;
|
||||
- 真实数据证明原验收指标不可实现,需要维护者决定取舍。
|
||||
|
||||
## 7. 完成定义
|
||||
|
||||
以下条件必须同时成立:
|
||||
|
||||
- 主动采集主机指标、Prometheus 指标和批准的文件日志;
|
||||
- 随包 Java Agent 从真实应用采集 Trace、JVM 指标并关联日志;
|
||||
- 安全接收并转发外部 OTLP 三信号;
|
||||
- 三信号拥有一致资源身份和 HertzBeat Collector 归属;
|
||||
- Server Desired Config 能安全下发、验证、切换和回滚;
|
||||
- 后端中断、进程崩溃和机器重启后能够恢复;
|
||||
- Java Agentless 采集不依赖 Go Runtime 健康;
|
||||
- JVM 和 Native Collector 均可运行,发布包覆盖目标平台;
|
||||
- 真实 HertzBeat + Greptime 完成三信号查询与跨信号跳转;
|
||||
- 所有 M1 到 M8 门禁通过;
|
||||
- 代码通过人工可维护性评审,无未使用实现和临时兼容层。
|
||||
|
||||
未同时满足上述条件时,不得使用“Hybrid Collector 已完成”或“可正常替代 OTel Collector”作为结论。
|
||||
@@ -1,46 +0,0 @@
|
||||
# Application Instrumentation API
|
||||
|
||||
HertzBeat exposes one public application-instrumentation route family. The
|
||||
paths are unversioned; internal implementation package names are not alternate
|
||||
API surfaces. Every response uses the ordinary HertzBeat `Message<T>` envelope.
|
||||
|
||||
## Public endpoints
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/instrumentation/catalog` | Discover source groups, sources, and selectable recipes |
|
||||
| `GET` | `/api/instrumentation/intake-profiles` | Discover non-secret OTLP intake destinations and transport requirements |
|
||||
| `POST` | `/api/instrumentation/render` | Render structured onboarding blocks for a recipe and intake profile |
|
||||
| `POST` | `/api/instrumentation/detect` | Detect scoped Metrics, Logs, and Traces reception |
|
||||
|
||||
There are no public path-version aliases. Clients must not prepend a version
|
||||
segment or construct paths from implementation class or package names.
|
||||
|
||||
## Authorization
|
||||
|
||||
Catalog and intake-profile discovery are available to `admin`, `user`, and
|
||||
`guest` roles. Rendering and detection require `admin` or `user`. Shipped
|
||||
Sureness configurations enumerate the four paths explicitly so authorization
|
||||
does not imply a second route family.
|
||||
|
||||
## Wire compatibility
|
||||
|
||||
The existing `schemaVersion` field remains part of the request and response
|
||||
payloads. It is a payload compatibility guard, not a URL version. This contract
|
||||
does not add, remove, or rename any wire field or enum value.
|
||||
|
||||
- Catalog data contains `schemaVersion`, `groups`, `sources`, and `recipes`.
|
||||
- Intake-profile discovery returns explicit non-secret destinations,
|
||||
transports, authentication requirements, and availability state.
|
||||
- Render requests select a source or recipe, environment, platform, intake
|
||||
profile, and service identity. Responses contain structured blocks and
|
||||
declared secret placeholders; no token value is returned.
|
||||
- Detection requests carry the same selection and identity scope plus
|
||||
`startedAt`. Responses distinguish `waiting`, `received`, `unsupported`,
|
||||
`unavailable`, and `error`, and return bounded polling and typed query-jump
|
||||
context.
|
||||
|
||||
Clients should consume server-provided catalog choices and intake profiles
|
||||
rather than inventing endpoints, package coordinates, health states, or signal
|
||||
support. Unknown or unavailable data must remain distinguishable from healthy
|
||||
or zero-valued data.
|
||||
@@ -1,49 +0,0 @@
|
||||
# Observability Query Context v1
|
||||
|
||||
This document freezes the optional query context shared by the Metrics, Logs, and Traces operator
|
||||
APIs. It is separate from the application instrumentation catalog, render, and detection contract;
|
||||
that contract remains unchanged.
|
||||
|
||||
## Context hierarchy
|
||||
|
||||
Queries narrow data in this order:
|
||||
|
||||
`Collector -> service.name -> service.namespace -> deployment.environment.name -> instance -> endpoint`
|
||||
|
||||
Each supplied dimension is an exact additional constraint. A row, series, log record, or span that
|
||||
does not carry a supplied dimension does not match. An implementation must return an honest empty
|
||||
result instead of falling back to the broader service context.
|
||||
|
||||
## Frozen fields
|
||||
|
||||
| Query field | Meaning | Metrics | Logs | Traces |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `instance` | OTel Resource `service.instance.id` | data-point label `service_instance_id` | Resource `service.instance.id` | Resource `service.instance.id` |
|
||||
| `endpoint` | Low-cardinality HTTP route template | data-point label `http_route` | LogRecord attribute `http.route` | Span attribute `http.route` |
|
||||
|
||||
`endpoint` is a route template such as `/checkout`. It excludes the HTTP method, query string,
|
||||
fragment, and concrete URL. Values such as `POST /checkout` and `/checkout?order=42` are invalid.
|
||||
RPC method, messaging destination, and database operation meanings are not overloaded into this
|
||||
field. Supporting those protocols requires new versioned fields.
|
||||
|
||||
The dedicated fields cannot compete with the same key in a free-form resource, attribute, or metric
|
||||
filter. Such a request is rejected instead of silently applying ambiguous constraints.
|
||||
|
||||
## API coverage
|
||||
|
||||
- Metrics console request and returned context.
|
||||
- Historical log list, surrounding context, overview, trace coverage, trend, and group-by queries.
|
||||
- Live log SSE filtering.
|
||||
- Trace list, overview, group-by, detail, and span queries. Detail and span requests retain the
|
||||
exact trace identifier while also applying the supplied discovery context; an optional `spanId`
|
||||
further narrows the row set.
|
||||
|
||||
Storage access remains behind the existing query services and storage adapters. Controllers only
|
||||
bind the HTTP parameters and construct the storage-neutral context; they contain no database query
|
||||
language or storage-specific SQL.
|
||||
|
||||
## Compatibility
|
||||
|
||||
These fields are optional additions to the existing query APIs. Omitting both retains the prior
|
||||
behavior. Changing either field's semantic mapping, accepting an HTTP method in `endpoint`, or
|
||||
reusing `endpoint` for a non-HTTP protocol requires a new contract version.
|
||||
@@ -1,215 +0,0 @@
|
||||
# Product Design User Validation Loop
|
||||
|
||||
This runbook makes Product Design part of the HertzBeat 2.0 user-layer
|
||||
validation loop. It is not a screenshot-only audit pass and it is not a visual
|
||||
polish layer. Use it to close product experience loops around real operator
|
||||
workflows, honest state feedback, data lifecycle clarity, and repeatable
|
||||
actions.
|
||||
|
||||
## Product Context
|
||||
|
||||
Start every Product Design workflow from saved context:
|
||||
|
||||
- Product Design state: `$CODEX_HOME/state/plugins/product-design/user-context.md`
|
||||
- Local screenshot/reference assets:
|
||||
`$CODEX_HOME/state/plugins/product-design/assets/`
|
||||
- Repo guidance: `AGENTS.md` and the latest active slice in `progress.md`
|
||||
- Frontend implementation: `web-app/src/app`, `web-app/src/layout`,
|
||||
`web-app/src/features`, and `web-app/src/shared`
|
||||
- Theme and UI constraints: `web-app/src/app/theme`, feature CSS modules,
|
||||
shared Ant Design primitives, source-backed icon packages, and runtime i18n
|
||||
catalogs
|
||||
|
||||
Saved context should include the HertzBeat product position, operator audience,
|
||||
competitive references, design preferences, code paths, screenshots, design
|
||||
system constraints, and mainstream observability console expectations. Future
|
||||
audits, ideation, prototypes, and implementation work must prefer that context
|
||||
unless the user explicitly asks for a different direction.
|
||||
|
||||
## Required Workflow Selection
|
||||
|
||||
Use the smallest workflow that matches the problem.
|
||||
|
||||
Small issue:
|
||||
|
||||
```text
|
||||
audit -> small fix -> focused test -> Browser verification -> progress.md
|
||||
```
|
||||
|
||||
Use this for a concrete user-visible problem in one flow, such as confusing
|
||||
error copy, an unclear button, an empty state that hides the data lifecycle, a
|
||||
broken cancel path, or a refresh/status mismatch.
|
||||
|
||||
Systemic issue:
|
||||
|
||||
```text
|
||||
user-context/get-context -> research -> audit -> ideate -> prototype -> design-qa -> small implementation slice -> progress.md
|
||||
```
|
||||
|
||||
Use this when the issue exposes a broader product pattern, such as monitor
|
||||
authoring confusion, entity lifecycle ambiguity, alert rule authoring friction,
|
||||
or settings forms that repeatedly fail to explain required state and side
|
||||
effects.
|
||||
|
||||
Do not skip `audit` or `research` before broad UI changes. Do not use Product
|
||||
Design to decorate an existing screen while leaving the real workflow,
|
||||
state feedback, error explanation, or data lifecycle unresolved.
|
||||
|
||||
## Design Brief Gate
|
||||
|
||||
Before any design judgment, experience redesign, prototype exploration, or UI
|
||||
implementation, confirm a brief through Product Design `get-context`.
|
||||
|
||||
The brief must identify:
|
||||
|
||||
- Target user and operating context.
|
||||
- Workflow problem being solved.
|
||||
- Existing route, screenshot, design system, or component reference.
|
||||
- Required interaction fidelity.
|
||||
- Backend/data lifecycle states that must stay honest.
|
||||
- Acceptance evidence expected before implementation is considered done.
|
||||
|
||||
If the brief is missing or under-specified, stop and ask for only the missing
|
||||
context. Do not edit UI from an unconfirmed brief.
|
||||
|
||||
## Research Gate
|
||||
|
||||
For systemic problems, perform product and user research before designing a
|
||||
direction. Cover:
|
||||
|
||||
- Operator mental model and repeated-use pressure.
|
||||
- Comparable observability platform patterns, especially Grafana, Datadog,
|
||||
SigNoz, and OpenTelemetry ecosystem consoles where relevant.
|
||||
- State, error, loading, empty, and recovery language.
|
||||
- Help affordance density and progressive disclosure.
|
||||
- Safe authoring, save/cancel, detect/test, refresh, and status convergence.
|
||||
|
||||
Prioritize monitor, entity, alert, and settings workflows because these are
|
||||
high-frequency operator surfaces.
|
||||
|
||||
## Real Flow Audit
|
||||
|
||||
Audits must use current product evidence:
|
||||
|
||||
- Production frontend build.
|
||||
- Real HertzBeat backend, using H2/Greptime/warehouse data honestly.
|
||||
- In-app Browser screenshots for the current route state.
|
||||
- Step notes tied to every screenshot and finding.
|
||||
- Theme preflight before accepting screenshots: record `html[data-theme]`,
|
||||
`body[data-theme]`, `--background`, `--card`, and `--ops-background`.
|
||||
HertzBeat's default audit baseline is `dark-ops` (`--background: 220 11%
|
||||
5%`, `--ops-background: #0b0c0e`). If an existing Browser origin has
|
||||
persisted `light-ops`, do not use those screenshots as dark-baseline
|
||||
evidence; use an isolated localhost port/origin or explicitly document that
|
||||
the slice is auditing light theme.
|
||||
|
||||
Audit at least the relevant parts of:
|
||||
|
||||
- Login and guarded deep-link return.
|
||||
- Navigation and retained query/context parameters.
|
||||
- Create, edit, save, cancel, detect/test, refresh, and route revisit.
|
||||
- Status convergence without browser reload when the UI promises refresh.
|
||||
- Empty, loading, unavailable, failed, and recovered states.
|
||||
- Help question marks, button labels, destructive confirmations, and action
|
||||
meaning.
|
||||
- Card hierarchy, table density, form grouping, text overflow, responsive
|
||||
behavior, keyboard/focus semantics, and visible accessibility risks.
|
||||
|
||||
Findings must cite screenshot names and step numbers. Do not evaluate from
|
||||
memory.
|
||||
|
||||
## Ideation Gate
|
||||
|
||||
When audit and research show a systemic UX problem, do not immediately rewrite
|
||||
product code. Generate two or three different directions first. Useful default
|
||||
directions for HertzBeat include:
|
||||
|
||||
- Dense operations console: fastest scanning and repeat actions.
|
||||
- Diagnosis-first evidence chain: status, raw backend reason, metric evidence,
|
||||
history availability, and recovery steps.
|
||||
- Guided authoring: lower misoperation risk for complex create/edit/test flows.
|
||||
|
||||
Choose a direction before prototyping or implementing.
|
||||
|
||||
## Prototype Gate
|
||||
|
||||
For high-impact flows, build a local interactive prototype before production
|
||||
implementation. Prototype only the smallest slice needed to validate interaction
|
||||
and information architecture. Good candidates include:
|
||||
|
||||
- Monitor create, detect, detail refresh, and history chart availability.
|
||||
- Alert rule authoring, preview/test, save/cancel, and template selection.
|
||||
- Entity create/edit/detail and definition import/edit handoff.
|
||||
- Settings forms with required fields, disabled save, cancel, and no-write
|
||||
guarantees.
|
||||
|
||||
The prototype is evidence for product decisions. It does not replace
|
||||
production code, tests, or Browser verification.
|
||||
|
||||
## Implementation Gate
|
||||
|
||||
Use Product Design `image-to-code` or production UI implementation only after
|
||||
there is a confirmed brief plus a concrete visual or interaction target:
|
||||
screenshot, selected visual direction, prototype, Figma frame, or mockup.
|
||||
|
||||
Implementation must preserve:
|
||||
|
||||
- Existing HertzBeat route/controller/view-model patterns.
|
||||
- Shared `@hertzbeat/ui` primitives and source-backed icons.
|
||||
- Dense operational layouts and stable dimensions.
|
||||
- Clear data lifecycle and backend-state honesty.
|
||||
- Runtime i18n rules and no hardcoded localized UI copy outside approved
|
||||
locale resources.
|
||||
|
||||
Do not introduce marketing-page composition, decorative chrome, fake zero
|
||||
states, fake healthy states, broad refactors, or untested UI rewrites.
|
||||
|
||||
## Design QA Gate
|
||||
|
||||
After implementation, run design QA against the target evidence:
|
||||
|
||||
- Original audit screenshot(s).
|
||||
- Selected direction or prototype.
|
||||
- Actual production page rendered through the in-app Browser.
|
||||
- Focused automated tests and the smallest relevant Browser proof.
|
||||
|
||||
Check layout, hierarchy, density, text overflow, responsive behavior, control
|
||||
states, focus/keyboard semantics, empty/error/loading states, and visible
|
||||
accessibility risks. P0, P1, and P2 design issues must be fixed before the
|
||||
slice is treated as passing.
|
||||
|
||||
## Artifact And Progress Policy
|
||||
|
||||
Persist Product Design evidence locally. Use clear names that can be understood
|
||||
without opening the file.
|
||||
|
||||
Recommended local artifact layout:
|
||||
|
||||
```text
|
||||
.tmp/product-design/
|
||||
<slice-id>/
|
||||
screenshots/
|
||||
audit.md
|
||||
research.md
|
||||
directions.md
|
||||
prototype-notes.md
|
||||
design-qa.md
|
||||
```
|
||||
|
||||
Do not commit `.tmp/`, screenshots, reports, runtime logs, local proof data, or
|
||||
generated artifacts unless maintainers explicitly ask. The active slice in
|
||||
`progress.md` must record:
|
||||
|
||||
- Saved Product Design context used.
|
||||
- Research sources or research scope.
|
||||
- Audit route, backend, screenshots, and steps.
|
||||
- Theme preflight evidence and whether the screenshot baseline is `dark-ops`
|
||||
or an intentional alternate theme.
|
||||
- Findings by severity and user impact.
|
||||
- Selected direction and prototype target, when applicable.
|
||||
- Implementation files changed.
|
||||
- Focused tests and Browser verification.
|
||||
- Remaining risks and next slice.
|
||||
|
||||
The release gate is not complete until Product Design evidence supports the
|
||||
actual operator workflow, not just a single screenshot or isolated component.
|
||||
@@ -1,184 +0,0 @@
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
contributor license agreements. See the NOTICE file distributed with
|
||||
this work for additional information regarding copyright ownership.
|
||||
The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
(the "License"); you may not use this file except in compliance with
|
||||
the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
# Lightweight React Frontend Architecture
|
||||
|
||||
## Status And Reference Baselines
|
||||
|
||||
This document is the implementation contract for the maintainable React
|
||||
single-page application that replaces the deprecated Next.js frontend.
|
||||
|
||||
- Implementation baseline: `2.0.0` at `2de48d2caa43fa58fd93e71b0e22e7d4f431c9d5`.
|
||||
- Angular behavior and release reference: `apache/master` at
|
||||
`9bd2796119b558cff2834dd35e3d21f2eb4a53a4`.
|
||||
- Transitional signal reference: `feature/angular-three-signals` at
|
||||
`5d523ddce322ad82ab1bcb65b3dbe516b0a1b97d`.
|
||||
|
||||
The branches have materially diverged. `apache/master` is a behavior and
|
||||
packaging reference, not a merge source. Relevant fixes are selected and
|
||||
reimplemented; the branch is never merged wholesale into `2.0.0`.
|
||||
|
||||
## Sources Of Truth
|
||||
|
||||
Use the following precedence for implementation decisions:
|
||||
|
||||
1. `apache/master` defines established operator workflows, stable URLs,
|
||||
monitor hierarchy behavior, translations, and the external `web-app/dist`
|
||||
release layout.
|
||||
2. `2.0.0` defines entity, topology, OTLP signals, workspace and token scope,
|
||||
GreptimeDB integration, and the current HttpOnly UI session boundary.
|
||||
3. The Angular three-signal transition defines the simple onboarding and
|
||||
query workflow that does not require a user to understand storage internals.
|
||||
4. The removed Next.js implementation supplied only proven DTOs and pure
|
||||
algorithms. Its page structure, private UI framework, BFF, compatibility
|
||||
scaffolding, and implementation-detail tests were not migration sources.
|
||||
|
||||
## Scope Allowlist
|
||||
|
||||
### Established Workflows
|
||||
|
||||
- Session, lock, logout, permissions, locale, theme, shell notifications, and
|
||||
account actions.
|
||||
- Dashboard and Overview as aliases for one implementation, Bulletin, and the
|
||||
public status page.
|
||||
- Monitor list, create, edit, copy, detect, bulk operations, detail, realtime,
|
||||
history, and favorite metrics.
|
||||
- Alert center, thresholds, groups, inhibition, silence, notice, and integration.
|
||||
- Collector, monitor definitions, labels, plugins, status, system configuration,
|
||||
and message server settings.
|
||||
- Dynamic monitor categories loaded from `/apps/hierarchy`.
|
||||
|
||||
### Approved 2.0 Extensions
|
||||
|
||||
- Entity list, create, edit, detail, import, discovery, and definition.
|
||||
- Topology query, graph inspection, table drilldown, and detail interaction.
|
||||
- Unified OTLP integration plus Metrics, Logs, and Traces workbenches.
|
||||
- Object store and token settings required by observability workflows.
|
||||
- Workspace, token scope, GreptimeDB, performance protection, and cross-signal
|
||||
navigation context.
|
||||
|
||||
### Excluded From The New Frontend
|
||||
|
||||
- Actions, Incidents, Explorer, the standalone Events experiment, AI Chat UI,
|
||||
MCP settings UI, and the signal dashboard draft/composition editor.
|
||||
- The production UI Lab, parity framework, source-string ownership assertions,
|
||||
generated demo states, in-memory fallbacks, and permanent compatibility pages.
|
||||
- Next.js route handlers, the standalone Node server, the dedicated Next image,
|
||||
and the Next-specific gateway split.
|
||||
|
||||
Deleted routes use the common not-found page. Compatibility URLs are explicit
|
||||
redirects in the route registry and never duplicate a page implementation.
|
||||
|
||||
## Target Stack And Production Shape
|
||||
|
||||
- React 19 with Vite and React Router declarative routing.
|
||||
- TanStack Query as the only server-state lifecycle.
|
||||
- Ant Design React as the primitive component system. HertzBeat only owns
|
||||
domain composites such as the time toolbar, signal workbench, data state,
|
||||
attribute table, and topology canvas.
|
||||
- i18next with locale JSON resources based on the five `apache/master` locales.
|
||||
- CSS Modules and Ant Design theme tokens; no Tailwind utility layer.
|
||||
- ECharts, G6, CodeMirror, and the log virtualizer load only in their owning
|
||||
features.
|
||||
- Vitest, React Testing Library, MSW, and Playwright for behavior-level tests.
|
||||
- Node 22 and pnpm 10 for development and CI. Release assemblies run only the
|
||||
JVM and never ship a Node runtime or a separate frontend service.
|
||||
|
||||
Vite emits `web-app/dist`. Release assemblies copy that directory to the
|
||||
external distribution `dist/`, matching `apache/master`. Spring serves the SPA
|
||||
and `/api` from one origin. The frontend is not embedded into the startup jar,
|
||||
and no Maven frontend plugin is introduced.
|
||||
|
||||
## Source Ownership
|
||||
|
||||
```text
|
||||
web-app/src/
|
||||
app/ bootstrap, providers, route registry, startup
|
||||
core/ API client, session, errors, i18n, permissions
|
||||
layout/ basic, passport, and blank layouts
|
||||
features/ domain-owned routes, API, model, hooks, pages, components
|
||||
shared/ domain-neutral composites, forms, time, and query context
|
||||
assets/i18n/ locale JSON resources
|
||||
```
|
||||
|
||||
Each feature owns its route, API, types, hooks, pages, components, and tests.
|
||||
`core` and `shared` cannot depend on a feature. Features cannot import another
|
||||
feature's internals. Cross-feature behavior is exposed through a small explicit
|
||||
feature entry.
|
||||
|
||||
Pages orchestrate route state and feature hooks. API modules perform requests
|
||||
and response mapping. Hooks own TanStack Query lifecycles. Models contain domain
|
||||
types and pure transformations. Components render visible behavior. A
|
||||
controller or view model is introduced only when it removes real complexity;
|
||||
parameter forwarding is not an abstraction.
|
||||
|
||||
The typed route registry is the only static source for routes, navigation,
|
||||
breadcrumbs, permissions, canonical URLs, and redirects. Dynamic monitor
|
||||
categories are appended after startup and are not duplicated in static source.
|
||||
|
||||
## Session And Routing Boundaries
|
||||
|
||||
The React migration must not copy the Angular local-storage token boundary.
|
||||
Spring exposes UI session create, read, refresh, and delete endpoints backed by
|
||||
the existing account service. Access and refresh tokens remain HttpOnly
|
||||
cookies. A filter before Sureness adapts the UI access cookie to an internal
|
||||
Bearer header only when the request has no explicit `Authorization` header.
|
||||
Existing API tokens, OTLP Bearer authentication, workspace claims, and token
|
||||
scope remain unchanged.
|
||||
|
||||
Cookie-authenticated mutations require same-origin and CSRF validation. Query
|
||||
requests may refresh through one single-flight request and retry once.
|
||||
Mutations are never replayed automatically. Same-origin SSE uses the same
|
||||
session filter and reconnects with bounded backoff.
|
||||
|
||||
SPA fallback accepts only `GET` or `HEAD` HTML navigation for registered UI
|
||||
prefixes. API, actuator, Swagger, H2, OTLP, SSE, and missing static resources
|
||||
must retain their own non-HTML errors.
|
||||
|
||||
## Maintainability Gates
|
||||
|
||||
- Page orchestration files: at most 300 lines.
|
||||
- Ordinary components, hooks, API modules, and models: at most 400 lines.
|
||||
- Every production file: at most 600 lines.
|
||||
- Feature public entries: at most 100 lines.
|
||||
- Functions: at most 100 lines and cyclomatic complexity at most 12.
|
||||
- No page or component calls `fetch` directly.
|
||||
- No production mock, in-memory fallback, permanent `compat`, `legacy`, or
|
||||
`deprecated` implementation directory.
|
||||
- No tests that read production source and assert source strings.
|
||||
- Knip and an explicit dependency-boundary check run in CI.
|
||||
- Production frontend target: no more than 80,000 lines; hard limit 100,000.
|
||||
- The final migration must delete at least 40 percent of the previous
|
||||
production frontend source and must not retain the deprecated Next.js tree.
|
||||
- Initial shell JavaScript: at most 450 KiB gzip. ECharts, G6, and CodeMirror
|
||||
are forbidden from the shell chunk.
|
||||
|
||||
The budgets are architecture limits, not encouragement to split cohesive code
|
||||
into one-line wrappers. Any temporary exception must name an owner, reason, and
|
||||
expiry milestone and must be removed before cutover.
|
||||
|
||||
## Delivery Gates
|
||||
|
||||
Implementation proceeds in vertical milestones: foundation; session and
|
||||
release; established shell and dashboard; monitors, alerts, and settings;
|
||||
entities and topology; OTLP signals; then cutover and deletion. Each milestone
|
||||
must pass focused tests, production build, and its real-backend browser flow
|
||||
before the next milestone starts.
|
||||
|
||||
The final release must contain one JVM runtime, external hashed SPA assets,
|
||||
working deep links, JSON API 404 responses, authenticated SSE, explicit Bearer
|
||||
precedence, and no Node runtime or Next-specific gateway.
|
||||
+28
-7
@@ -1,14 +1,35 @@
|
||||
Please add the corresponding e2e (aka end-to-end) test cases if you add or update APIs.
|
||||
|
||||
## How to work
|
||||
* Start and watch the [docker-compose](https://docs.docker.com/compose/) via [the script](script/start.sh)
|
||||
* Run the e2e testing via [api-testing](https://github.com/LinuxSuRen/api-testing)
|
||||
* It will run the test cases from top to bottom
|
||||
* You can add the necessary asserts to it
|
||||
* Start and watch the [Docker Compose](https://docs.docker.com/compose/) environment
|
||||
* The Compose file is `e2e/docker-compose.yml`
|
||||
* The `testing` container uses [api-testing](https://github.com/LinuxSuRen/api-testing)
|
||||
* The main test definition file is `e2e/testsuite.yaml`
|
||||
* Before starting the suite locally, build the `apache/hertzbeat:test` image from the repository root:
|
||||
|
||||
```bash
|
||||
mvn clean -B package -Prelease -Dmaven.test.skip=true --file pom.xml
|
||||
docker build -t apache/hertzbeat:test -f script/docker/server/Dockerfile ./dist
|
||||
```
|
||||
|
||||
* Run the E2E tests via [api-testing](https://github.com/LinuxSuRen/api-testing)
|
||||
* The test cases run from top to bottom
|
||||
* You can add the necessary assertions there
|
||||
* Test data files are under `e2e/data/`
|
||||
* The test report is generated at `e2e/report/report.md`
|
||||
* HertzBeat logs are written to `e2e/logs/`
|
||||
|
||||
## Run locally
|
||||
Please follow these steps if you want to run the e2e testing locally.
|
||||
Please follow these steps if you want to run the E2E tests locally.
|
||||
|
||||
> Please make sure you have installed docker-compose v2
|
||||
> Please make sure that Docker Compose v2 is installed
|
||||
|
||||
* Change the directory to `e2e`, then execute `./script/start.sh`
|
||||
* Build the local `apache/hertzbeat:test` image from the repository root
|
||||
* Change the directory to `e2e`, then run:
|
||||
|
||||
```bash
|
||||
docker compose up --exit-code-from testing --remove-orphans
|
||||
```
|
||||
|
||||
* If your environment uses the standalone Compose binary, use `docker-compose` instead of `docker compose`
|
||||
* After the run, check `e2e/report/report.md` and `e2e/logs/` for the test result and runtime logs
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
testing:
|
||||
image: ghcr.io/linuxsuren/api-testing:v0.0.17
|
||||
image: ghcr.io/linuxsuren/api-testing:v0.0.19
|
||||
environment:
|
||||
SERVER: http://hertzbeat:1157
|
||||
container_name: e2e-testing
|
||||
|
||||
@@ -29,6 +29,7 @@ param:
|
||||
monitorUDP: "{{randAlpha 6}}"
|
||||
monitorWebsite: "{{randAlpha 6}}"
|
||||
monitorFTP: "{{randAlpha 6}}"
|
||||
apiTokenName: "e2e-token-{{randAlpha 6}}"
|
||||
labelName: "{{randAlpha 3}}"
|
||||
items:
|
||||
- name: login
|
||||
@@ -69,6 +70,8 @@ items:
|
||||
- name: missing-auth-header
|
||||
request:
|
||||
api: /api/monitors
|
||||
header:
|
||||
Accept: application/json
|
||||
expect:
|
||||
statusCode: 401
|
||||
- name: createSitemapMonitor
|
||||
@@ -385,6 +388,53 @@ items:
|
||||
header:
|
||||
Authorization: Bearer {{.login.data.token}}
|
||||
|
||||
## API Token Management
|
||||
- name: generateApiToken
|
||||
request:
|
||||
api: /api/account/token/generate?name={{.apiTokenName}}&expireSeconds=-1
|
||||
method: POST
|
||||
header:
|
||||
Authorization: Bearer {{.login.data.token}}
|
||||
expect:
|
||||
bodyFieldsExpect:
|
||||
code: "0"
|
||||
- name: listApiTokens
|
||||
request:
|
||||
api: /api/account/token
|
||||
header:
|
||||
Authorization: Bearer {{.login.data.token}}
|
||||
expect:
|
||||
bodyFieldsExpect:
|
||||
code: "0"
|
||||
data.0.name: "{{.apiTokenName}}"
|
||||
data.0.creator: "admin"
|
||||
- name: listApiTokensByGeneratedToken
|
||||
request:
|
||||
api: /api/account/token
|
||||
header:
|
||||
Authorization: Bearer {{.generateApiToken.data.token}}
|
||||
expect:
|
||||
bodyFieldsExpect:
|
||||
code: "0"
|
||||
data.0.name: "{{.apiTokenName}}"
|
||||
- name: useGeneratedTokenToCallApi
|
||||
request:
|
||||
api: /api/monitors?pageIndex=0&pageSize=8
|
||||
header:
|
||||
Authorization: Bearer {{.generateApiToken.data.token}}
|
||||
expect:
|
||||
bodyFieldsExpect:
|
||||
code: "0"
|
||||
- name: deleteApiToken
|
||||
request:
|
||||
api: /api/account/token/{{(index .listApiTokens.data 0).id | int64}}
|
||||
method: DELETE
|
||||
header:
|
||||
Authorization: Bearer {{.login.data.token}}
|
||||
expect:
|
||||
bodyFieldsExpect:
|
||||
code: "0"
|
||||
|
||||
## Config
|
||||
- name: getConfigEmail
|
||||
request:
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
~ Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
~ contributor license agreements. See the NOTICE file distributed with
|
||||
~ this work for additional information regarding copyright ownership.
|
||||
~ The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
~ (the "License"); you may not use this file except in compliance with
|
||||
~ the License. You may obtain a copy of the License at
|
||||
~
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.apache.hertzbeat</groupId>
|
||||
<artifactId>hertzbeat</artifactId>
|
||||
<version>2.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>hertzbeat-ai-gateway</artifactId>
|
||||
<name>${project.artifactId}</name>
|
||||
<description>HertzBeat Agent Gateway shell and read-only runtime boundary.</description>
|
||||
|
||||
<properties>
|
||||
<spring-ai.version>2.0.0</spring-ai.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.apache.hertzbeat</groupId>
|
||||
<artifactId>hertzbeat-common-spring</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.hertzbeat</groupId>
|
||||
<artifactId>hertzbeat-common-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.hertzbeat</groupId>
|
||||
<artifactId>hertzbeat-base</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.hertzbeat</groupId>
|
||||
<artifactId>hertzbeat-manager</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.hertzbeat</groupId>
|
||||
<artifactId>hertzbeat-alerter</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.hertzbeat</groupId>
|
||||
<artifactId>hertzbeat-warehouse</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.hertzbeat</groupId>
|
||||
<artifactId>hertzbeat-observability</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.usthe.sureness</groupId>
|
||||
<artifactId>spring-boot3-starter-sureness</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-lang3</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>commons-codec</groupId>
|
||||
<artifactId>commons-codec</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-model</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-openai</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-bom</artifactId>
|
||||
<version>${spring-ai.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<skip>true</skip>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
-254
@@ -1,254 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.alert;
|
||||
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.hertzbeat.alert.service.AlertAnalysisPolicyService;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.InvokeCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.ReplyMode;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommandRouter;
|
||||
import org.apache.hertzbeat.ai.gateway.channel.core.ChannelId;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentAlertIncidentContext;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentResponseLanguage;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentTargetRef;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.GatewayEnvelope;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.UserInput;
|
||||
import org.apache.hertzbeat.ai.gateway.identity.AgentActor;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.AgentRuntimeEntryType;
|
||||
import org.apache.hertzbeat.ai.gateway.text.GatewayText;
|
||||
import org.apache.hertzbeat.common.constants.CommonConstants;
|
||||
import org.apache.hertzbeat.common.entity.alerter.AlertAnalysisPolicy;
|
||||
import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
|
||||
import org.apache.hertzbeat.common.util.JsonUtil;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Groups persisted single-alert facts and routes qualifying incidents through the standard Gateway command path.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class AgentAlertAnalysisEventHandler {
|
||||
|
||||
private static final String ALERT_ANALYSIS_PROMPT_TEMPLATE = """
|
||||
Analyze this HertzBeat alert incident. Correlate the alerts, gather relevant monitoring evidence with \
|
||||
tools, identify likely causes, and provide prioritized checks. Incident context:
|
||||
%s""";
|
||||
|
||||
private final AlertAnalysisPolicyService policyService;
|
||||
private final GatewayCommandRouter commandRouter;
|
||||
private final Map<AnalysisWindowKey, AnalysisWindow> windows = new ConcurrentHashMap<>();
|
||||
private final ExecutorService executor = new ThreadPoolExecutor(2, 2, 0, TimeUnit.MILLISECONDS,
|
||||
new ArrayBlockingQueue<>(256), Thread.ofPlatform().name("agent-alert-analysis-", 0).factory(),
|
||||
new ThreadPoolExecutor.AbortPolicy());
|
||||
|
||||
public AgentAlertAnalysisEventHandler(AlertAnalysisPolicyService policyService,
|
||||
GatewayCommandRouter commandRouter) {
|
||||
this.policyService = policyService;
|
||||
this.commandRouter = commandRouter;
|
||||
}
|
||||
|
||||
@EventListener
|
||||
public void onSingleAlertCreated(SingleAlert.CreatedEvent event) {
|
||||
SingleAlert alert = event.alert();
|
||||
if (alert == null || !CommonConstants.ALERT_STATUS_FIRING.equals(alert.getStatus())) {
|
||||
return;
|
||||
}
|
||||
if (alert.getId() == null || alert.getWorkspaceId() == null || alert.getWorkspaceId().isBlank()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
executor.execute(() -> process(alert.clone()));
|
||||
} catch (RuntimeException exception) {
|
||||
log.warn("Agent alert analysis input was rejected for alert {}", alert.getFingerprint(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void process(SingleAlert alert) {
|
||||
for (AlertAnalysisPolicy policy : policyService.findEnabled(alert.getWorkspaceId())) {
|
||||
if (alert.getWorkspaceId().equals(policy.getWorkspaceId()) && matches(policy, alert)) {
|
||||
accept(policy, alert);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void accept(AlertAnalysisPolicy policy, SingleAlert alert) {
|
||||
String group = groupKey(policy.getGroupByLabels(), alert.getLabels());
|
||||
if (group == null) {
|
||||
return;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
AnalysisWindowKey windowKey = new AnalysisWindowKey(alert.getWorkspaceId(), policy.getId(), group);
|
||||
AnalysisTrigger trigger;
|
||||
synchronized (windows.computeIfAbsent(windowKey, ignored -> new AnalysisWindow(now))) {
|
||||
AnalysisWindow window = windows.get(windowKey);
|
||||
if (now - window.firstSeenAt > TimeUnit.SECONDS.toMillis(policy.getWindowSeconds())) {
|
||||
window.reset(now);
|
||||
}
|
||||
window.alerts.put(alertKey(alert), alert.clone());
|
||||
if (now - window.lastTriggeredAt < TimeUnit.SECONDS.toMillis(policy.getCooldownSeconds())
|
||||
|| window.alerts.size() < policy.getMinimumAlertCount()) {
|
||||
return;
|
||||
}
|
||||
window.lastTriggeredAt = now;
|
||||
trigger = new AnalysisTrigger(alert.getWorkspaceId(), policy, group, window.firstSeenAt,
|
||||
List.copyOf(window.alerts.values()), alert);
|
||||
window.alerts.clear();
|
||||
window.firstSeenAt = now;
|
||||
}
|
||||
invoke(trigger);
|
||||
}
|
||||
|
||||
private void invoke(AnalysisTrigger trigger) {
|
||||
String contextHash = GatewayText.sha256(trigger.workspaceId() + ":" + trigger.policy().getId()
|
||||
+ ":" + trigger.groupKey() + ":" + trigger.firstSeenAt());
|
||||
String conversationId = "alert-analysis:" + contextHash;
|
||||
String commandId = "alert_" + GatewayText.sha256(conversationId + ":"
|
||||
+ alertKey(trigger.triggerAlert())).substring(0, 32);
|
||||
List<Long> alertIds = trigger.alerts().stream()
|
||||
.map(SingleAlert::getId)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.distinct()
|
||||
.limit(256)
|
||||
.toList();
|
||||
long now = System.currentTimeMillis();
|
||||
UserInput userInput = UserInput.builder()
|
||||
.messageId(commandId)
|
||||
.conversationId(conversationId)
|
||||
.target(AgentTargetRef.builder().alertId(trigger.triggerAlert().getId()).build())
|
||||
.alertIncident(AgentAlertIncidentContext.builder()
|
||||
.analysisPolicyId(trigger.policy().getId())
|
||||
.triggerAlertId(trigger.triggerAlert().getId())
|
||||
.alertIds(alertIds)
|
||||
.alertCount(trigger.alerts().size())
|
||||
.windowStartedAt(trigger.firstSeenAt())
|
||||
.build())
|
||||
.message(UserInput.Message.builder().text(prompt(trigger)).build())
|
||||
.build();
|
||||
InvokeCommand command = InvokeCommand.builder()
|
||||
.envelope(GatewayEnvelope.builder()
|
||||
.channelId(ChannelId.SYSTEM.id())
|
||||
.receivedAt(now)
|
||||
.actor(AgentActor.alertAnalysisActor())
|
||||
.preferredLanguage(AgentResponseLanguage.systemDefault())
|
||||
.workspaceId(trigger.workspaceId())
|
||||
.build())
|
||||
.replyMode(ReplyMode.FINAL_ONLY)
|
||||
.commandId(commandId)
|
||||
.userInput(userInput)
|
||||
.entryType(AgentRuntimeEntryType.ALERT_TRIGGER)
|
||||
.build();
|
||||
try {
|
||||
commandRouter.handle(command);
|
||||
} catch (RuntimeException exception) {
|
||||
log.error("Automatic alert analysis failed for policy {} and conversation {}",
|
||||
trigger.policy().getId(), conversationId, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private String prompt(AnalysisTrigger trigger) {
|
||||
List<Map<String, Object>> alerts = new ArrayList<>(trigger.alerts().size());
|
||||
for (SingleAlert alert : trigger.alerts()) {
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("id", alert.getId());
|
||||
item.put("status", alert.getStatus());
|
||||
item.put("labels", alert.getLabels());
|
||||
item.put("annotations", alert.getAnnotations());
|
||||
item.put("content", alert.getContent());
|
||||
item.put("startAt", alert.getStartAt());
|
||||
item.put("activeAt", alert.getActiveAt());
|
||||
item.put("triggerTimes", alert.getTriggerTimes());
|
||||
alerts.add(item);
|
||||
}
|
||||
Map<String, Object> context = new LinkedHashMap<>();
|
||||
context.put("analysisPolicyId", trigger.policy().getId());
|
||||
context.put("analysisPolicyName", trigger.policy().getName());
|
||||
context.put("groupByLabels", trigger.policy().getGroupByLabels());
|
||||
context.put("groupKey", trigger.groupKey());
|
||||
context.put("windowStartedAt", trigger.firstSeenAt());
|
||||
context.put("alerts", alerts);
|
||||
return ALERT_ANALYSIS_PROMPT_TEMPLATE.formatted(JsonUtil.toJson(context));
|
||||
}
|
||||
|
||||
private boolean matches(AlertAnalysisPolicy policy, SingleAlert alert) {
|
||||
Map<String, String> labels = alert.getLabels();
|
||||
if (labels == null) {
|
||||
return false;
|
||||
}
|
||||
return policy.getMatchLabels().entrySet().stream()
|
||||
.allMatch(entry -> entry.getValue().equals(labels.get(entry.getKey())));
|
||||
}
|
||||
|
||||
private String groupKey(List<String> groupByLabels, Map<String, String> labels) {
|
||||
if (labels == null) {
|
||||
return null;
|
||||
}
|
||||
List<String> components = new ArrayList<>(groupByLabels.size());
|
||||
for (String label : groupByLabels) {
|
||||
String value = labels.get(label);
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
components.add(label + "=" + value);
|
||||
}
|
||||
components.sort(Comparator.naturalOrder());
|
||||
return String.join(",", components);
|
||||
}
|
||||
|
||||
private String alertKey(SingleAlert alert) {
|
||||
return alert.getId() == null ? alert.getFingerprint() : String.valueOf(alert.getId());
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void destroy() {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
|
||||
private static final class AnalysisWindow {
|
||||
private final Map<String, SingleAlert> alerts = new LinkedHashMap<>();
|
||||
private long firstSeenAt;
|
||||
private long lastTriggeredAt;
|
||||
|
||||
private AnalysisWindow(long firstSeenAt) {
|
||||
this.firstSeenAt = firstSeenAt;
|
||||
}
|
||||
|
||||
private void reset(long now) {
|
||||
alerts.clear();
|
||||
firstSeenAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
private record AnalysisWindowKey(String workspaceId, Long policyId, String groupKey) {
|
||||
}
|
||||
|
||||
private record AnalysisTrigger(String workspaceId, AlertAnalysisPolicy policy, String groupKey, long firstSeenAt,
|
||||
List<SingleAlert> alerts, SingleAlert triggerAlert) {
|
||||
}
|
||||
}
|
||||
-479
@@ -1,479 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.application;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.InvokeCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.ReplyMode;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayEvent.ErrorPayload;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayEvent.GatewayEventType;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayEvent.RunStatusPayload;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayResponse.GatewaySingleResponse;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayResponse.GatewayStreamResponse;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayResponse.Meta;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.UserInput;
|
||||
import org.apache.hertzbeat.ai.gateway.conversation.AgentRunSnapshot;
|
||||
import org.apache.hertzbeat.ai.gateway.conversation.AgentRunSnapshotService;
|
||||
import org.apache.hertzbeat.ai.gateway.conversation.AgentRunService;
|
||||
import org.apache.hertzbeat.ai.gateway.conversation.AgentRunStatus;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.AgentRuntimeEvent;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.AgentRuntimeEvent.EventStatus;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.AgentRuntimeEventType;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.AgentRuntimeItemKind;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.AgentRuntimeRequest;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.AgentRuntimeService;
|
||||
import org.apache.hertzbeat.common.entity.agent.AgentRun;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.SignalType;
|
||||
|
||||
/**
|
||||
* Agent command execution service. Only agent-message commands enter runtime.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class AgentCommandService {
|
||||
|
||||
private final AgentRunAdmissionService admissionService;
|
||||
private final AgentRunService runService;
|
||||
private final AgentRunSnapshotService snapshotService;
|
||||
private final AgentRuntimeService runtimeService;
|
||||
private final GatewayRuntimeEventProjector runtimeEventProjector;
|
||||
|
||||
public AgentCommandService(AgentRunAdmissionService admissionService, AgentRunService runService,
|
||||
AgentRunSnapshotService snapshotService,
|
||||
AgentRuntimeService runtimeService,
|
||||
GatewayRuntimeEventProjector runtimeEventProjector) {
|
||||
this.admissionService = admissionService;
|
||||
this.runService = runService;
|
||||
this.snapshotService = snapshotService;
|
||||
this.runtimeService = runtimeService;
|
||||
this.runtimeEventProjector = runtimeEventProjector;
|
||||
}
|
||||
|
||||
public GatewayResponse handle(InvokeCommand command) {
|
||||
return invoke(command, command.userInput());
|
||||
}
|
||||
|
||||
GatewaySingleResponse invokeFinal(GatewayCommand command, UserInput userInput) {
|
||||
AgentRunAdmission admission = admit(command);
|
||||
if (admission.decision() != AgentRunAdmission.Decision.EXECUTE_NEW) {
|
||||
return replayFinal(command, userInput, admission);
|
||||
}
|
||||
AgentRuntimeRequest request = runtimeRequest((InvokeCommand) command, admission);
|
||||
String conversationId = userInput.getConversationId();
|
||||
AtomicReference<String> reliableFinalResult = new AtomicReference<>();
|
||||
List<GatewayEvent> events = gatewayEvents(command, request, conversationId, reliableFinalResult)
|
||||
.collectList()
|
||||
.block();
|
||||
GatewayEvent terminalEvent = terminalEvent(events);
|
||||
boolean failed = terminalEvent.type() == GatewayEventType.ERROR;
|
||||
return GatewaySingleResponse.builder()
|
||||
.meta(Meta.builder()
|
||||
.commandId(command.commandId())
|
||||
.conversationId(conversationId)
|
||||
.sessionUid(request.getSession().getSessionUid())
|
||||
.runUid(request.getRun().getRunUid())
|
||||
.terminal(true)
|
||||
.message(failed ? "error" : "completed")
|
||||
.build())
|
||||
.body(body(finalMessage(terminalEvent, reliableFinalResult.get()), terminalStatus(terminalEvent)))
|
||||
.events(events)
|
||||
.build();
|
||||
}
|
||||
|
||||
GatewayStreamResponse invokeStream(GatewayCommand command, UserInput userInput) {
|
||||
AgentRunAdmission admission = admit(command);
|
||||
if (admission.decision() != AgentRunAdmission.Decision.EXECUTE_NEW) {
|
||||
return replayStream(command, userInput, admission);
|
||||
}
|
||||
AgentRuntimeRequest request = runtimeRequest((InvokeCommand) command, admission);
|
||||
String conversationId = userInput.getConversationId();
|
||||
return GatewayStreamResponse.builder()
|
||||
.meta(Meta.builder()
|
||||
.commandId(command.commandId())
|
||||
.conversationId(conversationId)
|
||||
.sessionUid(request.getSession().getSessionUid())
|
||||
.runUid(request.getRun().getRunUid())
|
||||
.terminal(false)
|
||||
.message("streaming")
|
||||
.build())
|
||||
.events(gatewayEvents(command, request, conversationId, new AtomicReference<>()))
|
||||
.build();
|
||||
}
|
||||
|
||||
AgentRuntimeRequest prepare(GatewayCommand command, UserInput userInput) {
|
||||
AgentRunAdmission admission = admit(command);
|
||||
if (admission.decision() != AgentRunAdmission.Decision.EXECUTE_NEW) {
|
||||
throw new IllegalStateException("Agent run admission did not authorize execution");
|
||||
}
|
||||
return runtimeRequest((InvokeCommand) command, admission);
|
||||
}
|
||||
|
||||
private AgentRuntimeRequest runtimeRequest(InvokeCommand command, AgentRunAdmission admission) {
|
||||
return AgentRuntimeRequest.builder()
|
||||
.entryType(command.entryType())
|
||||
.approvalHandling(admission.approvalHandling())
|
||||
.envelope(command.envelope())
|
||||
.userInput(command.userInput())
|
||||
.session(admission.session())
|
||||
.run(admission.run())
|
||||
.chatHistory(admission.chatHistory())
|
||||
.build();
|
||||
}
|
||||
|
||||
private AgentRunAdmission admit(GatewayCommand command) {
|
||||
AgentRunAdmission admission = admissionService.admit((InvokeCommand) command);
|
||||
if (admission.decision() == AgentRunAdmission.Decision.REJECT_MISMATCH) {
|
||||
throw new IllegalArgumentException("Agent message identity conflicts with the durable run");
|
||||
}
|
||||
return admission;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared runtime entry after channel-specific commands have been normalized to UserInput while preserving
|
||||
* command metadata and runtime entry type for response IDs and tool exposure.
|
||||
*/
|
||||
private GatewayResponse invoke(GatewayCommand command, UserInput userInput) {
|
||||
if (command.replyMode() == ReplyMode.STREAM) {
|
||||
return invokeStream(command, userInput);
|
||||
}
|
||||
return invokeFinal(command, userInput);
|
||||
}
|
||||
|
||||
private Flux<GatewayEvent> gatewayEvents(GatewayCommand command, AgentRuntimeRequest request,
|
||||
String conversationId, AtomicReference<String> reliableFinalResult) {
|
||||
AtomicBoolean completed = new AtomicBoolean();
|
||||
AssistantCompletionTracker assistantCompletion = new AssistantCompletionTracker();
|
||||
AgentGatewayLifecycleRelay relay = new AgentGatewayLifecycleRelay(request.getRun().getRunUid());
|
||||
Flux<GatewayEvent> lifecycle = Flux.defer(() -> runtimeService.streamInvoke(request))
|
||||
.map(event -> {
|
||||
GatewayEvent gatewayEvent = runtimeEventProjector.project(event, conversationId,
|
||||
request.getSession().getSessionUid(), request.getRun().getRunUid());
|
||||
assistantCompletion.observe(event);
|
||||
try {
|
||||
completeInvocationOnTerminalEvent(request.getRun(), event, completed,
|
||||
assistantCompletion, reliableFinalResult);
|
||||
} catch (Error error) {
|
||||
if (isFatalJvmError(error)) {
|
||||
completed.set(true);
|
||||
relay.fail(error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return gatewayEvent;
|
||||
})
|
||||
.onErrorResume(exception -> {
|
||||
if (exception instanceof Error error && isFatalJvmError(error)) {
|
||||
completed.set(true);
|
||||
return Flux.error(error);
|
||||
}
|
||||
if (exception instanceof RecoveryRequiredPersistenceException recoveryFailure) {
|
||||
return Flux.just(recoveryRequiredErrorEvent(
|
||||
command, request, conversationId, recoveryFailure.operatorMessage()));
|
||||
}
|
||||
log.debug("Agent Gateway runtime failed for run {}", request.getRun().getRunUid(), exception);
|
||||
failInvocationIfIncomplete(request.getRun(), completed, "Agent Gateway runtime failed.");
|
||||
return Flux.just(errorEvent(command, request, conversationId,
|
||||
"Agent Gateway runtime failed."));
|
||||
})
|
||||
.concatWith(Flux.defer(() -> {
|
||||
if (completed.get()) {
|
||||
return Flux.empty();
|
||||
}
|
||||
String message = "Agent Gateway runtime stream completed without a terminal event.";
|
||||
failInvocationIfIncomplete(request.getRun(), completed, message);
|
||||
return Flux.just(errorEvent(command, request, conversationId, message));
|
||||
}))
|
||||
.doFinally(signalType -> completeInvocationIfStreamFinishedWithoutTerminal(request.getRun(),
|
||||
signalType, completed));
|
||||
return relay.connect(lifecycle);
|
||||
}
|
||||
|
||||
private void completeInvocationOnTerminalEvent(AgentRun run, AgentRuntimeEvent event,
|
||||
AtomicBoolean completed, AssistantCompletionTracker assistantCompletion,
|
||||
AtomicReference<String> reliableFinalResult) {
|
||||
if (completed.get()) {
|
||||
return;
|
||||
}
|
||||
if (event.getType() != AgentRuntimeEventType.RUN_COMPLETED
|
||||
&& event.getType() != AgentRuntimeEventType.ERROR) {
|
||||
return;
|
||||
}
|
||||
if (!completed.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
String terminalMessage = StringUtils.hasText(event.getErrorMessage())
|
||||
? event.getErrorMessage()
|
||||
: "Agent Gateway runtime failed.";
|
||||
try {
|
||||
if (event.getType() == AgentRuntimeEventType.RUN_COMPLETED) {
|
||||
if (!assistantCompletion.latestAssistantCompleted()) {
|
||||
throw new IllegalStateException("Agent runtime completed without an assistant message completion");
|
||||
}
|
||||
if (!StringUtils.hasText(event.getResult())) {
|
||||
throw new IllegalStateException("Agent runtime completed without a durable result");
|
||||
}
|
||||
AgentRun succeededRun = runService.markSucceeded(run, event.getResult());
|
||||
if (succeededRun == null || !StringUtils.hasText(succeededRun.getResultSummary())) {
|
||||
throw new IllegalStateException("Succeeded agent run must expose its persisted result");
|
||||
}
|
||||
reliableFinalResult.compareAndSet(null, succeededRun.getResultSummary());
|
||||
return;
|
||||
}
|
||||
if (event.getStatus() == EventStatus.RECOVERY_REQUIRED) {
|
||||
runService.markRecoveryRequired(run, terminalMessage);
|
||||
} else {
|
||||
runService.markFailed(run, terminalMessage);
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
if (event.getStatus() == EventStatus.RECOVERY_REQUIRED) {
|
||||
throw new RecoveryRequiredPersistenceException(terminalMessage, exception);
|
||||
}
|
||||
completed.set(false);
|
||||
throw exception;
|
||||
} catch (Error error) {
|
||||
if (event.getStatus() == EventStatus.RECOVERY_REQUIRED && !isFatalJvmError(error)) {
|
||||
throw new RecoveryRequiredPersistenceException(terminalMessage, error);
|
||||
}
|
||||
completed.set(false);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isFatalJvmError(Error error) {
|
||||
return error instanceof VirtualMachineError
|
||||
|| error instanceof ThreadDeath
|
||||
|| error instanceof LinkageError;
|
||||
}
|
||||
|
||||
private void failInvocationIfIncomplete(AgentRun run, AtomicBoolean completed, String message) {
|
||||
if (!completed.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
runService.markFailed(run, message);
|
||||
} catch (RuntimeException exception) {
|
||||
completed.set(false);
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
private void completeInvocationIfStreamFinishedWithoutTerminal(AgentRun run, SignalType signalType,
|
||||
AtomicBoolean completed) {
|
||||
if (!completed.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
if (signalType == SignalType.CANCEL) {
|
||||
runService.markCancelled(run, "Runtime stream client disconnected.");
|
||||
return;
|
||||
}
|
||||
if (signalType == SignalType.ON_ERROR) {
|
||||
runService.markFailed(run, "Agent Gateway runtime failed.");
|
||||
return;
|
||||
}
|
||||
runService.markFailed(run, "Agent Gateway runtime stream completed without a terminal event.");
|
||||
}
|
||||
|
||||
private GatewayEvent terminalEvent(List<GatewayEvent> events) {
|
||||
for (int index = events.size() - 1; index >= 0; index--) {
|
||||
GatewayEvent event = events.get(index);
|
||||
if (isTerminal(event)) {
|
||||
return event;
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("Mapped runtime events must contain a terminal event");
|
||||
}
|
||||
|
||||
private String finalMessage(GatewayEvent terminalEvent, String reliableFinalResult) {
|
||||
if (terminalEvent.type() == GatewayEventType.ERROR) {
|
||||
if (terminalEvent.payload() instanceof ErrorPayload payload
|
||||
&& StringUtils.hasText(payload.errorMessage())) {
|
||||
return payload.errorMessage();
|
||||
}
|
||||
return "Agent Gateway runtime failed.";
|
||||
}
|
||||
if (!StringUtils.hasText(reliableFinalResult)) {
|
||||
throw new IllegalStateException("Completed agent run must expose its reliable final result");
|
||||
}
|
||||
return reliableFinalResult;
|
||||
}
|
||||
|
||||
private String terminalStatus(GatewayEvent terminalEvent) {
|
||||
if (terminalEvent.type() != GatewayEventType.ERROR) {
|
||||
return AgentRunStatus.SUCCEEDED.name();
|
||||
}
|
||||
if (terminalEvent.payload() instanceof ErrorPayload payload
|
||||
&& EventStatus.RECOVERY_REQUIRED.externalName().equals(payload.status())) {
|
||||
return AgentRunStatus.RECOVERY_REQUIRED.name();
|
||||
}
|
||||
return AgentRunStatus.FAILED.name();
|
||||
}
|
||||
|
||||
private boolean isTerminal(GatewayEvent event) {
|
||||
return event.type() == GatewayEventType.RUN_COMPLETED
|
||||
|| event.type() == GatewayEventType.RUN_STATUS
|
||||
|| event.type() == GatewayEventType.ERROR;
|
||||
}
|
||||
|
||||
private GatewayStreamResponse replayStream(GatewayCommand command, UserInput userInput,
|
||||
AgentRunAdmission admission) {
|
||||
AgentRunSnapshot snapshot = snapshotService.snapshot(admission.session(), admission.run());
|
||||
boolean terminal = admission.decision() == AgentRunAdmission.Decision.REPLAY_TERMINAL;
|
||||
return GatewayStreamResponse.builder()
|
||||
.meta(replayMeta(command, userInput, snapshot, terminal))
|
||||
.events(Flux.just(runStatusEvent(command, userInput, snapshot)))
|
||||
.build();
|
||||
}
|
||||
|
||||
private GatewaySingleResponse replayFinal(GatewayCommand command, UserInput userInput,
|
||||
AgentRunAdmission admission) {
|
||||
AgentRunSnapshot snapshot = snapshotService.snapshot(admission.session(), admission.run());
|
||||
boolean terminal = admission.decision() == AgentRunAdmission.Decision.REPLAY_TERMINAL;
|
||||
GatewayEvent event = runStatusEvent(command, userInput, snapshot);
|
||||
String message = StringUtils.hasText(snapshot.result()) ? snapshot.result() : snapshot.errorMessage();
|
||||
return GatewaySingleResponse.builder()
|
||||
.meta(replayMeta(command, userInput, snapshot, terminal))
|
||||
.body(body(message, snapshot.status()))
|
||||
.events(List.of(event))
|
||||
.build();
|
||||
}
|
||||
|
||||
private Meta replayMeta(GatewayCommand command, UserInput userInput, AgentRunSnapshot snapshot,
|
||||
boolean terminal) {
|
||||
return Meta.builder()
|
||||
.commandId(command.commandId())
|
||||
.conversationId(userInput.getConversationId())
|
||||
.sessionUid(snapshot.sessionUid())
|
||||
.runUid(snapshot.runUid())
|
||||
.terminal(terminal)
|
||||
.message(terminal ? "replayed" : "running")
|
||||
.build();
|
||||
}
|
||||
|
||||
private GatewayEvent runStatusEvent(GatewayCommand command, UserInput userInput, AgentRunSnapshot snapshot) {
|
||||
return GatewayEvent.builder()
|
||||
.type(GatewayEventType.RUN_STATUS)
|
||||
.eventId(snapshot.runUid() + ":status:" + snapshot.status().toLowerCase(java.util.Locale.ROOT))
|
||||
.conversationId(userInput.getConversationId())
|
||||
.sessionUid(snapshot.sessionUid())
|
||||
.runUid(snapshot.runUid())
|
||||
.payload(RunStatusPayload.builder()
|
||||
.status(snapshot.status())
|
||||
.result(snapshot.result())
|
||||
.errorMessage(snapshot.errorMessage())
|
||||
.replayAvailable(snapshot.replayAvailable())
|
||||
.build())
|
||||
.timestamp(System.currentTimeMillis())
|
||||
.build();
|
||||
}
|
||||
|
||||
private GatewayEvent errorEvent(GatewayCommand command, AgentRuntimeRequest request, String conversationId,
|
||||
String message) {
|
||||
return GatewayEvent.builder()
|
||||
.type(GatewayEventType.ERROR)
|
||||
.eventId(command.commandId() + ":error")
|
||||
.conversationId(conversationId)
|
||||
.sessionUid(request.getSession().getSessionUid())
|
||||
.runUid(request.getRun().getRunUid())
|
||||
.payload(ErrorPayload.builder()
|
||||
.errorMessage(message)
|
||||
.build())
|
||||
.timestamp(System.currentTimeMillis())
|
||||
.build();
|
||||
}
|
||||
|
||||
private GatewayEvent recoveryRequiredErrorEvent(GatewayCommand command, AgentRuntimeRequest request,
|
||||
String conversationId, String message) {
|
||||
return GatewayEvent.builder()
|
||||
.type(GatewayEventType.ERROR)
|
||||
.eventId(command.commandId() + ":recovery-required")
|
||||
.conversationId(conversationId)
|
||||
.sessionUid(request.getSession().getSessionUid())
|
||||
.runUid(request.getRun().getRunUid())
|
||||
.payload(ErrorPayload.builder()
|
||||
.errorMessage(message)
|
||||
.status(EventStatus.RECOVERY_REQUIRED.externalName())
|
||||
.build())
|
||||
.timestamp(System.currentTimeMillis())
|
||||
.build();
|
||||
}
|
||||
|
||||
private static final class RecoveryRequiredPersistenceException extends RuntimeException {
|
||||
|
||||
private final String operatorMessage;
|
||||
|
||||
private RecoveryRequiredPersistenceException(String operatorMessage, Throwable cause) {
|
||||
super("Agent recovery-required state could not be persisted", cause);
|
||||
this.operatorMessage = operatorMessage;
|
||||
}
|
||||
|
||||
private String operatorMessage() {
|
||||
return operatorMessage;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class AssistantCompletionTracker {
|
||||
|
||||
private String latestAssistantItemId;
|
||||
private String completedAssistantItemId;
|
||||
|
||||
private void observe(AgentRuntimeEvent event) {
|
||||
if (event.getItemKind() == AgentRuntimeItemKind.TOOL_CALL) {
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
if (event.getItemKind() != AgentRuntimeItemKind.ASSISTANT_MESSAGE) {
|
||||
return;
|
||||
}
|
||||
if (event.getType() == AgentRuntimeEventType.ITEM_STARTED) {
|
||||
latestAssistantItemId = event.getItemId();
|
||||
completedAssistantItemId = null;
|
||||
} else if (event.getType() == AgentRuntimeEventType.ITEM_DELTA
|
||||
&& !Objects.equals(latestAssistantItemId, event.getItemId())) {
|
||||
invalidate();
|
||||
} else if (event.getType() == AgentRuntimeEventType.ITEM_COMPLETED) {
|
||||
completedAssistantItemId = Objects.equals(latestAssistantItemId, event.getItemId())
|
||||
? event.getItemId()
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
private void invalidate() {
|
||||
latestAssistantItemId = null;
|
||||
completedAssistantItemId = null;
|
||||
}
|
||||
|
||||
private boolean latestAssistantCompleted() {
|
||||
return StringUtils.hasText(latestAssistantItemId)
|
||||
&& Objects.equals(latestAssistantItemId, completedAssistantItemId);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> body(String message, String status) {
|
||||
return Map.of("message", StringUtils.hasText(message) ? message : "",
|
||||
"status", StringUtils.hasText(status) ? status : "");
|
||||
}
|
||||
|
||||
}
|
||||
-168
@@ -1,168 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.application;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.TreeMap;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentTargetAuthority;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentTargetRef;
|
||||
import org.apache.hertzbeat.common.entity.manager.ObserveEntity;
|
||||
import org.apache.hertzbeat.manager.service.entity.EntityWorkspaceQueryService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/** Resolves and rechecks persisted authority for an exact workspace-owned Entity. */
|
||||
@Service
|
||||
public class AgentEntityTargetAuthorityService {
|
||||
|
||||
public static final String TARGET_VERSION_PREFIX = "entity.";
|
||||
public static final String AUTHORITY_VERSION_PREFIX = "entity-authority.";
|
||||
public static final String TARGET_VERSION = "entity.v1";
|
||||
public static final String AUTHORITY_VERSION = "entity-authority.v1";
|
||||
|
||||
private final EntityWorkspaceQueryService entityWorkspaceQueryService;
|
||||
|
||||
public AgentEntityTargetAuthorityService(EntityWorkspaceQueryService entityWorkspaceQueryService) {
|
||||
this.entityWorkspaceQueryService = entityWorkspaceQueryService;
|
||||
}
|
||||
|
||||
public AgentTargetRef canonicalize(String workspaceId, long entityId) {
|
||||
if (!StringUtils.hasText(workspaceId) || entityId <= 0) {
|
||||
throw unavailable();
|
||||
}
|
||||
ObserveEntity entity;
|
||||
try {
|
||||
entity = entityWorkspaceQueryService.findEntityById(workspaceId, entityId)
|
||||
.filter(candidate -> Objects.equals(workspaceId, candidate.getWorkspaceId()))
|
||||
.filter(candidate -> Objects.equals(entityId, candidate.getId()))
|
||||
.orElseThrow(this::unavailable);
|
||||
} catch (UnavailableException failure) {
|
||||
throw failure;
|
||||
} catch (RuntimeException ignored) {
|
||||
throw unavailable();
|
||||
}
|
||||
return AgentTargetRef.builder()
|
||||
.version(TARGET_VERSION)
|
||||
.entityId(entityId)
|
||||
.authority(AgentTargetAuthority.builder()
|
||||
.bindingId(entityId)
|
||||
.version(AUTHORITY_VERSION)
|
||||
.hash(authorityHash(workspaceId, entity))
|
||||
.build())
|
||||
.build();
|
||||
}
|
||||
|
||||
public boolean verify(String workspaceId, AgentTargetRef target) {
|
||||
if (!isCanonicalTarget(target)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return Objects.equals(target, canonicalize(workspaceId, target.getEntityId()));
|
||||
} catch (UnavailableException ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isCanonicalTarget(AgentTargetRef target) {
|
||||
AgentTargetAuthority authority = target == null ? null : target.getAuthority();
|
||||
return target != null
|
||||
&& TARGET_VERSION.equals(target.getVersion())
|
||||
&& target.getEntityId() != null && target.getEntityId() > 0
|
||||
&& authority != null
|
||||
&& Objects.equals(target.getEntityId(), authority.getBindingId())
|
||||
&& AUTHORITY_VERSION.equals(authority.getVersion())
|
||||
&& StringUtils.hasText(authority.getHash())
|
||||
&& target.getMonitorId() == null && target.getAlertId() == null
|
||||
&& target.getAlertType() == null && target.getCollector() == null
|
||||
&& target.getSignal() == null && target.getTopology() == null
|
||||
&& target.getTrace() == null && target.getLog() == null && target.getService() == null;
|
||||
}
|
||||
|
||||
private String authorityHash(String workspaceId, ObserveEntity entity) {
|
||||
StringBuilder material = new StringBuilder("entity-authority.v1;");
|
||||
append(material, "workspace", workspaceId);
|
||||
append(material, "id", entity.getId());
|
||||
append(material, "type", entity.getType());
|
||||
append(material, "name", entity.getName());
|
||||
append(material, "displayName", entity.getDisplayName());
|
||||
append(material, "subtype", entity.getSubtype());
|
||||
append(material, "namespace", entity.getNamespace());
|
||||
append(material, "environment", entity.getEnvironment());
|
||||
append(material, "status", entity.getStatus());
|
||||
append(material, "criticality", entity.getCriticality());
|
||||
append(material, "owner", entity.getOwner());
|
||||
append(material, "lifecycle", entity.getLifecycle());
|
||||
append(material, "tier", entity.getTier());
|
||||
append(material, "system", entity.getSystem());
|
||||
append(material, "source", entity.getSource());
|
||||
append(material, "description", entity.getDescription());
|
||||
appendMap(material, "labels", entity.getLabels());
|
||||
appendList(material, "tags", entity.getTags());
|
||||
append(material, "updatedAt", entity.getGmtUpdate());
|
||||
try {
|
||||
return "sha256:" + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(material.toString().getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 is required for entity target authority", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void appendMap(StringBuilder material, String field, Map<String, String> values) {
|
||||
append(material, field + "Size", values == null ? null : values.size());
|
||||
if (values != null) {
|
||||
new TreeMap<>(values).forEach((key, value) -> {
|
||||
append(material, field + "Key", key);
|
||||
append(material, field + "Value", value);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void appendList(StringBuilder material, String field, List<String> values) {
|
||||
append(material, field + "Size", values == null ? null : values.size());
|
||||
if (values != null) {
|
||||
values.forEach(value -> append(material, field + "Value", value));
|
||||
}
|
||||
}
|
||||
|
||||
private void append(StringBuilder material, String field, Object value) {
|
||||
String text = value == null ? null : String.valueOf(value);
|
||||
material.append(field).append(':').append(text == null ? -1 : text.length()).append(':');
|
||||
if (text != null) {
|
||||
material.append(text);
|
||||
}
|
||||
material.append(';');
|
||||
}
|
||||
|
||||
private UnavailableException unavailable() {
|
||||
return new UnavailableException();
|
||||
}
|
||||
|
||||
/** Cause-free failure used at the channel boundary. */
|
||||
public static final class UnavailableException extends IllegalArgumentException {
|
||||
|
||||
UnavailableException() {
|
||||
super("Entity target is unavailable");
|
||||
}
|
||||
}
|
||||
}
|
||||
-139
@@ -1,139 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.application;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Sinks;
|
||||
import reactor.util.concurrent.Queues;
|
||||
|
||||
/**
|
||||
* Separates the durable runtime subscription from optional client demand.
|
||||
*/
|
||||
@Slf4j
|
||||
final class AgentGatewayLifecycleRelay {
|
||||
|
||||
private static final int LIVE_BUFFER_SIZE = Queues.SMALL_BUFFER_SIZE;
|
||||
private static final String LIFECYCLE_FAILURE = "Agent Gateway lifecycle failed";
|
||||
|
||||
private final String runUid;
|
||||
private final Sinks.One<GatewayEvent> started = Sinks.one();
|
||||
private final Sinks.Many<GatewayEvent> live = Sinks.many().multicast()
|
||||
.onBackpressureBuffer(LIVE_BUFFER_SIZE, false);
|
||||
private final Sinks.One<GatewayEvent> terminal = Sinks.one();
|
||||
private final AtomicBoolean primaryClient = new AtomicBoolean();
|
||||
private final AtomicBoolean drainStarted = new AtomicBoolean();
|
||||
private final AtomicBoolean startSeen = new AtomicBoolean();
|
||||
private final AtomicBoolean terminalSeen = new AtomicBoolean();
|
||||
private final AtomicLong droppedLiveEvents = new AtomicLong();
|
||||
|
||||
AgentGatewayLifecycleRelay(String runUid) {
|
||||
this.runUid = runUid;
|
||||
}
|
||||
|
||||
Flux<GatewayEvent> connect(Flux<GatewayEvent> lifecycle) {
|
||||
return Flux.defer(() -> {
|
||||
Flux<GatewayEvent> clientEvents = primaryClient.compareAndSet(false, true)
|
||||
? Flux.concat(started.asMono(), live.asFlux(), terminal.asMono())
|
||||
: terminal.asMono().flux();
|
||||
return clientEvents.doOnSubscribe(ignored -> startDrain(lifecycle));
|
||||
});
|
||||
}
|
||||
|
||||
void fail(Throwable failure) {
|
||||
completeStartIfMissing();
|
||||
completeLive();
|
||||
Throwable clientFailure = isFatal(failure)
|
||||
? failure
|
||||
: new IllegalStateException(LIFECYCLE_FAILURE);
|
||||
emitCritical("terminal failure", terminal.tryEmitError(clientFailure));
|
||||
}
|
||||
|
||||
private void startDrain(Flux<GatewayEvent> lifecycle) {
|
||||
if (!drainStarted.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
lifecycle.subscribe(this::emit, this::fail, this::complete);
|
||||
} catch (RuntimeException failure) {
|
||||
fail(failure);
|
||||
throw failure;
|
||||
} catch (Error failure) {
|
||||
fail(failure);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
private void emit(GatewayEvent event) {
|
||||
if (event.type() == GatewayEvent.GatewayEventType.RUN_STARTED) {
|
||||
startSeen.set(true);
|
||||
emitCritical("run start", started.tryEmitValue(event));
|
||||
return;
|
||||
}
|
||||
if (event.type() == GatewayEvent.GatewayEventType.RUN_COMPLETED
|
||||
|| event.type() == GatewayEvent.GatewayEventType.ERROR) {
|
||||
terminalSeen.set(true);
|
||||
emitCritical("terminal event", terminal.tryEmitValue(event));
|
||||
return;
|
||||
}
|
||||
Sinks.EmitResult result = live.tryEmitNext(event);
|
||||
if (result.isFailure()) {
|
||||
long dropped = droppedLiveEvents.incrementAndGet();
|
||||
if (dropped == 1) {
|
||||
log.warn("Agent Gateway live event buffer is full for run {}; dropping nonterminal events", runUid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void complete() {
|
||||
completeStartIfMissing();
|
||||
completeLive();
|
||||
if (!terminalSeen.get()) {
|
||||
emitCritical("missing terminal event", terminal.tryEmitError(
|
||||
new IllegalStateException(LIFECYCLE_FAILURE)));
|
||||
}
|
||||
long dropped = droppedLiveEvents.get();
|
||||
if (dropped > 1) {
|
||||
log.warn("Agent Gateway dropped {} nonterminal live events for run {}", dropped, runUid);
|
||||
}
|
||||
}
|
||||
|
||||
private void completeStartIfMissing() {
|
||||
if (!startSeen.get()) {
|
||||
emitCritical("empty run start", started.tryEmitEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
private void completeLive() {
|
||||
emitCritical("live completion", live.tryEmitComplete());
|
||||
}
|
||||
|
||||
private void emitCritical(String signal, Sinks.EmitResult result) {
|
||||
if (result.isFailure()) {
|
||||
log.warn("Agent Gateway could not emit {} for run {}: {}", signal, runUid, result);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isFatal(Throwable failure) {
|
||||
return failure instanceof VirtualMachineError
|
||||
|| failure instanceof ThreadDeath
|
||||
|| failure instanceof LinkageError;
|
||||
}
|
||||
}
|
||||
-228
@@ -1,228 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.application;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.regex.Pattern;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentLogRef;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentTargetAuthority;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentTargetRef;
|
||||
import org.apache.hertzbeat.ai.gateway.text.GatewayText;
|
||||
import org.apache.hertzbeat.common.entity.log.LogEntry;
|
||||
import org.apache.hertzbeat.common.util.JsonUtil;
|
||||
import org.apache.hertzbeat.observability.logs.service.LogQueryService;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/** Resolves one exact non-live Log Explore page through the trusted-workspace query boundary. */
|
||||
@Service
|
||||
public class AgentLogTargetAuthorityService {
|
||||
|
||||
public static final String TARGET_VERSION_PREFIX = "log-page.";
|
||||
public static final String AUTHORITY_VERSION_PREFIX = "log-page-authority.";
|
||||
public static final String TARGET_VERSION = "log-page.v1";
|
||||
public static final String AUTHORITY_VERSION = "log-page-authority.v1";
|
||||
|
||||
private static final long MAX_RANGE_MILLIS = Duration.ofDays(7).toMillis();
|
||||
private static final Pattern SAFE_ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._:-]{0,127}");
|
||||
private static final Set<String> SEVERITIES = Set.of("TRACE", "DEBUG", "INFO", "WARN", "ERROR", "FATAL");
|
||||
|
||||
private final LogQueryService logQueryService;
|
||||
|
||||
public AgentLogTargetAuthorityService(LogQueryService logQueryService) {
|
||||
this.logQueryService = logQueryService;
|
||||
}
|
||||
|
||||
public AgentTargetRef canonicalize(String workspaceId, AgentLogRef source) {
|
||||
AgentLogRef log = normalizeSource(source);
|
||||
Page<LogEntry> page;
|
||||
try {
|
||||
page = query(workspaceId, log);
|
||||
} catch (RuntimeException ignored) {
|
||||
throw unavailable();
|
||||
}
|
||||
if (page == null || page.isEmpty() || page.getNumber() != log.getPageIndex()
|
||||
|| page.getSize() != log.getPageSize()) {
|
||||
throw unavailable();
|
||||
}
|
||||
return AgentTargetRef.builder()
|
||||
.version(TARGET_VERSION)
|
||||
.log(log)
|
||||
.authority(AgentTargetAuthority.builder()
|
||||
.version(AUTHORITY_VERSION)
|
||||
.hash("sha256:" + GatewayText.sha256(authorityMaterial(workspaceId, log, page)))
|
||||
.build())
|
||||
.build();
|
||||
}
|
||||
|
||||
public boolean verify(String workspaceId, AgentTargetRef target) {
|
||||
if (!isCanonicalTarget(target)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return Objects.equals(target, canonicalize(workspaceId, target.getLog()));
|
||||
} catch (UnavailableException ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isCanonicalTarget(AgentTargetRef target) {
|
||||
AgentTargetAuthority authority = target == null ? null : target.getAuthority();
|
||||
if (target == null || !TARGET_VERSION.equals(target.getVersion()) || target.getLog() == null
|
||||
|| authority == null || authority.getBindingId() != null
|
||||
|| !AUTHORITY_VERSION.equals(authority.getVersion())
|
||||
|| authority.getHash() == null || !authority.getHash().matches("sha256:[0-9a-f]{64}")
|
||||
|| target.getMonitorId() != null || target.getAlertId() != null || target.getAlertType() != null
|
||||
|| target.getEntityId() != null || target.getCollector() != null || target.getSignal() != null
|
||||
|| target.getTopology() != null || target.getTrace() != null || target.getService() != null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return Objects.equals(target.getLog(), normalizeSource(target.getLog()));
|
||||
} catch (UnavailableException ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public AgentLogRef normalizeSource(AgentLogRef source) {
|
||||
if (source == null || source.getStart() == null || source.getEnd() == null || source.getStart() <= 0
|
||||
|| source.getEnd() <= source.getStart() || source.getEnd() - source.getStart() > MAX_RANGE_MILLIS
|
||||
|| !optionalSafeId(source.getTraceId()) || !optionalSafeId(source.getSpanId())
|
||||
|| source.getSeverityNumber() != null
|
||||
&& (source.getSeverityNumber() < 1 || source.getSeverityNumber() > 24)
|
||||
|| source.getHideInternal() == null || source.getHideNoise() == null
|
||||
|| source.getPageIndex() == null || source.getPageIndex() < 0 || source.getPageIndex() > 10_000
|
||||
|| source.getPageSize() == null || source.getPageSize() < 1 || source.getPageSize() > 100) {
|
||||
throw unavailable();
|
||||
}
|
||||
String severity = text(source.getSeverityText(), 16);
|
||||
if (severity != null) {
|
||||
severity = severity.toUpperCase(Locale.ROOT);
|
||||
if (!SEVERITIES.contains(severity)) {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
return AgentLogRef.builder()
|
||||
.start(source.getStart()).end(source.getEnd())
|
||||
.traceId(source.getTraceId()).spanId(source.getSpanId())
|
||||
.severityNumber(source.getSeverityNumber()).severityText(severity)
|
||||
.search(text(source.getSearch(), 256))
|
||||
.serviceName(text(source.getServiceName(), 512))
|
||||
.serviceNamespace(text(source.getServiceNamespace(), 512))
|
||||
.environment(text(source.getEnvironment(), 512))
|
||||
.resourceFilter(text(source.getResourceFilter(), 2048))
|
||||
.attributeFilter(text(source.getAttributeFilter(), 2048))
|
||||
.hideInternal(source.getHideInternal()).hideNoise(source.getHideNoise())
|
||||
.pageIndex(source.getPageIndex()).pageSize(source.getPageSize())
|
||||
.build();
|
||||
}
|
||||
|
||||
private Page<LogEntry> query(String workspaceId, AgentLogRef log) {
|
||||
return logQueryService.list(workspaceId, null, log.getStart(), log.getEnd(), log.getTraceId(), log.getSpanId(),
|
||||
log.getSeverityNumber(), log.getSeverityText(), log.getSearch(), log.getServiceName(),
|
||||
log.getServiceNamespace(), log.getEnvironment(), log.getResourceFilter(), log.getAttributeFilter(),
|
||||
log.getPageIndex(), log.getPageSize(), log.getHideInternal(), log.getHideNoise());
|
||||
}
|
||||
|
||||
private String authorityMaterial(String workspaceId, AgentLogRef log, Page<LogEntry> page) {
|
||||
StringBuilder material = new StringBuilder("log-page-authority.v1;");
|
||||
append(material, "workspaceId", workspaceId);
|
||||
append(material, "scope", JsonUtil.toJson(log));
|
||||
append(material, "number", page.getNumber());
|
||||
append(material, "size", page.getSize());
|
||||
append(material, "totalElements", page.getTotalElements());
|
||||
append(material, "totalPages", page.getTotalPages());
|
||||
for (LogEntry row : page.getContent()) {
|
||||
append(material, "timeUnixNano", row == null ? null : row.getTimeUnixNano());
|
||||
append(material, "observedTimeUnixNano", row == null ? null : row.getObservedTimeUnixNano());
|
||||
append(material, "severityNumber", row == null ? null : row.getSeverityNumber());
|
||||
append(material, "severityText", row == null ? null : row.getSeverityText());
|
||||
append(material, "body", row == null ? null : canonical(row.getBody()));
|
||||
append(material, "attributes", row == null ? null : canonical(row.getAttributes()));
|
||||
append(material, "droppedAttributesCount", row == null ? null : row.getDroppedAttributesCount());
|
||||
append(material, "traceId", row == null ? null : row.getTraceId());
|
||||
append(material, "spanId", row == null ? null : row.getSpanId());
|
||||
append(material, "traceFlags", row == null ? null : row.getTraceFlags());
|
||||
append(material, "resource", row == null ? null : canonical(row.getResource()));
|
||||
append(material, "resourceSchemaUrl", row == null ? null : row.getResourceSchemaUrl());
|
||||
append(material, "instrumentationScope", row == null ? null : canonical(row.getInstrumentationScope()));
|
||||
append(material, "scopeSchemaUrl", row == null ? null : row.getScopeSchemaUrl());
|
||||
}
|
||||
return material.toString();
|
||||
}
|
||||
|
||||
private Object canonical(Object value) {
|
||||
if (value instanceof Map<?, ?> map) {
|
||||
Map<String, Object> sorted = new TreeMap<>();
|
||||
map.forEach((key, nested) -> sorted.put(String.valueOf(key), canonical(nested)));
|
||||
return sorted;
|
||||
}
|
||||
if (value instanceof Iterable<?> iterable) {
|
||||
List<Object> values = new ArrayList<>();
|
||||
iterable.forEach(nested -> values.add(canonical(nested)));
|
||||
return values;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private String text(String value, int maximumLength) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String normalized = value.trim();
|
||||
if (!StringUtils.hasText(normalized) || normalized.length() > maximumLength
|
||||
|| normalized.codePoints().anyMatch(code -> code < 32 || code == 127)
|
||||
|| !Objects.equals(normalized, GatewayText.redactSecrets(normalized))) {
|
||||
throw unavailable();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private boolean optionalSafeId(String value) {
|
||||
return value == null || SAFE_ID.matcher(value).matches();
|
||||
}
|
||||
|
||||
private void append(StringBuilder material, String field, Object value) {
|
||||
String text = value == null ? null : value instanceof String ? (String) value : JsonUtil.toJson(value);
|
||||
material.append(field).append(':').append(text == null ? -1 : text.length()).append(':');
|
||||
if (text != null) {
|
||||
material.append(text);
|
||||
}
|
||||
material.append(';');
|
||||
}
|
||||
|
||||
private UnavailableException unavailable() {
|
||||
return new UnavailableException();
|
||||
}
|
||||
|
||||
/** Cause-free failure used at the channel boundary. */
|
||||
public static final class UnavailableException extends IllegalArgumentException {
|
||||
|
||||
UnavailableException() {
|
||||
super("Log target is unavailable");
|
||||
}
|
||||
}
|
||||
}
|
||||
-116
@@ -1,116 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.application;
|
||||
|
||||
import java.util.Objects;
|
||||
import org.apache.hertzbeat.ai.gateway.application.AgentTargetCanonicalizationService.FailureKind;
|
||||
import org.apache.hertzbeat.ai.gateway.application.AgentTargetCanonicalizationService.TargetCanonicalizationException;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentTargetAuthority;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentTargetRef;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.UserInput;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/** Isolates strict source/canonical adaptation for one exact non-live Log Explore page. */
|
||||
@Service
|
||||
public class AgentLogTargetCanonicalizationAdapter {
|
||||
|
||||
private final AgentLogTargetAuthorityService authorityService;
|
||||
|
||||
public AgentLogTargetCanonicalizationAdapter(AgentLogTargetAuthorityService authorityService) {
|
||||
this.authorityService = authorityService;
|
||||
}
|
||||
|
||||
public boolean isIntent(AgentTargetRef target) {
|
||||
if (target == null) {
|
||||
return false;
|
||||
}
|
||||
AgentTargetAuthority authority = target.getAuthority();
|
||||
return target.getLog() != null
|
||||
|| hasVersion(target.getVersion(), AgentLogTargetAuthorityService.TARGET_VERSION_PREFIX)
|
||||
|| authority != null && hasVersion(
|
||||
authority.getVersion(), AgentLogTargetAuthorityService.AUTHORITY_VERSION_PREFIX);
|
||||
}
|
||||
|
||||
public GatewayCommand.InvokeCommand canonicalize(GatewayCommand.InvokeCommand command) {
|
||||
AgentTargetRef source = requireSourceIntent(command.userInput().getTarget());
|
||||
try {
|
||||
AgentTargetRef target = authorityService.canonicalize(
|
||||
command.envelope().getWorkspaceId(), source.getLog());
|
||||
if (!authorityService.isCanonicalTarget(target)
|
||||
|| !Objects.equals(authorityService.normalizeSource(source.getLog()), target.getLog())) {
|
||||
throw unavailable();
|
||||
}
|
||||
return withTarget(command, target);
|
||||
} catch (AgentLogTargetAuthorityService.UnavailableException failure) {
|
||||
throw unavailable();
|
||||
} catch (TargetCanonicalizationException failure) {
|
||||
throw failure;
|
||||
} catch (RuntimeException ignored) {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
public GatewayCommand.InvokeCommand replayCommand(
|
||||
GatewayCommand.InvokeCommand command, AgentTargetRef persistedTarget) {
|
||||
AgentTargetRef source = requireSourceIntent(command.userInput().getTarget());
|
||||
try {
|
||||
if (!authorityService.isCanonicalTarget(persistedTarget)
|
||||
|| !Objects.equals(authorityService.normalizeSource(source.getLog()), persistedTarget.getLog())) {
|
||||
throw mismatch();
|
||||
}
|
||||
return withTarget(command, persistedTarget);
|
||||
} catch (TargetCanonicalizationException failure) {
|
||||
throw failure;
|
||||
} catch (RuntimeException ignored) {
|
||||
throw mismatch();
|
||||
}
|
||||
}
|
||||
|
||||
public static AgentTargetRef sourceIntent(AgentTargetRef target) {
|
||||
return AgentTargetRef.builder().log(target.getLog()).build();
|
||||
}
|
||||
|
||||
private AgentTargetRef requireSourceIntent(AgentTargetRef target) {
|
||||
if (target == null || target.getLog() == null || target.getVersion() != null || target.getAuthority() != null
|
||||
|| target.getMonitorId() != null || target.getAlertId() != null || target.getAlertType() != null
|
||||
|| target.getEntityId() != null || target.getCollector() != null || target.getSignal() != null
|
||||
|| target.getTopology() != null || target.getTrace() != null || target.getService() != null) {
|
||||
throw unavailable();
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
private GatewayCommand.InvokeCommand withTarget(GatewayCommand.InvokeCommand command, AgentTargetRef target) {
|
||||
UserInput userInput = command.userInput().toBuilder().target(target).build();
|
||||
return new GatewayCommand.InvokeCommand(command.envelope(), command.replyMode(), command.commandId(), userInput,
|
||||
command.entryType());
|
||||
}
|
||||
|
||||
private boolean hasVersion(String version, String prefix) {
|
||||
return StringUtils.hasText(version) && version.startsWith(prefix);
|
||||
}
|
||||
|
||||
private TargetCanonicalizationException mismatch() {
|
||||
return new TargetCanonicalizationException(FailureKind.MISMATCH);
|
||||
}
|
||||
|
||||
private TargetCanonicalizationException unavailable() {
|
||||
return new TargetCanonicalizationException(FailureKind.UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.application;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.AgentApprovalHandling;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.TranscriptMessage;
|
||||
import org.apache.hertzbeat.common.entity.agent.AgentRun;
|
||||
import org.apache.hertzbeat.common.entity.agent.AgentSession;
|
||||
|
||||
/**
|
||||
* Linearized outcome of admitting one durable Agent Gateway message.
|
||||
*/
|
||||
public record AgentRunAdmission(
|
||||
Decision decision,
|
||||
AgentSession session,
|
||||
AgentRun run,
|
||||
AgentApprovalHandling approvalHandling,
|
||||
List<TranscriptMessage> chatHistory) {
|
||||
|
||||
public AgentRunAdmission {
|
||||
Objects.requireNonNull(decision, "Agent run admission decision is required");
|
||||
Objects.requireNonNull(session, "Agent run admission session is required");
|
||||
Objects.requireNonNull(run, "Agent run admission run is required");
|
||||
Objects.requireNonNull(approvalHandling, "Agent run admission approval handling is required");
|
||||
chatHistory = chatHistory == null ? List.of() : List.copyOf(chatHistory);
|
||||
}
|
||||
|
||||
/** Durable admission decisions for one normalized message identity. */
|
||||
public enum Decision {
|
||||
EXECUTE_NEW,
|
||||
REPLAY_ACTIVE,
|
||||
REPLAY_TERMINAL,
|
||||
REJECT_MISMATCH
|
||||
}
|
||||
}
|
||||
-90
@@ -1,90 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.application;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.InvokeCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.conversation.persistence.AgentRunDao;
|
||||
import org.apache.hertzbeat.ai.gateway.conversation.AgentSessionService;
|
||||
import org.apache.hertzbeat.common.entity.agent.AgentSession;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/** Resolves the canonical session before entering the serialized admission transaction. */
|
||||
@Service
|
||||
public class AgentRunAdmissionService {
|
||||
|
||||
private final AgentSessionService sessionService;
|
||||
private final AgentRunAdmissionTransaction admissionTransaction;
|
||||
private final AgentRunDao runDao;
|
||||
private final AgentTargetCanonicalizationService targetCanonicalizationService;
|
||||
|
||||
public AgentRunAdmissionService(AgentSessionService sessionService,
|
||||
AgentRunAdmissionTransaction admissionTransaction, AgentRunDao runDao,
|
||||
AgentTargetCanonicalizationService targetCanonicalizationService) {
|
||||
this.sessionService = sessionService;
|
||||
this.admissionTransaction = admissionTransaction;
|
||||
this.runDao = runDao;
|
||||
this.targetCanonicalizationService = targetCanonicalizationService;
|
||||
}
|
||||
|
||||
public AgentRunAdmission admit(InvokeCommand command) {
|
||||
validateCommandIdentity(command);
|
||||
if (!targetCanonicalizationService.requiresCanonicalization(command)) {
|
||||
return admitResolved(command, command);
|
||||
}
|
||||
Optional<AgentSession> existingSession = sessionService.findSession(
|
||||
command.envelope(), command.userInput().getConversationId());
|
||||
if (hasExistingRun(existingSession, command)) {
|
||||
return admissionTransaction.admit(existingSession.orElseThrow().getId(), command, null);
|
||||
}
|
||||
try {
|
||||
InvokeCommand canonicalCommand = targetCanonicalizationService.canonicalize(command);
|
||||
return admitResolved(command, canonicalCommand);
|
||||
} catch (RuntimeException failure) {
|
||||
Optional<AgentSession> winnerSession = sessionService.findSession(
|
||||
command.envelope(), command.userInput().getConversationId());
|
||||
if (hasExistingRun(winnerSession, command)) {
|
||||
return admissionTransaction.admit(winnerSession.orElseThrow().getId(), command, null);
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
private AgentRunAdmission admitResolved(InvokeCommand sourceCommand, InvokeCommand executionCommand) {
|
||||
AgentSession session = sessionService.findOrCreateSession(
|
||||
sourceCommand.envelope(), sourceCommand.userInput(), sourceCommand.entryType());
|
||||
if (!Objects.equals(session.getWorkspaceId(), sourceCommand.envelope().getWorkspaceId())) {
|
||||
throw new IllegalArgumentException("Agent run session workspace does not match the command");
|
||||
}
|
||||
return admissionTransaction.admit(session.getId(), sourceCommand, executionCommand);
|
||||
}
|
||||
|
||||
private boolean hasExistingRun(Optional<AgentSession> session, InvokeCommand command) {
|
||||
return session.isPresent() && runDao.findBySessionIdAndMessageId(
|
||||
session.get().getId(), command.userInput().getMessageId()).isPresent();
|
||||
}
|
||||
|
||||
private void validateCommandIdentity(InvokeCommand command) {
|
||||
if (!StringUtils.hasText(command.userInput().getMessageId())
|
||||
|| !Objects.equals(command.commandId(), command.userInput().getMessageId())) {
|
||||
throw new IllegalArgumentException("Agent command and message identity must match");
|
||||
}
|
||||
}
|
||||
}
|
||||
-200
@@ -1,200 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.application;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import org.apache.hertzbeat.ai.gateway.application.AgentRunAdmission.Decision;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.InvokeCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.ReplyMode;
|
||||
import org.apache.hertzbeat.ai.gateway.conversation.AgentRunService;
|
||||
import org.apache.hertzbeat.ai.gateway.conversation.AgentRunStatus;
|
||||
import org.apache.hertzbeat.ai.gateway.conversation.AgentTranscriptRecorder;
|
||||
import org.apache.hertzbeat.ai.gateway.conversation.persistence.AgentRunDao;
|
||||
import org.apache.hertzbeat.ai.gateway.conversation.persistence.AgentSessionDao;
|
||||
import org.apache.hertzbeat.ai.gateway.identity.AgentActor;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.AgentApprovalHandling;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.AgentRuntimeEntryType;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.TranscriptMessage;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentRunRequestSnapshot;
|
||||
import org.apache.hertzbeat.common.entity.agent.AgentRun;
|
||||
import org.apache.hertzbeat.common.entity.agent.AgentSession;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/** Serializes run admission after the canonical session has been resolved. */
|
||||
@Service
|
||||
public class AgentRunAdmissionTransaction {
|
||||
|
||||
private final AgentSessionDao sessionDao;
|
||||
private final AgentRunDao runDao;
|
||||
private final AgentRunService runService;
|
||||
private final AgentTranscriptRecorder transcriptRecorder;
|
||||
private final AgentTargetCanonicalizationService targetCanonicalizationService;
|
||||
|
||||
public AgentRunAdmissionTransaction(AgentSessionDao sessionDao, AgentRunDao runDao,
|
||||
AgentRunService runService, AgentTranscriptRecorder transcriptRecorder,
|
||||
AgentTargetCanonicalizationService targetCanonicalizationService) {
|
||||
this.sessionDao = sessionDao;
|
||||
this.runDao = runDao;
|
||||
this.runService = runService;
|
||||
this.transcriptRecorder = transcriptRecorder;
|
||||
this.targetCanonicalizationService = targetCanonicalizationService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AgentRunAdmission admit(Long sessionId, InvokeCommand command) {
|
||||
return admit(sessionId, command, command);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AgentRunAdmission admit(Long sessionId, InvokeCommand sourceCommand, InvokeCommand executionCommand) {
|
||||
AgentSession session = sessionDao.findFirstById(sessionId)
|
||||
.orElseThrow(() -> new IllegalStateException("Agent run session disappeared during admission"));
|
||||
validateSessionIdentity(session, sourceCommand);
|
||||
AgentApprovalHandling approvalHandling = approvalHandling(sourceCommand.replyMode());
|
||||
Optional<AgentRun> existing = runDao.findBySessionIdAndMessageId(
|
||||
session.getId(), sourceCommand.userInput().getMessageId());
|
||||
if (existing.isPresent()) {
|
||||
InvokeCommand replayCommand = replayCommand(sourceCommand, existing.get(), executionCommand);
|
||||
if (replayCommand == null) {
|
||||
return admission(Decision.REJECT_MISMATCH, session, existing.get(), approvalHandling);
|
||||
}
|
||||
String fingerprint = AgentRunRequestFingerprint.from(replayCommand, approvalHandling);
|
||||
return admitExisting(session, existing.get(), replayCommand, approvalHandling, fingerprint);
|
||||
}
|
||||
if (executionCommand == null) {
|
||||
throw new IllegalStateException("Canonical target is required for a new Agent run");
|
||||
}
|
||||
AgentRunRequestSnapshot requestSnapshot = AgentRunRequestFingerprint.snapshot(
|
||||
executionCommand, approvalHandling);
|
||||
String fingerprint = AgentRunRequestFingerprint.from(requestSnapshot);
|
||||
return createAndAdmit(session, executionCommand, approvalHandling, fingerprint, requestSnapshot);
|
||||
}
|
||||
|
||||
private InvokeCommand replayCommand(InvokeCommand sourceCommand, AgentRun run, InvokeCommand executionCommand) {
|
||||
if (!targetCanonicalizationService.requiresCanonicalization(sourceCommand)) {
|
||||
return executionCommand == null ? sourceCommand : executionCommand;
|
||||
}
|
||||
try {
|
||||
return targetCanonicalizationService.replayCommand(sourceCommand, AgentRunService.targetFromRun(run));
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private AgentRunAdmission createAndAdmit(AgentSession session, InvokeCommand command,
|
||||
AgentApprovalHandling approvalHandling, String fingerprint,
|
||||
AgentRunRequestSnapshot requestSnapshot) {
|
||||
List<TranscriptMessage> history = transcriptRecorder.chatHistory(session.getId());
|
||||
AgentRun run = runService.createOrResumeRun(session, command.userInput(), command.entryType());
|
||||
transcriptRecorder.recordUserTranscriptEntry(session, run, command.userInput(),
|
||||
AgentRunRequestFingerprint.VERSION, fingerprint, requestSnapshot);
|
||||
AgentRun running = runService.markRunning(run);
|
||||
return new AgentRunAdmission(Decision.EXECUTE_NEW, session, running, approvalHandling, history);
|
||||
}
|
||||
|
||||
private AgentRunAdmission admitExisting(AgentSession session, AgentRun run, InvokeCommand command,
|
||||
AgentApprovalHandling approvalHandling, String fingerprint) {
|
||||
if (!sameRunIdentity(session, run, command)) {
|
||||
return admission(Decision.REJECT_MISMATCH, session, run, approvalHandling);
|
||||
}
|
||||
Optional<TranscriptMessage> requestMessage = transcriptRecorder.findRunRequestMessage(run.getId());
|
||||
if (requestMessage.isEmpty()) {
|
||||
if (isScheduleReservation(run, command)) {
|
||||
return admitScheduleReservation(session, run, command, approvalHandling, fingerprint);
|
||||
}
|
||||
return admission(Decision.REJECT_MISMATCH, session, run, approvalHandling);
|
||||
}
|
||||
if (!matchesFingerprint(requestMessage.get(), fingerprint)) {
|
||||
return admission(Decision.REJECT_MISMATCH, session, run, approvalHandling);
|
||||
}
|
||||
AgentRunStatus status = status(run);
|
||||
if (status == AgentRunStatus.RUNNING) {
|
||||
return admission(Decision.REPLAY_ACTIVE, session, run, approvalHandling);
|
||||
}
|
||||
if (status == AgentRunStatus.SUCCEEDED
|
||||
|| status == AgentRunStatus.FAILED
|
||||
|| status == AgentRunStatus.CANCELLED
|
||||
|| status == AgentRunStatus.RECOVERY_REQUIRED) {
|
||||
return admission(Decision.REPLAY_TERMINAL, session, run, approvalHandling);
|
||||
}
|
||||
return admission(Decision.REJECT_MISMATCH, session, run, approvalHandling);
|
||||
}
|
||||
|
||||
private AgentRunAdmission admitScheduleReservation(AgentSession session, AgentRun run, InvokeCommand command,
|
||||
AgentApprovalHandling approvalHandling, String fingerprint) {
|
||||
List<TranscriptMessage> history = transcriptRecorder.chatHistory(session.getId());
|
||||
transcriptRecorder.recordUserTranscriptEntry(session, run, command.userInput(),
|
||||
AgentRunRequestFingerprint.VERSION, fingerprint);
|
||||
AgentRun running = runService.markRunning(run);
|
||||
return new AgentRunAdmission(Decision.EXECUTE_NEW, session, running, approvalHandling, history);
|
||||
}
|
||||
|
||||
private boolean matchesFingerprint(TranscriptMessage requestMessage, String fingerprint) {
|
||||
return Objects.equals(AgentRunRequestFingerprint.VERSION, requestMessage.getRequestFingerprintVersion())
|
||||
&& Objects.equals(fingerprint, requestMessage.getRequestFingerprint());
|
||||
}
|
||||
|
||||
private boolean sameRunIdentity(AgentSession session, AgentRun run, InvokeCommand command) {
|
||||
return Objects.equals(session.getId(), run.getSessionId())
|
||||
&& Objects.equals(command.userInput().getMessageId(), run.getMessageId())
|
||||
&& Objects.equals(command.entryType().name(), run.getEntryType());
|
||||
}
|
||||
|
||||
private boolean isScheduleReservation(AgentRun run, InvokeCommand command) {
|
||||
return status(run) == AgentRunStatus.CREATED
|
||||
&& command.entryType() == AgentRuntimeEntryType.SCHEDULE_TRIGGER
|
||||
&& Objects.equals(AgentRuntimeEntryType.SCHEDULE_TRIGGER.name(), run.getEntryType())
|
||||
&& Objects.equals(AgentRunService.targetFromRun(run), command.userInput().getTarget());
|
||||
}
|
||||
|
||||
private AgentRunAdmission admission(Decision decision, AgentSession session, AgentRun run,
|
||||
AgentApprovalHandling approvalHandling) {
|
||||
return new AgentRunAdmission(decision, session, run, approvalHandling, List.of());
|
||||
}
|
||||
|
||||
private AgentRunStatus status(AgentRun run) {
|
||||
if (!StringUtils.hasText(run.getStatus())) {
|
||||
throw new IllegalStateException("Agent run status is required during admission");
|
||||
}
|
||||
return AgentRunStatus.valueOf(run.getStatus());
|
||||
}
|
||||
|
||||
private AgentApprovalHandling approvalHandling(ReplyMode replyMode) {
|
||||
return replyMode == ReplyMode.STREAM
|
||||
? AgentApprovalHandling.WAIT_FOR_DECISION
|
||||
: AgentApprovalHandling.DENY;
|
||||
}
|
||||
|
||||
private void validateSessionIdentity(AgentSession session, InvokeCommand command) {
|
||||
AgentActor actor = command.envelope().getActor();
|
||||
boolean matches = actor != null
|
||||
&& Objects.equals(session.getWorkspaceId(), command.envelope().getWorkspaceId())
|
||||
&& Objects.equals(session.getChannel(), command.envelope().getChannelId())
|
||||
&& Objects.equals(session.getActorType(), actor.getType())
|
||||
&& Objects.equals(session.getActorId(), actor.getId())
|
||||
&& Objects.equals(session.getOriginEntryType(), command.entryType().name())
|
||||
&& Objects.equals(session.getConversationId(), command.userInput().getConversationId());
|
||||
if (!matches) {
|
||||
throw new IllegalArgumentException("Agent run session identity does not match the command");
|
||||
}
|
||||
}
|
||||
}
|
||||
-89
@@ -1,89 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.application;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.InvokeCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentRunRequestSnapshot;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.AgentApprovalHandling;
|
||||
import org.apache.hertzbeat.ai.gateway.text.GatewayText;
|
||||
import org.apache.hertzbeat.common.util.JsonUtil;
|
||||
|
||||
/**
|
||||
* Stable hash of fields that can change one runtime invocation's behavior.
|
||||
*/
|
||||
public final class AgentRunRequestFingerprint {
|
||||
|
||||
public static final String VERSION = "1";
|
||||
|
||||
private AgentRunRequestFingerprint() {
|
||||
}
|
||||
|
||||
static String from(InvokeCommand command, AgentApprovalHandling approvalHandling) {
|
||||
return from(snapshot(command, approvalHandling));
|
||||
}
|
||||
|
||||
public static String from(AgentRunRequestSnapshot request) {
|
||||
FingerprintMaterial material = new FingerprintMaterial(
|
||||
request.entryType(), request.target(), request.alertIncident(), request.message(),
|
||||
List.copyOf(request.attachments()), GatewayText.normalize(request.preferredLanguage()),
|
||||
request.approvalHandling(), request.replyMode());
|
||||
String canonical = JsonUtil.toJson(material);
|
||||
if (!org.springframework.util.StringUtils.hasText(canonical)) {
|
||||
throw new IllegalStateException("Agent run request fingerprint material cannot be serialized");
|
||||
}
|
||||
try {
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest(canonical.getBytes(StandardCharsets.UTF_8));
|
||||
return "sha256:" + HexFormat.of().formatHex(digest);
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 is required for Agent run request identity", exception);
|
||||
}
|
||||
}
|
||||
|
||||
static AgentRunRequestSnapshot snapshot(InvokeCommand command, AgentApprovalHandling approvalHandling) {
|
||||
return AgentRunRequestSnapshot.builder()
|
||||
.version(AgentRunRequestSnapshot.VERSION)
|
||||
.conversationId(command.userInput().getConversationId())
|
||||
.messageId(command.userInput().getMessageId())
|
||||
.entryType(command.entryType().name())
|
||||
.target(command.userInput().getTarget())
|
||||
.alertIncident(command.userInput().getAlertIncident())
|
||||
.message(command.userInput().getMessage().getText())
|
||||
.attachments(command.userInput().getMessage().getAttachments())
|
||||
.preferredLanguage(GatewayText.normalize(command.envelope().getPreferredLanguage()))
|
||||
.approvalHandling(approvalHandling.name())
|
||||
.replyMode(command.replyMode().name())
|
||||
.build();
|
||||
}
|
||||
|
||||
private record FingerprintMaterial(
|
||||
String entryType,
|
||||
Object target,
|
||||
Object alertIncident,
|
||||
String message,
|
||||
List<String> attachments,
|
||||
String preferredLanguage,
|
||||
String approvalHandling,
|
||||
String replyMode) {
|
||||
}
|
||||
}
|
||||
-159
@@ -1,159 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.application;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.TreeMap;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentTargetAuthority;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentTargetRef;
|
||||
import org.apache.hertzbeat.alert.service.AlertService;
|
||||
import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/** Resolves and rechecks the persisted authority for an exact workspace-owned SingleAlert. */
|
||||
@Service
|
||||
public class AgentSingleAlertTargetAuthorityService {
|
||||
|
||||
public static final String TARGET_VERSION_PREFIX = "single-alert.";
|
||||
public static final String AUTHORITY_VERSION_PREFIX = "single-alert-authority.";
|
||||
public static final String TARGET_VERSION = "single-alert.v1";
|
||||
public static final String AUTHORITY_VERSION = "single-alert-authority.v1";
|
||||
public static final String ALERT_TYPE = "single";
|
||||
|
||||
private final AlertService alertService;
|
||||
|
||||
public AgentSingleAlertTargetAuthorityService(AlertService alertService) {
|
||||
this.alertService = alertService;
|
||||
}
|
||||
|
||||
public AgentTargetRef canonicalize(String workspaceId, long alertId) {
|
||||
if (!StringUtils.hasText(workspaceId) || alertId <= 0) {
|
||||
throw unavailable();
|
||||
}
|
||||
SingleAlert alert;
|
||||
try {
|
||||
alert = alertService.findSingleAlert(workspaceId, alertId)
|
||||
.filter(candidate -> Objects.equals(workspaceId, candidate.getWorkspaceId()))
|
||||
.filter(candidate -> Objects.equals(alertId, candidate.getId()))
|
||||
.orElseThrow(this::unavailable);
|
||||
} catch (UnavailableException failure) {
|
||||
throw failure;
|
||||
} catch (RuntimeException ignored) {
|
||||
throw unavailable();
|
||||
}
|
||||
return AgentTargetRef.builder()
|
||||
.version(TARGET_VERSION)
|
||||
.alertId(alertId)
|
||||
.alertType(ALERT_TYPE)
|
||||
.authority(AgentTargetAuthority.builder()
|
||||
.bindingId(alertId)
|
||||
.version(AUTHORITY_VERSION)
|
||||
.hash(authorityHash(workspaceId, alert))
|
||||
.build())
|
||||
.build();
|
||||
}
|
||||
|
||||
public boolean verify(String workspaceId, AgentTargetRef target) {
|
||||
if (!isCanonicalTarget(target)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return matches(target, canonicalize(workspaceId, target.getAlertId()));
|
||||
} catch (UnavailableException ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isCanonicalTarget(AgentTargetRef target) {
|
||||
AgentTargetAuthority authority = target == null ? null : target.getAuthority();
|
||||
return target != null
|
||||
&& TARGET_VERSION.equals(target.getVersion())
|
||||
&& target.getAlertId() != null && target.getAlertId() > 0
|
||||
&& ALERT_TYPE.equals(target.getAlertType())
|
||||
&& authority != null
|
||||
&& Objects.equals(target.getAlertId(), authority.getBindingId())
|
||||
&& AUTHORITY_VERSION.equals(authority.getVersion())
|
||||
&& StringUtils.hasText(authority.getHash())
|
||||
&& target.getMonitorId() == null && target.getEntityId() == null
|
||||
&& target.getCollector() == null && target.getSignal() == null
|
||||
&& target.getTopology() == null && target.getTrace() == null && target.getLog() == null
|
||||
&& target.getService() == null;
|
||||
}
|
||||
|
||||
public boolean matches(AgentTargetRef expected, AgentTargetRef actual) {
|
||||
return isCanonicalTarget(expected) && Objects.equals(expected, actual);
|
||||
}
|
||||
|
||||
private String authorityHash(String workspaceId, SingleAlert alert) {
|
||||
StringBuilder material = new StringBuilder("single-alert-authority.v1;");
|
||||
append(material, "workspace", workspaceId);
|
||||
append(material, "id", alert.getId());
|
||||
append(material, "fingerprint", alert.getFingerprint());
|
||||
append(material, "status", alert.getStatus());
|
||||
append(material, "content", alert.getContent());
|
||||
append(material, "triggerTimes", alert.getTriggerTimes());
|
||||
append(material, "startAt", alert.getStartAt());
|
||||
append(material, "activeAt", alert.getActiveAt());
|
||||
append(material, "endAt", alert.getEndAt());
|
||||
appendMap(material, "labels", alert.getLabels());
|
||||
appendMap(material, "annotations", alert.getAnnotations());
|
||||
try {
|
||||
return "sha256:" + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(material.toString().getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 is required for alert target authority", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void appendMap(StringBuilder material, String field, Map<String, String> values) {
|
||||
append(material, field + "Size", values == null ? null : values.size());
|
||||
if (values != null) {
|
||||
new TreeMap<>(values).forEach((key, value) -> {
|
||||
append(material, field + "Key", key);
|
||||
append(material, field + "Value", value);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void append(StringBuilder material, String field, Object value) {
|
||||
String text = value == null ? null : String.valueOf(value);
|
||||
material.append(field).append(':').append(text == null ? -1 : text.length()).append(':');
|
||||
if (text != null) {
|
||||
material.append(text);
|
||||
}
|
||||
material.append(';');
|
||||
}
|
||||
|
||||
private UnavailableException unavailable() {
|
||||
return new UnavailableException();
|
||||
}
|
||||
|
||||
/** Cause-free failure used at the channel boundary. */
|
||||
public static final class UnavailableException extends IllegalArgumentException {
|
||||
|
||||
UnavailableException() {
|
||||
super("Single alert target is unavailable");
|
||||
}
|
||||
}
|
||||
}
|
||||
-506
@@ -1,506 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.application;
|
||||
|
||||
import java.util.Objects;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.InvokeCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentServiceRef;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentSignalRef;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentTargetAuthority;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentTargetRef;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.UserInput;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.AgentRuntimeEntryType;
|
||||
import org.apache.hertzbeat.ai.gateway.text.GatewayText;
|
||||
import org.apache.hertzbeat.common.util.JsonUtil;
|
||||
import org.apache.hertzbeat.manager.service.entity.EntityMonitorMetricTargetCanonicalizer;
|
||||
import org.apache.hertzbeat.manager.service.metric.MonitorMetricQueryContract;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
/** Adapts untrusted Gateway monitor-metric intent to the manager-authoritative target contract. */
|
||||
@Service
|
||||
public class AgentTargetCanonicalizationService {
|
||||
|
||||
private final EntityMonitorMetricTargetCanonicalizer canonicalizer;
|
||||
private final AgentSingleAlertTargetAuthorityService alertAuthorityService;
|
||||
private final AgentEntityTargetAuthorityService entityAuthorityService;
|
||||
private final AgentTopologyTargetAuthorityService topologyAuthorityService;
|
||||
private final AgentTraceTargetCanonicalizationAdapter traceAdapter;
|
||||
private final AgentLogTargetCanonicalizationAdapter logAdapter;
|
||||
|
||||
public AgentTargetCanonicalizationService(EntityMonitorMetricTargetCanonicalizer canonicalizer,
|
||||
AgentSingleAlertTargetAuthorityService alertAuthorityService,
|
||||
AgentEntityTargetAuthorityService entityAuthorityService,
|
||||
AgentTopologyTargetAuthorityService topologyAuthorityService) {
|
||||
this(canonicalizer, alertAuthorityService, entityAuthorityService, topologyAuthorityService, null, null);
|
||||
}
|
||||
|
||||
public AgentTargetCanonicalizationService(EntityMonitorMetricTargetCanonicalizer canonicalizer,
|
||||
AgentSingleAlertTargetAuthorityService alertAuthorityService,
|
||||
AgentEntityTargetAuthorityService entityAuthorityService,
|
||||
AgentTopologyTargetAuthorityService topologyAuthorityService,
|
||||
AgentTraceTargetCanonicalizationAdapter traceAdapter) {
|
||||
this(canonicalizer, alertAuthorityService, entityAuthorityService, topologyAuthorityService,
|
||||
traceAdapter, null);
|
||||
}
|
||||
|
||||
@Autowired
|
||||
public AgentTargetCanonicalizationService(EntityMonitorMetricTargetCanonicalizer canonicalizer,
|
||||
AgentSingleAlertTargetAuthorityService alertAuthorityService,
|
||||
AgentEntityTargetAuthorityService entityAuthorityService,
|
||||
AgentTopologyTargetAuthorityService topologyAuthorityService,
|
||||
AgentTraceTargetCanonicalizationAdapter traceAdapter,
|
||||
AgentLogTargetCanonicalizationAdapter logAdapter) {
|
||||
this.canonicalizer = canonicalizer;
|
||||
this.alertAuthorityService = alertAuthorityService;
|
||||
this.entityAuthorityService = entityAuthorityService;
|
||||
this.topologyAuthorityService = topologyAuthorityService;
|
||||
this.traceAdapter = traceAdapter;
|
||||
this.logAdapter = logAdapter;
|
||||
}
|
||||
|
||||
public boolean requiresCanonicalization(InvokeCommand command) {
|
||||
AgentTargetRef target = command.userInput().getTarget();
|
||||
AgentSignalRef signal = target == null ? null : target.getSignal();
|
||||
return command.entryType() == AgentRuntimeEntryType.USER_INPUT
|
||||
&& target != null && (hasCanonicalMarker(target) || isAlertIntent(target) || isTopologyIntent(target)
|
||||
|| isEntityIntent(target) || traceAdapter != null && traceAdapter.isIntent(target)
|
||||
|| logAdapter != null && logAdapter.isIntent(target)
|
||||
|| target.getMonitorId() != null && signal != null && "metrics".equals(signal.getType()));
|
||||
}
|
||||
|
||||
public InvokeCommand canonicalize(InvokeCommand sourceCommand) {
|
||||
if (isAlertIntent(sourceCommand.userInput().getTarget())) {
|
||||
return canonicalizeAlert(sourceCommand);
|
||||
}
|
||||
if (isTopologyIntent(sourceCommand.userInput().getTarget())) {
|
||||
return canonicalizeTopology(sourceCommand);
|
||||
}
|
||||
if (traceAdapter != null && traceAdapter.isIntent(sourceCommand.userInput().getTarget())) {
|
||||
return traceAdapter.canonicalize(sourceCommand);
|
||||
}
|
||||
if (logAdapter != null && logAdapter.isIntent(sourceCommand.userInput().getTarget())) {
|
||||
return logAdapter.canonicalize(sourceCommand);
|
||||
}
|
||||
if (isEntityIntent(sourceCommand.userInput().getTarget())) {
|
||||
return canonicalizeEntity(sourceCommand);
|
||||
}
|
||||
AgentTargetRef source = requireSourceIntent(sourceCommand.userInput().getTarget());
|
||||
AgentSignalRef signal = source.getSignal();
|
||||
EntityMonitorMetricTargetCanonicalizer.CanonicalTarget canonical;
|
||||
try {
|
||||
canonical = canonicalizer.canonicalize(sourceCommand.envelope().getWorkspaceId(),
|
||||
new EntityMonitorMetricTargetCanonicalizer.SourceIntent(source.getMonitorId(), signal.getType(),
|
||||
signal.getQuery(), signal.getStart(), signal.getEnd(), signal.getTimezone()));
|
||||
} catch (EntityMonitorMetricTargetCanonicalizer.CanonicalizationException failure) {
|
||||
throw new TargetCanonicalizationException(
|
||||
failure.kind() == EntityMonitorMetricTargetCanonicalizer.FailureKind.MISMATCH
|
||||
? FailureKind.MISMATCH : FailureKind.UNAVAILABLE);
|
||||
} catch (RuntimeException ignored) {
|
||||
throw unavailable();
|
||||
}
|
||||
if (canonical == null || canonical.service() == null || canonical.signal() == null
|
||||
|| canonical.authority() == null) {
|
||||
throw unavailable();
|
||||
}
|
||||
AgentTargetRef target = AgentTargetRef.builder()
|
||||
.version(canonical.version())
|
||||
.entityId(canonical.entityId())
|
||||
.monitorId(canonical.monitorId())
|
||||
.service(AgentServiceRef.builder()
|
||||
.name(canonical.service().name())
|
||||
.namespace(canonical.service().namespace())
|
||||
.environment(canonical.service().environment())
|
||||
.build())
|
||||
.signal(AgentSignalRef.builder()
|
||||
.type(canonical.signal().type())
|
||||
.query(canonical.signal().query())
|
||||
.start(canonical.signal().start())
|
||||
.end(canonical.signal().end())
|
||||
.timezone(canonical.signal().timezone())
|
||||
.build())
|
||||
.authority(AgentTargetAuthority.builder()
|
||||
.bindingId(canonical.authority().bindingId())
|
||||
.version(canonical.authority().version())
|
||||
.hash(canonical.authority().hash())
|
||||
.build())
|
||||
.build();
|
||||
requireSafeCanonicalTarget(target);
|
||||
return withTarget(sourceCommand, target);
|
||||
}
|
||||
|
||||
public InvokeCommand replayCommand(InvokeCommand sourceCommand, AgentTargetRef persistedTarget) {
|
||||
if (isAlertIntent(sourceCommand.userInput().getTarget())) {
|
||||
AgentTargetRef source = requireAlertSourceIntent(sourceCommand.userInput().getTarget());
|
||||
if (!isCanonicalAlertTarget(persistedTarget)
|
||||
|| !Objects.equals(alertSourceIntent(source), alertSourceIntent(persistedTarget))) {
|
||||
throw mismatch();
|
||||
}
|
||||
return withTarget(sourceCommand, persistedTarget);
|
||||
}
|
||||
if (isTopologyIntent(sourceCommand.userInput().getTarget())) {
|
||||
AgentTargetRef source = requireTopologySourceIntent(sourceCommand.userInput().getTarget());
|
||||
if (!isCanonicalTopologyTarget(persistedTarget)
|
||||
|| !Objects.equals(normalizedTopologySourceIntent(source), topologySourceIntent(persistedTarget))) {
|
||||
throw mismatch();
|
||||
}
|
||||
return withTarget(sourceCommand, persistedTarget);
|
||||
}
|
||||
if (traceAdapter != null && traceAdapter.isIntent(sourceCommand.userInput().getTarget())) {
|
||||
return traceAdapter.replayCommand(sourceCommand, persistedTarget);
|
||||
}
|
||||
if (logAdapter != null && logAdapter.isIntent(sourceCommand.userInput().getTarget())) {
|
||||
return logAdapter.replayCommand(sourceCommand, persistedTarget);
|
||||
}
|
||||
if (isEntityIntent(sourceCommand.userInput().getTarget())) {
|
||||
AgentTargetRef source = requireEntitySourceIntent(sourceCommand.userInput().getTarget());
|
||||
if (!isCanonicalEntityTarget(persistedTarget)
|
||||
|| !Objects.equals(entitySourceIntent(source), entitySourceIntent(persistedTarget))) {
|
||||
throw mismatch();
|
||||
}
|
||||
return withTarget(sourceCommand, persistedTarget);
|
||||
}
|
||||
AgentTargetRef source = requireSourceIntent(sourceCommand.userInput().getTarget());
|
||||
if (!isCanonicalTarget(persistedTarget)
|
||||
|| !Objects.equals(sourceIntent(source), sourceIntent(persistedTarget))) {
|
||||
throw mismatch();
|
||||
}
|
||||
return withTarget(sourceCommand, persistedTarget);
|
||||
}
|
||||
|
||||
public static AgentTargetRef retrySourceIntent(AgentTargetRef target) {
|
||||
if (isCanonicalAlertTarget(target)) {
|
||||
return alertSourceIntent(target);
|
||||
}
|
||||
if (isCanonicalEntityTarget(target)) {
|
||||
return entitySourceIntent(target);
|
||||
}
|
||||
if (isCanonicalTopologyTarget(target)) {
|
||||
return topologySourceIntent(target);
|
||||
}
|
||||
if (AgentTraceTargetAuthorityService.TARGET_VERSION.equals(target == null ? null : target.getVersion())) {
|
||||
return AgentTraceTargetCanonicalizationAdapter.sourceIntent(target);
|
||||
}
|
||||
if (AgentLogTargetAuthorityService.TARGET_VERSION.equals(target == null ? null : target.getVersion())) {
|
||||
return AgentLogTargetCanonicalizationAdapter.sourceIntent(target);
|
||||
}
|
||||
return isCanonicalTarget(target) ? sourceIntent(target) : target;
|
||||
}
|
||||
|
||||
public static boolean isCanonicalTarget(AgentTargetRef target) {
|
||||
return target != null
|
||||
&& EntityMonitorMetricTargetCanonicalizer.TARGET_VERSION.equals(target.getVersion())
|
||||
&& target.getEntityId() != null && target.getMonitorId() != null
|
||||
&& target.getService() != null && target.getAuthority() != null
|
||||
&& target.getSignal() != null && "metrics".equals(target.getSignal().getType())
|
||||
&& target.getAlertId() == null && target.getAlertType() == null
|
||||
&& target.getCollector() == null && target.getTopology() == null && target.getTrace() == null
|
||||
&& target.getLog() == null;
|
||||
}
|
||||
|
||||
public static boolean isCanonicalAlertTarget(AgentTargetRef target) {
|
||||
AgentTargetAuthority authority = target == null ? null : target.getAuthority();
|
||||
return target != null
|
||||
&& AgentSingleAlertTargetAuthorityService.TARGET_VERSION.equals(target.getVersion())
|
||||
&& target.getAlertId() != null && target.getAlertId() > 0
|
||||
&& AgentSingleAlertTargetAuthorityService.ALERT_TYPE.equals(target.getAlertType())
|
||||
&& authority != null
|
||||
&& Objects.equals(target.getAlertId(), authority.getBindingId())
|
||||
&& AgentSingleAlertTargetAuthorityService.AUTHORITY_VERSION.equals(authority.getVersion())
|
||||
&& StringUtils.hasText(authority.getHash())
|
||||
&& target.getMonitorId() == null && target.getEntityId() == null
|
||||
&& target.getCollector() == null && target.getSignal() == null
|
||||
&& target.getTopology() == null && target.getTrace() == null && target.getLog() == null
|
||||
&& target.getService() == null;
|
||||
}
|
||||
|
||||
public static boolean isCanonicalEntityTarget(AgentTargetRef target) {
|
||||
AgentTargetAuthority authority = target == null ? null : target.getAuthority();
|
||||
return target != null
|
||||
&& AgentEntityTargetAuthorityService.TARGET_VERSION.equals(target.getVersion())
|
||||
&& target.getEntityId() != null && target.getEntityId() > 0
|
||||
&& authority != null
|
||||
&& Objects.equals(target.getEntityId(), authority.getBindingId())
|
||||
&& AgentEntityTargetAuthorityService.AUTHORITY_VERSION.equals(authority.getVersion())
|
||||
&& StringUtils.hasText(authority.getHash())
|
||||
&& target.getMonitorId() == null && target.getAlertId() == null
|
||||
&& target.getAlertType() == null && target.getCollector() == null
|
||||
&& target.getSignal() == null && target.getTopology() == null && target.getTrace() == null
|
||||
&& target.getLog() == null
|
||||
&& target.getService() == null;
|
||||
}
|
||||
|
||||
public static boolean isCanonicalTopologyTarget(AgentTargetRef target) {
|
||||
AgentTargetAuthority authority = target == null ? null : target.getAuthority();
|
||||
return target != null
|
||||
&& AgentTopologyTargetAuthorityService.TARGET_VERSION.equals(target.getVersion())
|
||||
&& target.getEntityId() != null && target.getEntityId() > 0
|
||||
&& target.getTopology() != null
|
||||
&& Objects.equals(target.getEntityId(), target.getTopology().getRootEntityId())
|
||||
&& authority != null
|
||||
&& Objects.equals(target.getEntityId(), authority.getBindingId())
|
||||
&& AgentTopologyTargetAuthorityService.AUTHORITY_VERSION.equals(authority.getVersion())
|
||||
&& StringUtils.hasText(authority.getHash())
|
||||
&& target.getMonitorId() == null && target.getAlertId() == null && target.getAlertType() == null
|
||||
&& target.getCollector() == null && target.getSignal() == null && target.getTrace() == null
|
||||
&& target.getLog() == null
|
||||
&& target.getService() == null;
|
||||
}
|
||||
|
||||
private AgentTargetRef requireSourceIntent(AgentTargetRef target) {
|
||||
if (target == null || target.getMonitorId() == null || target.getSignal() == null
|
||||
|| target.getVersion() != null || target.getEntityId() != null || target.getService() != null
|
||||
|| target.getAuthority() != null || target.getAlertId() != null || target.getAlertType() != null
|
||||
|| target.getCollector() != null
|
||||
|| target.getTopology() != null || target.getTrace() != null || target.getLog() != null
|
||||
|| target.getSignal().getTimeRange() != null
|
||||
|| !MonitorMetricQueryContract.isExactWindowAllowed(
|
||||
target.getSignal().getStart(), target.getSignal().getEnd())) {
|
||||
throw mismatch();
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
private static AgentTargetRef sourceIntent(AgentTargetRef target) {
|
||||
AgentSignalRef signal = target.getSignal();
|
||||
return AgentTargetRef.builder()
|
||||
.monitorId(target.getMonitorId())
|
||||
.signal(AgentSignalRef.builder()
|
||||
.type(signal.getType())
|
||||
.query(signal.getQuery())
|
||||
.start(signal.getStart())
|
||||
.end(signal.getEnd())
|
||||
.timezone(signal.getTimezone())
|
||||
.build())
|
||||
.build();
|
||||
}
|
||||
|
||||
private InvokeCommand canonicalizeAlert(InvokeCommand command) {
|
||||
AgentTargetRef source = requireAlertSourceIntent(command.userInput().getTarget());
|
||||
if (alertAuthorityService == null) {
|
||||
throw unavailable();
|
||||
}
|
||||
try {
|
||||
AgentTargetRef target = alertAuthorityService.canonicalize(
|
||||
command.envelope().getWorkspaceId(), source.getAlertId());
|
||||
requireSafeCanonicalTarget(target);
|
||||
return withTarget(command, target);
|
||||
} catch (AgentSingleAlertTargetAuthorityService.UnavailableException failure) {
|
||||
throw unavailable();
|
||||
} catch (RuntimeException ignored) {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
private AgentTargetRef requireAlertSourceIntent(AgentTargetRef target) {
|
||||
if (target == null || target.getAlertId() == null || target.getAlertId() <= 0
|
||||
|| !AgentSingleAlertTargetAuthorityService.ALERT_TYPE.equals(target.getAlertType())
|
||||
|| target.getVersion() != null || target.getAuthority() != null
|
||||
|| target.getMonitorId() != null || target.getEntityId() != null
|
||||
|| target.getCollector() != null || target.getSignal() != null
|
||||
|| target.getTopology() != null || target.getTrace() != null || target.getLog() != null
|
||||
|| target.getService() != null) {
|
||||
throw unavailable();
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
private static AgentTargetRef alertSourceIntent(AgentTargetRef target) {
|
||||
return AgentTargetRef.builder()
|
||||
.alertId(target.getAlertId())
|
||||
.alertType(AgentSingleAlertTargetAuthorityService.ALERT_TYPE)
|
||||
.build();
|
||||
}
|
||||
|
||||
private InvokeCommand canonicalizeEntity(InvokeCommand command) {
|
||||
AgentTargetRef source = requireEntitySourceIntent(command.userInput().getTarget());
|
||||
if (entityAuthorityService == null) {
|
||||
throw unavailable();
|
||||
}
|
||||
try {
|
||||
AgentTargetRef target = entityAuthorityService.canonicalize(
|
||||
command.envelope().getWorkspaceId(), source.getEntityId());
|
||||
requireSafeCanonicalTarget(target);
|
||||
return withTarget(command, target);
|
||||
} catch (AgentEntityTargetAuthorityService.UnavailableException failure) {
|
||||
throw unavailable();
|
||||
} catch (RuntimeException ignored) {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
private AgentTargetRef requireEntitySourceIntent(AgentTargetRef target) {
|
||||
if (target == null || target.getEntityId() == null || target.getEntityId() <= 0
|
||||
|| target.getVersion() != null || target.getAuthority() != null
|
||||
|| target.getMonitorId() != null || target.getAlertId() != null || target.getAlertType() != null
|
||||
|| target.getCollector() != null || target.getSignal() != null
|
||||
|| target.getTopology() != null || target.getTrace() != null || target.getLog() != null
|
||||
|| target.getService() != null) {
|
||||
throw unavailable();
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
private static AgentTargetRef entitySourceIntent(AgentTargetRef target) {
|
||||
return AgentTargetRef.builder().entityId(target.getEntityId()).build();
|
||||
}
|
||||
|
||||
private InvokeCommand canonicalizeTopology(InvokeCommand command) {
|
||||
AgentTargetRef source = requireTopologySourceIntent(command.userInput().getTarget());
|
||||
if (topologyAuthorityService == null) {
|
||||
throw unavailable();
|
||||
}
|
||||
try {
|
||||
AgentTargetRef target = topologyAuthorityService.canonicalize(
|
||||
command.envelope().getWorkspaceId(), source.getTopology());
|
||||
requireSafeCanonicalTarget(target);
|
||||
return withTarget(command, target);
|
||||
} catch (AgentTopologyTargetAuthorityService.UnavailableException failure) {
|
||||
throw unavailable();
|
||||
} catch (RuntimeException ignored) {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
private AgentTargetRef requireTopologySourceIntent(AgentTargetRef target) {
|
||||
if (target == null || target.getTopology() == null
|
||||
|| target.getTopology().getRootEntityId() == null || target.getTopology().getRootEntityId() <= 0
|
||||
|| target.getTopology().getDepth() == null || target.getTopology().getDepth() < 1
|
||||
|| target.getTopology().getDepth() > 2
|
||||
|| target.getTopology().getNodeId() != null && target.getTopology().getEdgeId() != null
|
||||
|| target.getVersion() != null || target.getAuthority() != null || target.getEntityId() != null
|
||||
|| target.getMonitorId() != null || target.getAlertId() != null || target.getAlertType() != null
|
||||
|| target.getCollector() != null || target.getSignal() != null || target.getTrace() != null
|
||||
|| target.getLog() != null
|
||||
|| target.getService() != null) {
|
||||
throw unavailable();
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
private static AgentTargetRef topologySourceIntent(AgentTargetRef target) {
|
||||
return AgentTargetRef.builder().topology(target.getTopology()).build();
|
||||
}
|
||||
|
||||
private AgentTargetRef normalizedTopologySourceIntent(AgentTargetRef target) {
|
||||
if (topologyAuthorityService == null) {
|
||||
throw mismatch();
|
||||
}
|
||||
try {
|
||||
return AgentTargetRef.builder()
|
||||
.topology(topologyAuthorityService.normalizeSource(target.getTopology()))
|
||||
.build();
|
||||
} catch (RuntimeException ignored) {
|
||||
throw mismatch();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isAlertIntent(AgentTargetRef target) {
|
||||
if (target == null) {
|
||||
return false;
|
||||
}
|
||||
AgentTargetAuthority authority = target.getAuthority();
|
||||
return target.getAlertId() != null || target.getAlertType() != null
|
||||
|| hasSingleAlertVersion(target.getVersion(), AgentSingleAlertTargetAuthorityService.TARGET_VERSION_PREFIX)
|
||||
|| authority != null && hasSingleAlertVersion(
|
||||
authority.getVersion(), AgentSingleAlertTargetAuthorityService.AUTHORITY_VERSION_PREFIX);
|
||||
}
|
||||
|
||||
private boolean isEntityIntent(AgentTargetRef target) {
|
||||
if (target == null) {
|
||||
return false;
|
||||
}
|
||||
AgentTargetAuthority authority = target.getAuthority();
|
||||
return target.getTopology() == null && target.getTrace() == null && target.getLog() == null
|
||||
&& target.getEntityId() != null && (target.getMonitorId() == null || target.getSignal() == null)
|
||||
|| hasEntityVersion(target.getVersion(), AgentEntityTargetAuthorityService.TARGET_VERSION_PREFIX)
|
||||
|| authority != null && hasEntityVersion(
|
||||
authority.getVersion(), AgentEntityTargetAuthorityService.AUTHORITY_VERSION_PREFIX);
|
||||
}
|
||||
|
||||
private boolean isTopologyIntent(AgentTargetRef target) {
|
||||
if (target == null) {
|
||||
return false;
|
||||
}
|
||||
AgentTargetAuthority authority = target.getAuthority();
|
||||
return target.getTopology() != null
|
||||
|| hasTopologyVersion(target.getVersion(), AgentTopologyTargetAuthorityService.TARGET_VERSION_PREFIX)
|
||||
|| authority != null && hasTopologyVersion(
|
||||
authority.getVersion(), AgentTopologyTargetAuthorityService.AUTHORITY_VERSION_PREFIX);
|
||||
}
|
||||
|
||||
private boolean hasCanonicalMarker(AgentTargetRef target) {
|
||||
return target.getVersion() != null || target.getAuthority() != null;
|
||||
}
|
||||
|
||||
private boolean hasSingleAlertVersion(String version, String prefix) {
|
||||
return StringUtils.hasText(version) && version.startsWith(prefix);
|
||||
}
|
||||
|
||||
private boolean hasEntityVersion(String version, String prefix) {
|
||||
return StringUtils.hasText(version) && version.startsWith(prefix);
|
||||
}
|
||||
|
||||
private boolean hasTopologyVersion(String version, String prefix) {
|
||||
return StringUtils.hasText(version) && version.startsWith(prefix);
|
||||
}
|
||||
|
||||
private InvokeCommand withTarget(InvokeCommand command, AgentTargetRef target) {
|
||||
UserInput userInput = command.userInput().toBuilder().target(target).build();
|
||||
return new InvokeCommand(command.envelope(), command.replyMode(), command.commandId(), userInput,
|
||||
command.entryType());
|
||||
}
|
||||
|
||||
private void requireSafeCanonicalTarget(AgentTargetRef target) {
|
||||
String json = JsonUtil.toJson(target);
|
||||
if (!StringUtils.hasText(json) || !Objects.equals(json, GatewayText.redactSecrets(json))) {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
private TargetCanonicalizationException mismatch() {
|
||||
return new TargetCanonicalizationException(FailureKind.MISMATCH);
|
||||
}
|
||||
|
||||
private TargetCanonicalizationException unavailable() {
|
||||
return new TargetCanonicalizationException(FailureKind.UNAVAILABLE);
|
||||
}
|
||||
|
||||
/** Stable cause-free failure categories for channel presentation. */
|
||||
public enum FailureKind {
|
||||
MISMATCH,
|
||||
UNAVAILABLE
|
||||
}
|
||||
|
||||
/** Cause-free exception used at the channel boundary. */
|
||||
public static final class TargetCanonicalizationException extends IllegalArgumentException {
|
||||
|
||||
private final FailureKind kind;
|
||||
|
||||
public TargetCanonicalizationException(FailureKind kind) {
|
||||
super(kind == FailureKind.MISMATCH ? "Investigation target does not match"
|
||||
: "Investigation target is unavailable");
|
||||
this.kind = kind;
|
||||
}
|
||||
|
||||
public FailureKind kind() {
|
||||
return kind;
|
||||
}
|
||||
}
|
||||
}
|
||||
-214
@@ -1,214 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.application;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Duration;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentTargetAuthority;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentTargetRef;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentTopologyRef;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/** Resolves a focused Topology scope through its workspace-owned root Entity. */
|
||||
@Service
|
||||
public class AgentTopologyTargetAuthorityService {
|
||||
|
||||
public static final String TARGET_VERSION_PREFIX = "topology.";
|
||||
public static final String AUTHORITY_VERSION_PREFIX = "topology-authority.";
|
||||
public static final String TARGET_VERSION = "topology.v1";
|
||||
public static final String AUTHORITY_VERSION = "topology-authority.v1";
|
||||
|
||||
private static final long MAX_RANGE_MILLIS = Duration.ofDays(7).toMillis();
|
||||
private static final Set<String> SOURCE_KINDS = Set.of(
|
||||
"all", "alert-impact", "entity-relation", "monitor-bind", "monitor-ownership",
|
||||
"otlp-trace-call", "k8s-workload", "cmdb-manual-label", "database-middleware-connection",
|
||||
"template-dependency");
|
||||
|
||||
private final AgentEntityTargetAuthorityService entityAuthorityService;
|
||||
|
||||
public AgentTopologyTargetAuthorityService(AgentEntityTargetAuthorityService entityAuthorityService) {
|
||||
this.entityAuthorityService = entityAuthorityService;
|
||||
}
|
||||
|
||||
public AgentTargetRef canonicalize(String workspaceId, AgentTopologyRef source) {
|
||||
AgentTopologyRef topology = normalizeSource(source);
|
||||
AgentTargetRef entityTarget;
|
||||
try {
|
||||
entityTarget = entityAuthorityService.canonicalize(workspaceId, topology.getRootEntityId());
|
||||
} catch (RuntimeException ignored) {
|
||||
throw unavailable();
|
||||
}
|
||||
if (!entityAuthorityService.isCanonicalTarget(entityTarget)
|
||||
|| !Objects.equals(topology.getRootEntityId(), entityTarget.getEntityId())) {
|
||||
throw unavailable();
|
||||
}
|
||||
return AgentTargetRef.builder()
|
||||
.version(TARGET_VERSION)
|
||||
.entityId(topology.getRootEntityId())
|
||||
.topology(topology)
|
||||
.authority(AgentTargetAuthority.builder()
|
||||
.bindingId(topology.getRootEntityId())
|
||||
.version(AUTHORITY_VERSION)
|
||||
.hash(authorityHash(entityTarget.getAuthority().getHash(), topology))
|
||||
.build())
|
||||
.build();
|
||||
}
|
||||
|
||||
public boolean verify(String workspaceId, AgentTargetRef target) {
|
||||
if (!isCanonicalTarget(target)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return Objects.equals(target, canonicalize(workspaceId, target.getTopology()));
|
||||
} catch (UnavailableException ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isCanonicalTarget(AgentTargetRef target) {
|
||||
AgentTargetAuthority authority = target == null ? null : target.getAuthority();
|
||||
if (target == null || !TARGET_VERSION.equals(target.getVersion())
|
||||
|| target.getEntityId() == null || target.getEntityId() <= 0
|
||||
|| target.getTopology() == null || authority == null
|
||||
|| !Objects.equals(target.getEntityId(), target.getTopology().getRootEntityId())
|
||||
|| !Objects.equals(target.getEntityId(), authority.getBindingId())
|
||||
|| !AUTHORITY_VERSION.equals(authority.getVersion())
|
||||
|| authority.getHash() == null || !authority.getHash().matches("sha256:[0-9a-f]{64}")
|
||||
|| target.getMonitorId() != null || target.getAlertId() != null || target.getAlertType() != null
|
||||
|| target.getCollector() != null || target.getSignal() != null || target.getTrace() != null
|
||||
|| target.getLog() != null
|
||||
|| target.getService() != null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return Objects.equals(target.getTopology(), normalizeSource(target.getTopology()));
|
||||
} catch (UnavailableException ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public AgentTopologyRef normalizeSource(AgentTopologyRef source) {
|
||||
if (source == null || source.getRootEntityId() == null || source.getRootEntityId() <= 0
|
||||
|| source.getDepth() == null || source.getDepth() < 1 || source.getDepth() > 2
|
||||
|| source.getNodeId() != null && source.getEdgeId() != null) {
|
||||
throw unavailable();
|
||||
}
|
||||
String nodeId = text(source.getNodeId(), 512);
|
||||
String edgeId = text(source.getEdgeId(), 512);
|
||||
String environment = text(source.getEnvironment(), 128);
|
||||
String relationType = text(source.getRelationType(), 128);
|
||||
String sourceKind = source.getSourceKind() == null
|
||||
? "entity-relation" : text(source.getSourceKind(), 64);
|
||||
if (sourceKind == null || !SOURCE_KINDS.contains(sourceKind.toLowerCase(Locale.ROOT))) {
|
||||
throw unavailable();
|
||||
}
|
||||
sourceKind = sourceKind.toLowerCase(Locale.ROOT);
|
||||
validateRange(source.getStart(), source.getEnd());
|
||||
int pageIndex = source.getPageIndex() == null ? 0 : source.getPageIndex();
|
||||
int pageSize = source.getPageSize() == null ? 50 : source.getPageSize();
|
||||
if (pageIndex < 0 || pageIndex > 10_000 || pageSize < 1 || pageSize > 100) {
|
||||
throw unavailable();
|
||||
}
|
||||
return AgentTopologyRef.builder()
|
||||
.rootEntityId(source.getRootEntityId())
|
||||
.nodeId(nodeId)
|
||||
.edgeId(edgeId)
|
||||
.depth(source.getDepth())
|
||||
.environment(environment)
|
||||
.sourceKind(sourceKind)
|
||||
.start(source.getStart())
|
||||
.end(source.getEnd())
|
||||
.relationType(relationType)
|
||||
.hideInternal(Boolean.TRUE.equals(source.getHideInternal()))
|
||||
.pageIndex(pageIndex)
|
||||
.pageSize(pageSize)
|
||||
.build();
|
||||
}
|
||||
|
||||
private void validateRange(Long start, Long end) {
|
||||
if (start == null && end == null) {
|
||||
return;
|
||||
}
|
||||
if (start == null || end == null || start <= 0 || end <= start || end - start > MAX_RANGE_MILLIS) {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
private String text(String value, int maximumLength) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String normalized = value.trim();
|
||||
if (!StringUtils.hasText(normalized) || normalized.length() > maximumLength
|
||||
|| normalized.codePoints().anyMatch(code -> code < 32 || code == 127)) {
|
||||
throw unavailable();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String authorityHash(String entityAuthorityHash, AgentTopologyRef topology) {
|
||||
StringBuilder material = new StringBuilder("topology-authority.v1;");
|
||||
append(material, "entityAuthority", entityAuthorityHash);
|
||||
append(material, "rootEntityId", topology.getRootEntityId());
|
||||
append(material, "nodeId", topology.getNodeId());
|
||||
append(material, "edgeId", topology.getEdgeId());
|
||||
append(material, "depth", topology.getDepth());
|
||||
append(material, "environment", topology.getEnvironment());
|
||||
append(material, "sourceKind", topology.getSourceKind());
|
||||
append(material, "start", topology.getStart());
|
||||
append(material, "end", topology.getEnd());
|
||||
append(material, "relationType", topology.getRelationType());
|
||||
append(material, "hideInternal", topology.getHideInternal());
|
||||
append(material, "pageIndex", topology.getPageIndex());
|
||||
append(material, "pageSize", topology.getPageSize());
|
||||
try {
|
||||
return "sha256:" + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(material.toString().getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 is required for topology target authority", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void append(StringBuilder material, String field, Object value) {
|
||||
String text = value == null ? null : String.valueOf(value);
|
||||
material.append(field).append(':').append(text == null ? -1 : text.length()).append(':');
|
||||
if (text != null) {
|
||||
material.append(text);
|
||||
}
|
||||
material.append(';');
|
||||
}
|
||||
|
||||
private UnavailableException unavailable() {
|
||||
return new UnavailableException();
|
||||
}
|
||||
|
||||
/** Cause-free failure used at the channel boundary. */
|
||||
public static final class UnavailableException extends IllegalArgumentException {
|
||||
|
||||
UnavailableException() {
|
||||
super("Topology target is unavailable");
|
||||
}
|
||||
}
|
||||
}
|
||||
-213
@@ -1,213 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.application;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.TreeMap;
|
||||
import java.util.regex.Pattern;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentTargetAuthority;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentTargetRef;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentTraceRef;
|
||||
import org.apache.hertzbeat.ai.gateway.text.GatewayText;
|
||||
import org.apache.hertzbeat.common.observability.dto.trace.TraceDetailDto;
|
||||
import org.apache.hertzbeat.common.observability.dto.trace.TraceSpanNodeDto;
|
||||
import org.apache.hertzbeat.common.util.JsonUtil;
|
||||
import org.apache.hertzbeat.observability.traces.service.EntityTraceQueryService;
|
||||
import org.apache.hertzbeat.observability.traces.service.EntityTraceQueryService.TraceDetailQuery;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/** Resolves one exact Trace Explore detail scope through the trusted-workspace query boundary. */
|
||||
@Service
|
||||
public class AgentTraceTargetAuthorityService {
|
||||
|
||||
public static final String TARGET_VERSION_PREFIX = "trace-detail.";
|
||||
public static final String AUTHORITY_VERSION_PREFIX = "trace-detail-authority.";
|
||||
public static final String TARGET_VERSION = "trace-detail.v1";
|
||||
public static final String AUTHORITY_VERSION = "trace-detail-authority.v1";
|
||||
|
||||
private static final long MAX_RANGE_MILLIS = Duration.ofDays(7).toMillis();
|
||||
private static final Pattern SAFE_ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._:-]{0,127}");
|
||||
|
||||
private final EntityTraceQueryService traceQueryService;
|
||||
|
||||
public AgentTraceTargetAuthorityService(EntityTraceQueryService traceQueryService) {
|
||||
this.traceQueryService = traceQueryService;
|
||||
}
|
||||
|
||||
public AgentTargetRef canonicalize(String workspaceId, AgentTraceRef source) {
|
||||
AgentTraceRef trace = normalizeSource(source);
|
||||
TraceDetailDto detail;
|
||||
try {
|
||||
detail = traceQueryService.getTraceDetail(workspaceId, detailQuery(trace));
|
||||
} catch (RuntimeException ignored) {
|
||||
throw unavailable();
|
||||
}
|
||||
if (detail == null || !Objects.equals(trace.getTraceId(), detail.getTraceId())) {
|
||||
throw unavailable();
|
||||
}
|
||||
return AgentTargetRef.builder()
|
||||
.version(TARGET_VERSION)
|
||||
.trace(trace)
|
||||
.authority(AgentTargetAuthority.builder()
|
||||
.version(AUTHORITY_VERSION)
|
||||
.hash("sha256:" + GatewayText.sha256(authorityMaterial(workspaceId, trace, detail)))
|
||||
.build())
|
||||
.build();
|
||||
}
|
||||
|
||||
public boolean verify(String workspaceId, AgentTargetRef target) {
|
||||
if (!isCanonicalTarget(target)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return Objects.equals(target, canonicalize(workspaceId, target.getTrace()));
|
||||
} catch (UnavailableException ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isCanonicalTarget(AgentTargetRef target) {
|
||||
AgentTargetAuthority authority = target == null ? null : target.getAuthority();
|
||||
if (target == null || !TARGET_VERSION.equals(target.getVersion()) || target.getTrace() == null
|
||||
|| authority == null || authority.getBindingId() != null
|
||||
|| !AUTHORITY_VERSION.equals(authority.getVersion())
|
||||
|| authority.getHash() == null || !authority.getHash().matches("sha256:[0-9a-f]{64}")
|
||||
|| target.getMonitorId() != null || target.getAlertId() != null || target.getAlertType() != null
|
||||
|| target.getEntityId() != null || target.getCollector() != null || target.getSignal() != null
|
||||
|| target.getTopology() != null || target.getLog() != null || target.getService() != null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return Objects.equals(target.getTrace(), normalizeSource(target.getTrace()));
|
||||
} catch (UnavailableException ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public AgentTraceRef normalizeSource(AgentTraceRef source) {
|
||||
if (source == null || !safeId(source.getTraceId()) || source.getSpanId() != null && !safeId(source.getSpanId())
|
||||
|| source.getStart() == null || source.getEnd() == null || source.getStart() <= 0
|
||||
|| source.getEnd() <= source.getStart() || source.getEnd() - source.getStart() > MAX_RANGE_MILLIS
|
||||
|| source.getMinDurationMs() != null && source.getMinDurationMs() < 0
|
||||
|| source.getMaxDurationMs() != null && source.getMaxDurationMs() < 0
|
||||
|| source.getMinDurationMs() != null && source.getMaxDurationMs() != null
|
||||
&& source.getMinDurationMs() > source.getMaxDurationMs()) {
|
||||
throw unavailable();
|
||||
}
|
||||
return AgentTraceRef.builder()
|
||||
.traceId(source.getTraceId())
|
||||
.spanId(source.getSpanId())
|
||||
.start(source.getStart())
|
||||
.end(source.getEnd())
|
||||
.serviceName(text(source.getServiceName(), 512))
|
||||
.serviceNamespace(text(source.getServiceNamespace(), 512))
|
||||
.environment(text(source.getEnvironment(), 512))
|
||||
.resourceFilter(text(source.getResourceFilter(), 2048))
|
||||
.attributeFilter(text(source.getAttributeFilter(), 2048))
|
||||
.minDurationMs(source.getMinDurationMs())
|
||||
.maxDurationMs(source.getMaxDurationMs())
|
||||
.build();
|
||||
}
|
||||
|
||||
private TraceDetailQuery detailQuery(AgentTraceRef trace) {
|
||||
return new TraceDetailQuery(null, trace.getTraceId(), trace.getSpanId(), trace.getStart(), trace.getEnd(),
|
||||
trace.getServiceName(), trace.getServiceNamespace(), trace.getEnvironment(), trace.getResourceFilter(),
|
||||
trace.getAttributeFilter(), trace.getMinDurationMs(), trace.getMaxDurationMs());
|
||||
}
|
||||
|
||||
private String authorityMaterial(String workspaceId, AgentTraceRef trace, TraceDetailDto detail) {
|
||||
StringBuilder material = new StringBuilder("trace-detail-authority.v1;");
|
||||
append(material, "workspaceId", workspaceId);
|
||||
append(material, "scope", JsonUtil.toJson(trace));
|
||||
append(material, "traceId", detail.getTraceId());
|
||||
append(material, "rootSpanId", detail.getRootSpanId());
|
||||
append(material, "serviceName", detail.getServiceName());
|
||||
append(material, "serviceNamespace", detail.getServiceNamespace());
|
||||
append(material, "rootSpanName", detail.getRootSpanName());
|
||||
append(material, "durationNanos", detail.getDurationNanos());
|
||||
append(material, "status", detail.getStatus());
|
||||
append(material, "startTime", detail.getStartTime());
|
||||
append(material, "errorSpanCount", detail.getErrorSpanCount());
|
||||
append(material, "resourceAttributes", sorted(detail.getResourceAttributes()));
|
||||
if (detail.getSpans() != null) {
|
||||
for (TraceSpanNodeDto span : detail.getSpans()) {
|
||||
appendSpan(material, span);
|
||||
}
|
||||
}
|
||||
return material.toString();
|
||||
}
|
||||
|
||||
private void appendSpan(StringBuilder material, TraceSpanNodeDto span) {
|
||||
append(material, "span.traceId", span == null ? null : span.getTraceId());
|
||||
append(material, "span.spanId", span == null ? null : span.getSpanId());
|
||||
append(material, "span.parentSpanId", span == null ? null : span.getParentSpanId());
|
||||
append(material, "span.name", span == null ? null : span.getSpanName());
|
||||
append(material, "span.service", span == null ? null : span.getServiceName());
|
||||
append(material, "span.status", span == null ? null : span.getStatus());
|
||||
append(material, "span.kind", span == null ? null : span.getSpanKind());
|
||||
append(material, "span.duration", span == null ? null : span.getDurationNanos());
|
||||
append(material, "span.start", span == null ? null : span.getStartTime());
|
||||
append(material, "span.resource", span == null ? null : sorted(span.getResourceAttributes()));
|
||||
append(material, "span.attributes", span == null ? null : sorted(span.getSpanAttributes()));
|
||||
}
|
||||
|
||||
private Map<String, String> sorted(Map<String, String> values) {
|
||||
return values == null ? Map.of() : new TreeMap<>(values);
|
||||
}
|
||||
|
||||
private String text(String value, int maximumLength) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String normalized = value.trim();
|
||||
if (!StringUtils.hasText(normalized) || normalized.length() > maximumLength
|
||||
|| normalized.codePoints().anyMatch(code -> code < 32 || code == 127)
|
||||
|| !Objects.equals(normalized, GatewayText.redactSecrets(normalized))) {
|
||||
throw unavailable();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private boolean safeId(String value) {
|
||||
return value != null && SAFE_ID.matcher(value).matches();
|
||||
}
|
||||
|
||||
private void append(StringBuilder material, String field, Object value) {
|
||||
String text = value == null ? null : String.valueOf(value);
|
||||
material.append(field).append(':').append(text == null ? -1 : text.length()).append(':');
|
||||
if (text != null) {
|
||||
material.append(text);
|
||||
}
|
||||
material.append(';');
|
||||
}
|
||||
|
||||
private UnavailableException unavailable() {
|
||||
return new UnavailableException();
|
||||
}
|
||||
|
||||
/** Cause-free failure used at the channel boundary. */
|
||||
public static final class UnavailableException extends IllegalArgumentException {
|
||||
|
||||
UnavailableException() {
|
||||
super("Trace target is unavailable");
|
||||
}
|
||||
}
|
||||
}
|
||||
-120
@@ -1,120 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.application;
|
||||
|
||||
import java.util.Objects;
|
||||
import org.apache.hertzbeat.ai.gateway.application.AgentTargetCanonicalizationService.FailureKind;
|
||||
import org.apache.hertzbeat.ai.gateway.application.AgentTargetCanonicalizationService.TargetCanonicalizationException;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentTargetAuthority;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentTargetRef;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.UserInput;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/** Isolates strict source/canonical adaptation for an exact Trace Explore detail target. */
|
||||
@Service
|
||||
public class AgentTraceTargetCanonicalizationAdapter {
|
||||
|
||||
private final AgentTraceTargetAuthorityService authorityService;
|
||||
|
||||
public AgentTraceTargetCanonicalizationAdapter(AgentTraceTargetAuthorityService authorityService) {
|
||||
this.authorityService = authorityService;
|
||||
}
|
||||
|
||||
public boolean isIntent(AgentTargetRef target) {
|
||||
if (target == null) {
|
||||
return false;
|
||||
}
|
||||
AgentTargetAuthority authority = target.getAuthority();
|
||||
return target.getTrace() != null
|
||||
|| hasVersion(target.getVersion(), AgentTraceTargetAuthorityService.TARGET_VERSION_PREFIX)
|
||||
|| authority != null && hasVersion(
|
||||
authority.getVersion(), AgentTraceTargetAuthorityService.AUTHORITY_VERSION_PREFIX);
|
||||
}
|
||||
|
||||
public GatewayCommand.InvokeCommand canonicalize(GatewayCommand.InvokeCommand command) {
|
||||
AgentTargetRef source = requireSourceIntent(command.userInput().getTarget());
|
||||
try {
|
||||
AgentTargetRef target = authorityService.canonicalize(
|
||||
command.envelope().getWorkspaceId(), source.getTrace());
|
||||
if (!authorityService.isCanonicalTarget(target)
|
||||
|| !Objects.equals(authorityService.normalizeSource(source.getTrace()), target.getTrace())) {
|
||||
throw unavailable();
|
||||
}
|
||||
return withTarget(command, target);
|
||||
} catch (AgentTraceTargetAuthorityService.UnavailableException failure) {
|
||||
throw unavailable();
|
||||
} catch (TargetCanonicalizationException failure) {
|
||||
throw failure;
|
||||
} catch (RuntimeException ignored) {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
public GatewayCommand.InvokeCommand replayCommand(
|
||||
GatewayCommand.InvokeCommand command, AgentTargetRef persistedTarget) {
|
||||
AgentTargetRef source = requireSourceIntent(command.userInput().getTarget());
|
||||
try {
|
||||
if (!authorityService.isCanonicalTarget(persistedTarget)
|
||||
|| !Objects.equals(authorityService.normalizeSource(source.getTrace()), persistedTarget.getTrace())) {
|
||||
throw mismatch();
|
||||
}
|
||||
return withTarget(command, persistedTarget);
|
||||
} catch (TargetCanonicalizationException failure) {
|
||||
throw failure;
|
||||
} catch (RuntimeException ignored) {
|
||||
throw mismatch();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isCanonicalTarget(AgentTargetRef target) {
|
||||
return authorityService.isCanonicalTarget(target);
|
||||
}
|
||||
|
||||
public static AgentTargetRef sourceIntent(AgentTargetRef target) {
|
||||
return AgentTargetRef.builder().trace(target.getTrace()).build();
|
||||
}
|
||||
|
||||
private AgentTargetRef requireSourceIntent(AgentTargetRef target) {
|
||||
if (target == null || target.getTrace() == null || target.getVersion() != null || target.getAuthority() != null
|
||||
|| target.getMonitorId() != null || target.getAlertId() != null || target.getAlertType() != null
|
||||
|| target.getEntityId() != null || target.getCollector() != null || target.getSignal() != null
|
||||
|| target.getTopology() != null || target.getLog() != null || target.getService() != null) {
|
||||
throw unavailable();
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
private GatewayCommand.InvokeCommand withTarget(GatewayCommand.InvokeCommand command, AgentTargetRef target) {
|
||||
UserInput userInput = command.userInput().toBuilder().target(target).build();
|
||||
return new GatewayCommand.InvokeCommand(command.envelope(), command.replyMode(), command.commandId(), userInput,
|
||||
command.entryType());
|
||||
}
|
||||
|
||||
private boolean hasVersion(String version, String prefix) {
|
||||
return StringUtils.hasText(version) && version.startsWith(prefix);
|
||||
}
|
||||
|
||||
private TargetCanonicalizationException mismatch() {
|
||||
return new TargetCanonicalizationException(FailureKind.MISMATCH);
|
||||
}
|
||||
|
||||
private TargetCanonicalizationException unavailable() {
|
||||
return new TargetCanonicalizationException(FailureKind.UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
-116
@@ -1,116 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.application;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.ApprovalDecisionCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayEvent.ErrorPayload;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayEvent.GatewayEventType;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayResponse.Meta;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayResponse.GatewaySingleResponse;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.AgentRuntimeApprovalRegistry;
|
||||
import org.apache.hertzbeat.ai.gateway.tool.core.AgentToolCallLedgerService;
|
||||
import org.apache.hertzbeat.common.entity.agent.AgentToolCall;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Approval decision commands for active runtime loops.
|
||||
*/
|
||||
@Service
|
||||
public class ApprovalCommandService {
|
||||
|
||||
private static final String STATUS_COMPLETED = "completed";
|
||||
private static final String STATUS_FAILED = "failed";
|
||||
private static final Duration DEFAULT_CONSUMPTION_TIMEOUT = Duration.ofSeconds(5);
|
||||
|
||||
private final AgentToolCallLedgerService toolCallLedgerService;
|
||||
private final AgentRuntimeApprovalRegistry approvalRegistry;
|
||||
private final Duration consumptionTimeout;
|
||||
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
public ApprovalCommandService(AgentToolCallLedgerService toolCallLedgerService,
|
||||
AgentRuntimeApprovalRegistry approvalRegistry) {
|
||||
this(toolCallLedgerService, approvalRegistry, DEFAULT_CONSUMPTION_TIMEOUT);
|
||||
}
|
||||
|
||||
ApprovalCommandService(AgentToolCallLedgerService toolCallLedgerService,
|
||||
AgentRuntimeApprovalRegistry approvalRegistry,
|
||||
Duration consumptionTimeout) {
|
||||
this.toolCallLedgerService = toolCallLedgerService;
|
||||
this.approvalRegistry = approvalRegistry;
|
||||
this.consumptionTimeout = consumptionTimeout;
|
||||
}
|
||||
|
||||
public GatewaySingleResponse decide(ApprovalDecisionCommand command) {
|
||||
toolCallLedgerService.requireApprovalOwner(command.approvalId(), command.envelope(),
|
||||
command.originEntryType());
|
||||
var reservation = approvalRegistry.reserve(command.approvalId());
|
||||
if (reservation.isEmpty()) {
|
||||
return response(command, null, List.of(errorEvent(command, null,
|
||||
"Agent approval runtime loop is no longer active.")));
|
||||
}
|
||||
AgentToolCall approval;
|
||||
try {
|
||||
approval = toolCallLedgerService.decideApproval(command.approvalId(), command.envelope(),
|
||||
command.originEntryType(), command.decision());
|
||||
} catch (RuntimeException | Error failure) {
|
||||
reservation.orElseThrow().release();
|
||||
throw failure;
|
||||
}
|
||||
var delivery = reservation.orElseThrow().deliver(command.decision());
|
||||
if (!delivery.accepted() || !delivery.awaitConsumption(consumptionTimeout)) {
|
||||
AgentToolCall terminal = toolCallLedgerService.terminalizeUnconsumedApproval(
|
||||
command.approvalId(), command.envelope(), command.originEntryType(), command.decision());
|
||||
return response(command, terminal, List.of(errorEvent(command, terminal,
|
||||
"Agent approval runtime loop is no longer active.")));
|
||||
}
|
||||
return response(command, approval, List.of());
|
||||
}
|
||||
|
||||
private GatewaySingleResponse response(ApprovalDecisionCommand command, AgentToolCall approval,
|
||||
List<GatewayEvent> events) {
|
||||
boolean success = events.isEmpty();
|
||||
Meta meta = Meta.builder()
|
||||
.commandId(command.commandId())
|
||||
.sessionUid(approval == null ? null : approval.getSessionUid())
|
||||
.runUid(approval == null ? null : approval.getRunUid())
|
||||
.terminal(true)
|
||||
.message(success ? "approval accepted" : "approval failed")
|
||||
.build();
|
||||
return GatewaySingleResponse.builder()
|
||||
.meta(meta)
|
||||
.body(Map.of("status", success ? STATUS_COMPLETED : STATUS_FAILED))
|
||||
.events(events)
|
||||
.build();
|
||||
}
|
||||
|
||||
private GatewayEvent errorEvent(ApprovalDecisionCommand command, AgentToolCall approval, String message) {
|
||||
return GatewayEvent.builder()
|
||||
.type(GatewayEventType.ERROR)
|
||||
.eventId(command.commandId() + ":error")
|
||||
.sessionUid(approval == null ? null : approval.getSessionUid())
|
||||
.runUid(approval == null ? null : approval.getRunUid())
|
||||
.payload(ErrorPayload.builder()
|
||||
.errorMessage(message)
|
||||
.build())
|
||||
.timestamp(System.currentTimeMillis())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
-419
@@ -1,419 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.application;
|
||||
|
||||
import java.util.Objects;
|
||||
import lombok.Builder;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.GatewayEnvelope;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.UserInput;
|
||||
import org.apache.hertzbeat.ai.gateway.identity.ActorSupport;
|
||||
import org.apache.hertzbeat.ai.gateway.tool.core.AgentApprovalDecision;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.AgentRuntimeEntryType;
|
||||
import org.apache.hertzbeat.common.entity.dto.ModelProviderConfig;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* Strongly typed Gateway command.
|
||||
*/
|
||||
public sealed interface GatewayCommand permits
|
||||
GatewayCommand.InvokeCommand,
|
||||
GatewayCommand.ApprovalDecisionCommand,
|
||||
GatewayCommand.CancelRunCommand,
|
||||
GatewayCommand.GetSessionCommand,
|
||||
GatewayCommand.GetRunCommand,
|
||||
GatewayCommand.GetLatestSessionRunCommand,
|
||||
GatewayCommand.ListSessionsCommand,
|
||||
GatewayCommand.GetSessionTranscriptCommand,
|
||||
GatewayCommand.ListModelProviderOptionsCommand,
|
||||
GatewayCommand.ListModelProviderConfigurationsCommand,
|
||||
GatewayCommand.CreateModelProviderConfigurationCommand,
|
||||
GatewayCommand.UpdateModelProviderConfigurationCommand,
|
||||
GatewayCommand.DeleteModelProviderConfigurationCommand,
|
||||
GatewayCommand.SwitchModelProviderCommand {
|
||||
|
||||
GatewayEnvelope envelope();
|
||||
|
||||
ReplyMode replyMode();
|
||||
|
||||
String commandId();
|
||||
|
||||
/**
|
||||
* Normalized runtime invocation shared by interactive and system-triggered entries.
|
||||
*/
|
||||
@Builder
|
||||
record InvokeCommand(
|
||||
GatewayEnvelope envelope,
|
||||
ReplyMode replyMode,
|
||||
String commandId,
|
||||
UserInput userInput,
|
||||
AgentRuntimeEntryType entryType) implements GatewayCommand {
|
||||
|
||||
public InvokeCommand {
|
||||
envelope = Objects.requireNonNull(envelope, "envelope is required");
|
||||
replyMode = Objects.requireNonNull(replyMode, "replyMode is required");
|
||||
if (!StringUtils.hasText(commandId)) {
|
||||
throw new IllegalArgumentException("commandId is required");
|
||||
}
|
||||
userInput = Objects.requireNonNull(userInput, "userInput is required");
|
||||
entryType = Objects.requireNonNull(entryType, "entryType is required");
|
||||
}
|
||||
|
||||
public InvokeCommand(GatewayEnvelope envelope, ReplyMode replyMode, String commandId, UserInput userInput) {
|
||||
this(envelope, replyMode, commandId, userInput, AgentRuntimeEntryType.USER_INPUT);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Approval decision command.
|
||||
*/
|
||||
@Builder
|
||||
record ApprovalDecisionCommand(
|
||||
GatewayEnvelope envelope,
|
||||
ReplyMode replyMode,
|
||||
String commandId,
|
||||
AgentRuntimeEntryType originEntryType,
|
||||
String approvalId,
|
||||
AgentApprovalDecision decision) implements GatewayCommand {
|
||||
|
||||
public ApprovalDecisionCommand {
|
||||
envelope = Objects.requireNonNull(envelope, "envelope is required");
|
||||
replyMode = Objects.requireNonNull(replyMode, "replyMode is required");
|
||||
originEntryType = Objects.requireNonNull(originEntryType, "originEntryType is required");
|
||||
decision = Objects.requireNonNull(decision, "approval decision is required");
|
||||
if (!StringUtils.hasText(commandId) || !StringUtils.hasText(approvalId)) {
|
||||
throw new IllegalArgumentException("commandId and approvalId are required");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime cancellation command.
|
||||
*/
|
||||
@Builder
|
||||
record CancelRunCommand(
|
||||
GatewayEnvelope envelope,
|
||||
ReplyMode replyMode,
|
||||
String commandId,
|
||||
AgentRuntimeEntryType originEntryType,
|
||||
String runUid,
|
||||
String reason) implements GatewayCommand {
|
||||
|
||||
public CancelRunCommand {
|
||||
envelope = Objects.requireNonNull(envelope, "envelope is required");
|
||||
replyMode = Objects.requireNonNull(replyMode, "replyMode is required");
|
||||
originEntryType = Objects.requireNonNull(originEntryType, "originEntryType is required");
|
||||
if (!StringUtils.hasText(commandId) || !StringUtils.hasText(runUid)) {
|
||||
throw new IllegalArgumentException("commandId and runUid are required");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Session query command.
|
||||
*/
|
||||
@Builder
|
||||
record GetSessionCommand(
|
||||
GatewayEnvelope envelope,
|
||||
ReplyMode replyMode,
|
||||
String commandId,
|
||||
AgentRuntimeEntryType originEntryType,
|
||||
String sessionUid) implements GatewayCommand {
|
||||
|
||||
public GetSessionCommand {
|
||||
envelope = Objects.requireNonNull(envelope, "envelope is required");
|
||||
replyMode = Objects.requireNonNull(replyMode, "replyMode is required");
|
||||
// The command must fully describe the session query scope.
|
||||
originEntryType = Objects.requireNonNull(originEntryType, "originEntryType is required");
|
||||
if (!StringUtils.hasText(commandId) || !StringUtils.hasText(sessionUid)) {
|
||||
throw new IllegalArgumentException("commandId and sessionUid are required");
|
||||
}
|
||||
if (!ActorSupport.hasIdentity(envelope.getActor())) {
|
||||
throw new IllegalArgumentException("Session query actor is required");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Owner-scoped durable run query. */
|
||||
@Builder
|
||||
record GetRunCommand(
|
||||
GatewayEnvelope envelope,
|
||||
ReplyMode replyMode,
|
||||
String commandId,
|
||||
AgentRuntimeEntryType originEntryType,
|
||||
String runUid) implements GatewayCommand {
|
||||
|
||||
public GetRunCommand {
|
||||
envelope = Objects.requireNonNull(envelope, "envelope is required");
|
||||
replyMode = Objects.requireNonNull(replyMode, "replyMode is required");
|
||||
originEntryType = Objects.requireNonNull(originEntryType, "originEntryType is required");
|
||||
if (!StringUtils.hasText(commandId) || !StringUtils.hasText(runUid)) {
|
||||
throw new IllegalArgumentException("commandId and runUid are required");
|
||||
}
|
||||
if (!ActorSupport.hasIdentity(envelope.getActor())) {
|
||||
throw new IllegalArgumentException("Run query actor is required");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Owner-scoped latest durable run for one session. */
|
||||
@Builder
|
||||
record GetLatestSessionRunCommand(
|
||||
GatewayEnvelope envelope,
|
||||
ReplyMode replyMode,
|
||||
String commandId,
|
||||
AgentRuntimeEntryType originEntryType,
|
||||
String sessionUid) implements GatewayCommand {
|
||||
|
||||
public GetLatestSessionRunCommand {
|
||||
envelope = Objects.requireNonNull(envelope, "envelope is required");
|
||||
replyMode = Objects.requireNonNull(replyMode, "replyMode is required");
|
||||
originEntryType = Objects.requireNonNull(originEntryType, "originEntryType is required");
|
||||
if (!StringUtils.hasText(commandId) || !StringUtils.hasText(sessionUid)) {
|
||||
throw new IllegalArgumentException("commandId and sessionUid are required");
|
||||
}
|
||||
if (!ActorSupport.hasIdentity(envelope.getActor())) {
|
||||
throw new IllegalArgumentException("Latest run query actor is required");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Current actor session list query.
|
||||
*/
|
||||
@Builder
|
||||
record ListSessionsCommand(
|
||||
GatewayEnvelope envelope,
|
||||
ReplyMode replyMode,
|
||||
String commandId,
|
||||
AgentRuntimeEntryType originEntryType,
|
||||
String title,
|
||||
int pageIndex,
|
||||
int pageSize) implements GatewayCommand {
|
||||
|
||||
public ListSessionsCommand {
|
||||
envelope = Objects.requireNonNull(envelope, "envelope is required");
|
||||
replyMode = Objects.requireNonNull(replyMode, "replyMode is required");
|
||||
// The command must fully describe the session query scope.
|
||||
originEntryType = Objects.requireNonNull(originEntryType, "originEntryType is required");
|
||||
if (!StringUtils.hasText(commandId)) {
|
||||
throw new IllegalArgumentException("commandId is required");
|
||||
}
|
||||
if (!ActorSupport.hasIdentity(envelope.getActor())) {
|
||||
throw new IllegalArgumentException("Session query actor is required");
|
||||
}
|
||||
if (pageIndex < 0 || pageSize < 1) {
|
||||
throw new IllegalArgumentException("Session page index and size are invalid");
|
||||
}
|
||||
pageSize = Math.min(pageSize, 200);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Current actor session transcript query.
|
||||
*/
|
||||
@Builder
|
||||
record GetSessionTranscriptCommand(
|
||||
GatewayEnvelope envelope,
|
||||
ReplyMode replyMode,
|
||||
String commandId,
|
||||
AgentRuntimeEntryType originEntryType,
|
||||
String sessionUid,
|
||||
int pageIndex,
|
||||
int pageSize) implements GatewayCommand {
|
||||
|
||||
public GetSessionTranscriptCommand {
|
||||
envelope = Objects.requireNonNull(envelope, "envelope is required");
|
||||
replyMode = Objects.requireNonNull(replyMode, "replyMode is required");
|
||||
// The command must fully describe the session query scope.
|
||||
originEntryType = Objects.requireNonNull(originEntryType, "originEntryType is required");
|
||||
if (!StringUtils.hasText(commandId) || !StringUtils.hasText(sessionUid)) {
|
||||
throw new IllegalArgumentException("commandId and sessionUid are required");
|
||||
}
|
||||
if (!ActorSupport.hasIdentity(envelope.getActor())) {
|
||||
throw new IllegalArgumentException("Session query actor is required");
|
||||
}
|
||||
if (pageIndex < 0 || pageSize < 1) {
|
||||
throw new IllegalArgumentException("Session page index and size are invalid");
|
||||
}
|
||||
pageSize = Math.min(pageSize, 200);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered model provider option query.
|
||||
*/
|
||||
@Builder
|
||||
record ListModelProviderOptionsCommand(
|
||||
GatewayEnvelope envelope,
|
||||
ReplyMode replyMode,
|
||||
String commandId) implements GatewayCommand {
|
||||
|
||||
public ListModelProviderOptionsCommand {
|
||||
// Channel commands require trusted invocation metadata and an explicit delivery mode.
|
||||
envelope = Objects.requireNonNull(envelope, "envelope is required");
|
||||
replyMode = Objects.requireNonNull(replyMode, "replyMode is required");
|
||||
if (!StringUtils.hasText(commandId)) {
|
||||
throw new IllegalArgumentException("commandId is required");
|
||||
}
|
||||
if (replyMode != ReplyMode.FINAL_ONLY) {
|
||||
throw new IllegalArgumentException("Model provider option queries require final-only replies");
|
||||
}
|
||||
if (!ActorSupport.hasIdentity(envelope.getActor())) {
|
||||
throw new IllegalArgumentException("Model provider option query actor is required");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saved model provider configuration query.
|
||||
*/
|
||||
@Builder
|
||||
record ListModelProviderConfigurationsCommand(
|
||||
GatewayEnvelope envelope,
|
||||
ReplyMode replyMode,
|
||||
String commandId) implements GatewayCommand {
|
||||
|
||||
public ListModelProviderConfigurationsCommand {
|
||||
// Channel commands require trusted invocation metadata and an explicit delivery mode.
|
||||
envelope = Objects.requireNonNull(envelope, "envelope is required");
|
||||
replyMode = Objects.requireNonNull(replyMode, "replyMode is required");
|
||||
if (!StringUtils.hasText(commandId)) {
|
||||
throw new IllegalArgumentException("commandId is required");
|
||||
}
|
||||
if (replyMode != ReplyMode.FINAL_ONLY) {
|
||||
throw new IllegalArgumentException("Model provider configuration queries require final-only replies");
|
||||
}
|
||||
if (!ActorSupport.hasIdentity(envelope.getActor())) {
|
||||
throw new IllegalArgumentException("Model provider configuration query actor is required");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saved model provider configuration creation.
|
||||
*/
|
||||
@Builder
|
||||
record CreateModelProviderConfigurationCommand(
|
||||
GatewayEnvelope envelope,
|
||||
ReplyMode replyMode,
|
||||
String commandId,
|
||||
ModelProviderConfig config) implements GatewayCommand {
|
||||
|
||||
public CreateModelProviderConfigurationCommand {
|
||||
// Channel commands require trusted invocation metadata and an explicit delivery mode.
|
||||
envelope = Objects.requireNonNull(envelope, "envelope is required");
|
||||
replyMode = Objects.requireNonNull(replyMode, "replyMode is required");
|
||||
if (!StringUtils.hasText(commandId)) {
|
||||
throw new IllegalArgumentException("commandId is required");
|
||||
}
|
||||
if (replyMode != ReplyMode.FINAL_ONLY) {
|
||||
throw new IllegalArgumentException("Model provider configuration creation requires final-only replies");
|
||||
}
|
||||
if (!ActorSupport.hasIdentity(envelope.getActor())) {
|
||||
throw new IllegalArgumentException("Model provider configuration creation actor is required");
|
||||
}
|
||||
// A create command owns the complete provider DTO so validation and normalization happen once downstream.
|
||||
config = Objects.requireNonNull(config, "model provider config is required");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saved model provider configuration update.
|
||||
*/
|
||||
@Builder
|
||||
record UpdateModelProviderConfigurationCommand(
|
||||
GatewayEnvelope envelope,
|
||||
ReplyMode replyMode,
|
||||
String commandId,
|
||||
String providerUid,
|
||||
ModelProviderConfig config) implements GatewayCommand {
|
||||
|
||||
public UpdateModelProviderConfigurationCommand {
|
||||
envelope = Objects.requireNonNull(envelope, "envelope is required");
|
||||
replyMode = Objects.requireNonNull(replyMode, "replyMode is required");
|
||||
if (!StringUtils.hasText(commandId) || !StringUtils.hasText(providerUid)) {
|
||||
throw new IllegalArgumentException("commandId and providerUid are required");
|
||||
}
|
||||
if (replyMode != ReplyMode.FINAL_ONLY) {
|
||||
throw new IllegalArgumentException("Model provider configuration updates require final-only replies");
|
||||
}
|
||||
if (!ActorSupport.hasIdentity(envelope.getActor())) {
|
||||
throw new IllegalArgumentException("Model provider configuration update actor is required");
|
||||
}
|
||||
config = Objects.requireNonNull(config, "model provider config is required");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saved model provider configuration deletion.
|
||||
*/
|
||||
@Builder
|
||||
record DeleteModelProviderConfigurationCommand(
|
||||
GatewayEnvelope envelope,
|
||||
ReplyMode replyMode,
|
||||
String commandId,
|
||||
String providerUid) implements GatewayCommand {
|
||||
|
||||
public DeleteModelProviderConfigurationCommand {
|
||||
envelope = Objects.requireNonNull(envelope, "envelope is required");
|
||||
replyMode = Objects.requireNonNull(replyMode, "replyMode is required");
|
||||
if (!StringUtils.hasText(commandId) || !StringUtils.hasText(providerUid)) {
|
||||
throw new IllegalArgumentException("commandId and providerUid are required");
|
||||
}
|
||||
if (replyMode != ReplyMode.FINAL_ONLY) {
|
||||
throw new IllegalArgumentException("Model provider configuration deletions require final-only replies");
|
||||
}
|
||||
if (!ActorSupport.hasIdentity(envelope.getActor())) {
|
||||
throw new IllegalArgumentException("Model provider configuration deletion actor is required");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Active model provider selection. A null UID selects the YAML default.
|
||||
*/
|
||||
@Builder
|
||||
record SwitchModelProviderCommand(
|
||||
GatewayEnvelope envelope,
|
||||
ReplyMode replyMode,
|
||||
String commandId,
|
||||
String providerUid) implements GatewayCommand {
|
||||
|
||||
public SwitchModelProviderCommand {
|
||||
envelope = Objects.requireNonNull(envelope, "envelope is required");
|
||||
replyMode = Objects.requireNonNull(replyMode, "replyMode is required");
|
||||
if (!StringUtils.hasText(commandId)) {
|
||||
throw new IllegalArgumentException("commandId is required");
|
||||
}
|
||||
if (replyMode != ReplyMode.FINAL_ONLY) {
|
||||
throw new IllegalArgumentException("Model provider switches require final-only replies");
|
||||
}
|
||||
if (!ActorSupport.hasIdentity(envelope.getActor())) {
|
||||
throw new IllegalArgumentException("Model provider switch actor is required");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Response delivery mode requested by a channel.
|
||||
*/
|
||||
enum ReplyMode {
|
||||
FINAL_ONLY,
|
||||
STREAM
|
||||
}
|
||||
}
|
||||
-90
@@ -1,90 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.application;
|
||||
|
||||
import java.util.Objects;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.ApprovalDecisionCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.CancelRunCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.CreateModelProviderConfigurationCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.DeleteModelProviderConfigurationCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.GetLatestSessionRunCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.GetSessionCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.GetRunCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.GetSessionTranscriptCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.InvokeCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.ListModelProviderConfigurationsCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.ListModelProviderOptionsCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.ListSessionsCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.SwitchModelProviderCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.UpdateModelProviderConfigurationCommand;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Routes Gateway commands to domain services.
|
||||
*/
|
||||
@Service
|
||||
public class GatewayCommandRouter {
|
||||
|
||||
private final AgentCommandService agentCommandService;
|
||||
private final ApprovalCommandService approvalCommandService;
|
||||
private final RunCommandService runCommandService;
|
||||
private final GatewayQueryService queryService;
|
||||
private final ModelProviderCommandService modelProviderCommandService;
|
||||
|
||||
public GatewayCommandRouter(AgentCommandService agentCommandService,
|
||||
ApprovalCommandService approvalCommandService,
|
||||
RunCommandService runCommandService,
|
||||
GatewayQueryService queryService,
|
||||
ModelProviderCommandService modelProviderCommandService) {
|
||||
this.agentCommandService = Objects.requireNonNull(agentCommandService, "agentCommandService is required");
|
||||
this.approvalCommandService = Objects.requireNonNull(approvalCommandService,
|
||||
"approvalCommandService is required");
|
||||
this.runCommandService = Objects.requireNonNull(runCommandService, "runCommandService is required");
|
||||
this.queryService = Objects.requireNonNull(queryService, "queryService is required");
|
||||
// Provider commands cannot be exposed unless their application service is wired.
|
||||
this.modelProviderCommandService = Objects.requireNonNull(
|
||||
modelProviderCommandService, "modelProviderCommandService is required");
|
||||
}
|
||||
|
||||
public GatewayResponse handle(GatewayCommand command) {
|
||||
Objects.requireNonNull(command, "command is required");
|
||||
return switch (command) {
|
||||
case InvokeCommand invokeCommand -> agentCommandService.handle(invokeCommand);
|
||||
case ApprovalDecisionCommand approvalCommand -> approvalCommandService.decide(approvalCommand);
|
||||
case CancelRunCommand cancelCommand -> runCommandService.cancel(cancelCommand);
|
||||
case GetSessionCommand getSessionCommand -> queryService.getSession(getSessionCommand);
|
||||
case GetRunCommand getRunCommand -> queryService.getRun(getRunCommand);
|
||||
case GetLatestSessionRunCommand latestRunCommand -> queryService.getLatestSessionRun(latestRunCommand);
|
||||
case ListSessionsCommand listSessionsCommand -> queryService.listSessions(listSessionsCommand);
|
||||
case GetSessionTranscriptCommand transcriptCommand ->
|
||||
queryService.getSessionTranscript(transcriptCommand);
|
||||
case ListModelProviderOptionsCommand optionsCommand ->
|
||||
modelProviderCommandService.listOptions(optionsCommand);
|
||||
case ListModelProviderConfigurationsCommand configurationsCommand ->
|
||||
modelProviderCommandService.listConfigurations(configurationsCommand);
|
||||
case CreateModelProviderConfigurationCommand createCommand ->
|
||||
modelProviderCommandService.createConfiguration(createCommand);
|
||||
case UpdateModelProviderConfigurationCommand updateCommand ->
|
||||
modelProviderCommandService.updateConfiguration(updateCommand);
|
||||
case DeleteModelProviderConfigurationCommand deleteCommand ->
|
||||
modelProviderCommandService.deleteConfiguration(deleteCommand);
|
||||
case SwitchModelProviderCommand switchCommand ->
|
||||
modelProviderCommandService.switchConfiguration(switchCommand);
|
||||
};
|
||||
}
|
||||
}
|
||||
-199
@@ -1,199 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.application;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import lombok.Builder;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* Channel-neutral event emitted by the Gateway layer.
|
||||
*/
|
||||
@Builder
|
||||
public record GatewayEvent(
|
||||
GatewayEventType type,
|
||||
String eventId,
|
||||
String conversationId,
|
||||
String sessionUid,
|
||||
String runUid,
|
||||
String itemId,
|
||||
GatewayEventPayload payload,
|
||||
Long timestamp) {
|
||||
|
||||
public GatewayEvent {
|
||||
// GatewayEvent is the external event boundary; clients need stable identity and a concrete schema.
|
||||
Objects.requireNonNull(type, "type is required");
|
||||
if (!StringUtils.hasText(eventId)) {
|
||||
throw new IllegalArgumentException("eventId is required");
|
||||
}
|
||||
Objects.requireNonNull(payload, "payload is required");
|
||||
Objects.requireNonNull(timestamp, "timestamp is required");
|
||||
if (timestamp < 0) {
|
||||
throw new IllegalArgumentException("timestamp must not be negative");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Event categories exposed to channels.
|
||||
*/
|
||||
public enum GatewayEventType {
|
||||
RUN_STARTED,
|
||||
MESSAGE_STARTED,
|
||||
MESSAGE_DELTA,
|
||||
MESSAGE_COMPLETED,
|
||||
TOOL_STARTED,
|
||||
TOOL_COMPLETED,
|
||||
INPUT_REQUESTED,
|
||||
INPUT_COMPLETED,
|
||||
APPROVAL_REQUESTED,
|
||||
APPROVAL_COMPLETED,
|
||||
RUN_STATUS,
|
||||
RUN_COMPLETED,
|
||||
ERROR
|
||||
}
|
||||
|
||||
/**
|
||||
* Marker boundary for structured event payloads.
|
||||
*/
|
||||
public sealed interface GatewayEventPayload permits
|
||||
RunStartedPayload,
|
||||
MessageStartedPayload,
|
||||
MessageDeltaPayload,
|
||||
MessageCompletedPayload,
|
||||
ToolStartedPayload,
|
||||
ToolCompletedPayload,
|
||||
InputRequestedPayload,
|
||||
InputCompletedPayload,
|
||||
ApprovalRequestedPayload,
|
||||
ApprovalCompletedPayload,
|
||||
RunStatusPayload,
|
||||
RunCompletedPayload,
|
||||
ErrorPayload {
|
||||
}
|
||||
|
||||
/** Run start payload. */
|
||||
@Builder
|
||||
public record RunStartedPayload(String traceId) implements GatewayEventPayload {
|
||||
}
|
||||
|
||||
/** Assistant message start payload. */
|
||||
@Builder
|
||||
public record MessageStartedPayload(String traceId) implements GatewayEventPayload {
|
||||
}
|
||||
|
||||
/** Assistant message delta payload. */
|
||||
@Builder
|
||||
public record MessageDeltaPayload(
|
||||
String traceId,
|
||||
Integer deltaIndex,
|
||||
String delta) implements GatewayEventPayload {
|
||||
}
|
||||
|
||||
/** Assistant message completion payload. */
|
||||
@Builder
|
||||
public record MessageCompletedPayload(String traceId) implements GatewayEventPayload {
|
||||
}
|
||||
|
||||
/** Tool execution start payload. */
|
||||
@Builder
|
||||
public record ToolStartedPayload(
|
||||
String traceId,
|
||||
String toolName,
|
||||
String toolCallId,
|
||||
Map<String, Object> arguments,
|
||||
String status) implements GatewayEventPayload {
|
||||
}
|
||||
|
||||
/** Tool execution completion payload. */
|
||||
@Builder
|
||||
public record ToolCompletedPayload(
|
||||
String traceId,
|
||||
String toolName,
|
||||
String toolCallId,
|
||||
String approvalId,
|
||||
String policyDecision,
|
||||
String errorMessage,
|
||||
Long elapsedMs,
|
||||
String status) implements GatewayEventPayload {
|
||||
}
|
||||
|
||||
/** Structured user input requested by a tool. */
|
||||
@Builder
|
||||
public record InputRequestedPayload(
|
||||
String traceId,
|
||||
String interactionId,
|
||||
String targetTool,
|
||||
String title,
|
||||
String description,
|
||||
List<Map<String, Object>> fields,
|
||||
String status) implements GatewayEventPayload {
|
||||
}
|
||||
|
||||
/** Completion of a structured user input request. */
|
||||
@Builder
|
||||
public record InputCompletedPayload(
|
||||
String traceId,
|
||||
String interactionId,
|
||||
String status,
|
||||
String errorMessage) implements GatewayEventPayload {
|
||||
}
|
||||
|
||||
/** Approval request payload. */
|
||||
@Builder
|
||||
public record ApprovalRequestedPayload(
|
||||
String traceId,
|
||||
String toolName,
|
||||
String approvalId,
|
||||
String toolCallId,
|
||||
String status) implements GatewayEventPayload {
|
||||
}
|
||||
|
||||
/** Approval completion payload. */
|
||||
@Builder
|
||||
public record ApprovalCompletedPayload(
|
||||
String traceId,
|
||||
String toolName,
|
||||
String approvalId,
|
||||
String toolCallId,
|
||||
String status) implements GatewayEventPayload {
|
||||
}
|
||||
|
||||
/** Durable run snapshot emitted instead of starting a duplicate runtime. */
|
||||
@Builder
|
||||
public record RunStatusPayload(
|
||||
String status,
|
||||
String result,
|
||||
String errorMessage,
|
||||
boolean replayAvailable) implements GatewayEventPayload {
|
||||
}
|
||||
|
||||
/** Run completion payload. */
|
||||
@Builder
|
||||
public record RunCompletedPayload(String traceId) implements GatewayEventPayload {
|
||||
}
|
||||
|
||||
/** Gateway error payload. */
|
||||
@Builder
|
||||
public record ErrorPayload(
|
||||
String traceId,
|
||||
String errorMessage,
|
||||
String status) implements GatewayEventPayload {
|
||||
}
|
||||
}
|
||||
-184
@@ -1,184 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.application;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.GetSessionCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.GetRunCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.GetLatestSessionRunCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.GetSessionTranscriptCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.ListSessionsCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayResponse.Meta;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayResponse.GatewaySingleResponse;
|
||||
import org.apache.hertzbeat.ai.gateway.conversation.AgentSessionService;
|
||||
import org.apache.hertzbeat.ai.gateway.conversation.AgentSessionListItem;
|
||||
import org.apache.hertzbeat.ai.gateway.conversation.AgentRunListProjection;
|
||||
import org.apache.hertzbeat.ai.gateway.conversation.AgentRunService;
|
||||
import org.apache.hertzbeat.ai.gateway.conversation.AgentRunSnapshotService;
|
||||
import org.apache.hertzbeat.common.entity.agent.AgentSession;
|
||||
import org.apache.hertzbeat.common.entity.agent.AgentTranscriptEntry;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Gateway query commands. Query commands never invoke runtime.
|
||||
*/
|
||||
@Service
|
||||
public class GatewayQueryService {
|
||||
|
||||
private final AgentSessionService sessionService;
|
||||
private final AgentRunService runService;
|
||||
private final AgentRunSnapshotService snapshotService;
|
||||
|
||||
public GatewayQueryService(AgentSessionService sessionService, AgentRunService runService,
|
||||
AgentRunSnapshotService snapshotService) {
|
||||
this.sessionService = sessionService;
|
||||
this.runService = runService;
|
||||
this.snapshotService = snapshotService;
|
||||
}
|
||||
|
||||
public GatewaySingleResponse listSessions(ListSessionsCommand command) {
|
||||
Page<AgentSession> sessions = sessionService.findSessions(
|
||||
command.envelope(),
|
||||
command.originEntryType(),
|
||||
command.title(),
|
||||
PageRequest.of(command.pageIndex(), command.pageSize()));
|
||||
List<Long> sessionIds = sessions.stream().map(AgentSession::getId).toList();
|
||||
Map<Long, AgentRunListProjection> latestRuns = runService.findLatestRunProjections(sessionIds);
|
||||
List<AgentSessionListItem> projectedContent = sessions.stream()
|
||||
.map(session -> AgentSessionListItem.from(session, latestRuns.get(session.getId())))
|
||||
.sorted(Comparator.comparing(
|
||||
AgentSessionListItem::gmtUpdate,
|
||||
Comparator.nullsLast(Comparator.reverseOrder())))
|
||||
.toList();
|
||||
Page<AgentSessionListItem> projectedSessions = new PageImpl<>(
|
||||
projectedContent, sessions.getPageable(), sessions.getTotalElements());
|
||||
return GatewaySingleResponse.builder()
|
||||
.meta(Meta.builder()
|
||||
.commandId(command.commandId())
|
||||
.terminal(true)
|
||||
.message("sessions")
|
||||
.build())
|
||||
.body(projectedSessions)
|
||||
.events(List.of())
|
||||
.build();
|
||||
}
|
||||
|
||||
public GatewaySingleResponse getSession(GetSessionCommand command) {
|
||||
return sessionService.findOwnedSession(
|
||||
command.sessionUid(), command.envelope(), command.originEntryType())
|
||||
.<GatewaySingleResponse>map(session -> GatewaySingleResponse.builder()
|
||||
.meta(Meta.builder()
|
||||
.commandId(command.commandId())
|
||||
.sessionUid(session.getSessionUid())
|
||||
.terminal(true)
|
||||
.message("session")
|
||||
.build())
|
||||
.body(session)
|
||||
.events(List.of())
|
||||
.build())
|
||||
.orElseGet(() -> GatewaySingleResponse.builder()
|
||||
.meta(Meta.builder()
|
||||
.commandId(command.commandId())
|
||||
.sessionUid(command.sessionUid())
|
||||
.terminal(true)
|
||||
.message("Agent session not found")
|
||||
.build())
|
||||
.events(List.of())
|
||||
.build());
|
||||
}
|
||||
|
||||
public GatewaySingleResponse getRun(GetRunCommand command) {
|
||||
return runService.findRun(command.runUid())
|
||||
.filter(run -> command.originEntryType().name().equals(run.getEntryType()))
|
||||
.flatMap(run -> sessionService.findOwnedSession(
|
||||
String.valueOf(run.getSessionId()), command.envelope(), command.originEntryType())
|
||||
.map(session -> snapshotService.snapshot(session, run)))
|
||||
.<GatewaySingleResponse>map(snapshot -> GatewaySingleResponse.builder()
|
||||
.meta(Meta.builder()
|
||||
.commandId(command.commandId())
|
||||
.sessionUid(snapshot.sessionUid())
|
||||
.runUid(snapshot.runUid())
|
||||
.terminal(true)
|
||||
.message("run")
|
||||
.build())
|
||||
.body(snapshot)
|
||||
.events(List.of())
|
||||
.build())
|
||||
.orElseGet(() -> GatewaySingleResponse.builder()
|
||||
.meta(Meta.builder()
|
||||
.commandId(command.commandId())
|
||||
.runUid(command.runUid())
|
||||
.terminal(true)
|
||||
.message("Agent run not found")
|
||||
.build())
|
||||
.events(List.of())
|
||||
.build());
|
||||
}
|
||||
|
||||
public GatewaySingleResponse getLatestSessionRun(GetLatestSessionRunCommand command) {
|
||||
return sessionService.findOwnedSession(
|
||||
command.sessionUid(), command.envelope(), command.originEntryType())
|
||||
.flatMap(session -> runService.findLatestRun(session.getId())
|
||||
.filter(run -> command.originEntryType().name().equals(run.getEntryType()))
|
||||
.map(run -> snapshotService.snapshot(session, run)))
|
||||
.<GatewaySingleResponse>map(snapshot -> GatewaySingleResponse.builder()
|
||||
.meta(Meta.builder()
|
||||
.commandId(command.commandId())
|
||||
.sessionUid(snapshot.sessionUid())
|
||||
.runUid(snapshot.runUid())
|
||||
.terminal(true)
|
||||
.message("run")
|
||||
.build())
|
||||
.body(snapshot)
|
||||
.events(List.of())
|
||||
.build())
|
||||
.orElseGet(() -> GatewaySingleResponse.builder()
|
||||
.meta(Meta.builder()
|
||||
.commandId(command.commandId())
|
||||
.sessionUid(command.sessionUid())
|
||||
.terminal(true)
|
||||
.message("Agent run not found")
|
||||
.build())
|
||||
.events(List.of())
|
||||
.build());
|
||||
}
|
||||
|
||||
public GatewaySingleResponse getSessionTranscript(GetSessionTranscriptCommand command) {
|
||||
PageRequest pageRequest = PageRequest.of(command.pageIndex(), command.pageSize());
|
||||
AgentSession session = sessionService.findOwnedSession(
|
||||
command.sessionUid(), command.envelope(), command.originEntryType()).orElse(null);
|
||||
Page<AgentTranscriptEntry> transcript = session == null
|
||||
? Page.empty(pageRequest)
|
||||
: sessionService.findTranscriptEntries(session.getId(), pageRequest);
|
||||
return GatewaySingleResponse.builder()
|
||||
.meta(Meta.builder()
|
||||
.commandId(command.commandId())
|
||||
.sessionUid(session == null ? command.sessionUid() : session.getSessionUid())
|
||||
.terminal(true)
|
||||
.message(session == null ? "Agent session not found" : "session-transcript")
|
||||
.build())
|
||||
.body(transcript)
|
||||
.events(List.of())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
-83
@@ -1,83 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.application;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import lombok.Builder;
|
||||
import org.springframework.util.StringUtils;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
/**
|
||||
* Channel-neutral Gateway response.
|
||||
*/
|
||||
public sealed interface GatewayResponse permits
|
||||
GatewayResponse.GatewaySingleResponse,
|
||||
GatewayResponse.GatewayStreamResponse {
|
||||
|
||||
Meta meta();
|
||||
|
||||
/**
|
||||
* Final-only Gateway response.
|
||||
*/
|
||||
@Builder
|
||||
record GatewaySingleResponse(
|
||||
Meta meta,
|
||||
Object body,
|
||||
List<GatewayEvent> events) implements GatewayResponse {
|
||||
|
||||
public GatewaySingleResponse {
|
||||
Objects.requireNonNull(meta, "meta is required");
|
||||
events = events == null ? List.of() : List.copyOf(events);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming Gateway response.
|
||||
*/
|
||||
@Builder
|
||||
record GatewayStreamResponse(
|
||||
Meta meta,
|
||||
Flux<GatewayEvent> events) implements GatewayResponse {
|
||||
|
||||
public GatewayStreamResponse {
|
||||
Objects.requireNonNull(meta, "meta is required");
|
||||
Objects.requireNonNull(events, "events is required");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Common Gateway response metadata.
|
||||
*/
|
||||
@Builder
|
||||
record Meta(
|
||||
String commandId,
|
||||
String conversationId,
|
||||
String sessionUid,
|
||||
String runUid,
|
||||
boolean terminal,
|
||||
String message) {
|
||||
|
||||
public Meta {
|
||||
// Every response is correlated to one normalized command and exposes a human-readable outcome label.
|
||||
if (!StringUtils.hasText(commandId) || !StringUtils.hasText(message)) {
|
||||
throw new IllegalArgumentException("Response command id and message are required");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-282
@@ -1,282 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.application;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Stream;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayEvent.ApprovalCompletedPayload;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayEvent.ApprovalRequestedPayload;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayEvent.ErrorPayload;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayEvent.GatewayEventPayload;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayEvent.GatewayEventType;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayEvent.MessageCompletedPayload;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayEvent.InputRequestedPayload;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayEvent.InputCompletedPayload;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayEvent.MessageDeltaPayload;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayEvent.MessageStartedPayload;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayEvent.RunCompletedPayload;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayEvent.RunStartedPayload;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayEvent.ToolCompletedPayload;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayEvent.ToolStartedPayload;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.AgentRuntimeEvent;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.AgentRuntimeEvent.RequestKind;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.AgentRuntimeEvent.EventStatus;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.AgentRuntimeItemKind;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* Projects runtime facts into the external Gateway event contract.
|
||||
*/
|
||||
@Component
|
||||
public class GatewayRuntimeEventProjector {
|
||||
|
||||
|
||||
public GatewayEvent project(AgentRuntimeEvent event, String conversationId, String sessionUid, String runUid) {
|
||||
// Runtime publishers only emit complete events; projection cannot represent a missing source event.
|
||||
Objects.requireNonNull(event, "Runtime event is required");
|
||||
return switch (event.getType()) {
|
||||
case RUN_STARTED -> gatewayEvent(event, GatewayEventType.RUN_STARTED, conversationId, sessionUid, runUid,
|
||||
RunStartedPayload.builder()
|
||||
.traceId(event.getTraceId())
|
||||
.build());
|
||||
case ITEM_STARTED -> projectItemStarted(event, conversationId, sessionUid, runUid);
|
||||
case ITEM_DELTA -> projectItemDelta(event, conversationId, sessionUid, runUid);
|
||||
case ITEM_COMPLETED -> projectItemCompleted(event, conversationId, sessionUid, runUid);
|
||||
case REQUESTED_ACTION -> projectRequestedAction(event, conversationId, sessionUid, runUid);
|
||||
case REQUEST_COMPLETED -> projectRequestCompleted(event, conversationId, sessionUid, runUid);
|
||||
case RUN_COMPLETED -> gatewayEvent(event, GatewayEventType.RUN_COMPLETED, conversationId, sessionUid,
|
||||
runUid, RunCompletedPayload.builder()
|
||||
.traceId(event.getTraceId())
|
||||
.build());
|
||||
case ERROR -> gatewayEvent(event, GatewayEventType.ERROR, conversationId, sessionUid, runUid,
|
||||
errorPayload(event, event.getErrorMessage()));
|
||||
};
|
||||
}
|
||||
|
||||
private GatewayEvent projectItemStarted(AgentRuntimeEvent event, String conversationId, String sessionUid,
|
||||
String runUid) {
|
||||
if (isTool(event)) {
|
||||
return gatewayEvent(event, GatewayEventType.TOOL_STARTED, conversationId, sessionUid, runUid,
|
||||
ToolStartedPayload.builder()
|
||||
.traceId(event.getTraceId())
|
||||
.toolName(event.getToolName())
|
||||
.toolCallId(event.getToolCallId())
|
||||
.arguments(event.getToolArguments())
|
||||
.status(event.getStatus() == null ? EventStatus.IN_PROGRESS.externalName()
|
||||
: event.getStatus().externalName())
|
||||
.build());
|
||||
}
|
||||
if (isAssistantMessage(event)) {
|
||||
return gatewayEvent(event, GatewayEventType.MESSAGE_STARTED, conversationId, sessionUid, runUid,
|
||||
MessageStartedPayload.builder()
|
||||
.traceId(event.getTraceId())
|
||||
.build());
|
||||
}
|
||||
return unsupported(event, conversationId, sessionUid, runUid,
|
||||
"Runtime item start event has no supported item kind.");
|
||||
}
|
||||
|
||||
private GatewayEvent projectItemDelta(AgentRuntimeEvent event, String conversationId, String sessionUid,
|
||||
String runUid) {
|
||||
// Legacy runtime delta events without itemKind are assistant message deltas.
|
||||
if (event.getItemKind() == null || isAssistantMessage(event)) {
|
||||
return gatewayEvent(event, GatewayEventType.MESSAGE_DELTA, conversationId, sessionUid, runUid,
|
||||
MessageDeltaPayload.builder()
|
||||
.traceId(event.getTraceId())
|
||||
.deltaIndex(event.getDeltaIndex())
|
||||
.delta(event.getDelta())
|
||||
.build());
|
||||
}
|
||||
return unsupported(event, conversationId, sessionUid, runUid,
|
||||
"Runtime item delta event has no supported item kind.");
|
||||
}
|
||||
|
||||
private GatewayEvent projectItemCompleted(AgentRuntimeEvent event, String conversationId, String sessionUid,
|
||||
String runUid) {
|
||||
if (isTool(event)) {
|
||||
return gatewayEvent(event, GatewayEventType.TOOL_COMPLETED, conversationId, sessionUid, runUid,
|
||||
ToolCompletedPayload.builder()
|
||||
.traceId(event.getTraceId())
|
||||
.toolName(event.getToolName())
|
||||
.toolCallId(event.getToolCallId())
|
||||
.approvalId(event.getApprovalId())
|
||||
.policyDecision(externalName(event.getPolicyDecision()))
|
||||
.errorMessage(event.getErrorMessage())
|
||||
.elapsedMs(event.getElapsedMs())
|
||||
.status(toolCompletedStatus(event))
|
||||
.build());
|
||||
}
|
||||
if (isAssistantMessage(event)) {
|
||||
return gatewayEvent(event, GatewayEventType.MESSAGE_COMPLETED, conversationId, sessionUid, runUid,
|
||||
MessageCompletedPayload.builder()
|
||||
.traceId(event.getTraceId())
|
||||
.build());
|
||||
}
|
||||
return unsupported(event, conversationId, sessionUid, runUid,
|
||||
"Runtime item completion event has no supported item kind.");
|
||||
}
|
||||
|
||||
private GatewayEvent projectRequestedAction(AgentRuntimeEvent event, String conversationId, String sessionUid,
|
||||
String runUid) {
|
||||
if (event.getRequestKind() == RequestKind.USER_INPUT) {
|
||||
Map<String, Object> requestPayload = event.getRequestPayload() == null
|
||||
? Map.of() : event.getRequestPayload();
|
||||
return gatewayEvent(event, GatewayEventType.INPUT_REQUESTED, conversationId, sessionUid, runUid,
|
||||
InputRequestedPayload.builder()
|
||||
.traceId(event.getTraceId())
|
||||
.interactionId(event.getRequestId())
|
||||
.targetTool(stringValue(requestPayload.get("targetTool")))
|
||||
.title(stringValue(requestPayload.get("title")))
|
||||
.description(stringValue(requestPayload.get("description")))
|
||||
.fields(inputFields(requestPayload.get("fields")))
|
||||
.status(event.getStatus().externalName())
|
||||
.build());
|
||||
}
|
||||
if (event.getRequestKind() != RequestKind.APPROVAL) {
|
||||
return unsupported(event, conversationId, sessionUid, runUid,
|
||||
"Runtime requested action event has no supported request kind.");
|
||||
}
|
||||
return gatewayEvent(event, GatewayEventType.APPROVAL_REQUESTED, conversationId, sessionUid, runUid,
|
||||
ApprovalRequestedPayload.builder()
|
||||
.traceId(event.getTraceId())
|
||||
.toolName(event.getToolName())
|
||||
.approvalId(event.getApprovalId())
|
||||
.toolCallId(event.getToolCallId())
|
||||
.status(event.getStatus() == null ? EventStatus.WAITING_APPROVAL.externalName()
|
||||
: event.getStatus().externalName())
|
||||
.build());
|
||||
}
|
||||
|
||||
private GatewayEvent projectRequestCompleted(AgentRuntimeEvent event, String conversationId, String sessionUid,
|
||||
String runUid) {
|
||||
if (event.getRequestKind() == RequestKind.USER_INPUT) {
|
||||
return gatewayEvent(event, GatewayEventType.INPUT_COMPLETED, conversationId, sessionUid, runUid,
|
||||
InputCompletedPayload.builder()
|
||||
.traceId(event.getTraceId())
|
||||
.interactionId(event.getRequestId())
|
||||
.status(event.getStatus().externalName())
|
||||
.errorMessage(event.getErrorMessage())
|
||||
.build());
|
||||
}
|
||||
if (event.getRequestKind() != RequestKind.APPROVAL) {
|
||||
return unsupported(event, conversationId, sessionUid, runUid,
|
||||
"Runtime completed request event has no supported request kind.");
|
||||
}
|
||||
return gatewayEvent(event, GatewayEventType.APPROVAL_COMPLETED, conversationId, sessionUid, runUid,
|
||||
ApprovalCompletedPayload.builder()
|
||||
.traceId(event.getTraceId())
|
||||
.toolName(event.getToolName())
|
||||
.approvalId(event.getApprovalId())
|
||||
.toolCallId(event.getToolCallId())
|
||||
.status(event.getStatus().externalName())
|
||||
.build());
|
||||
}
|
||||
|
||||
private GatewayEvent unsupported(AgentRuntimeEvent event, String conversationId, String sessionUid, String runUid,
|
||||
String message) {
|
||||
return gatewayEvent(event, GatewayEventType.ERROR, conversationId, sessionUid, runUid,
|
||||
errorPayload(event, message));
|
||||
}
|
||||
|
||||
private GatewayEvent gatewayEvent(AgentRuntimeEvent event, GatewayEventType type, String conversationId,
|
||||
String sessionUid, String runUid, GatewayEventPayload payload) {
|
||||
return GatewayEvent.builder()
|
||||
.type(type)
|
||||
.eventId(eventId(event, type, runUid))
|
||||
.conversationId(conversationId)
|
||||
.sessionUid(sessionUid)
|
||||
.runUid(runUid)
|
||||
.itemId(event.getItemId())
|
||||
.payload(payload)
|
||||
.timestamp(timestamp(event.getTimestamp()))
|
||||
.build();
|
||||
}
|
||||
|
||||
private ErrorPayload errorPayload(AgentRuntimeEvent event, String errorMessage) {
|
||||
return ErrorPayload.builder()
|
||||
.traceId(event.getTraceId())
|
||||
.errorMessage(errorMessage)
|
||||
.status(event.getStatus() == null ? null : event.getStatus().externalName())
|
||||
.build();
|
||||
}
|
||||
|
||||
private String eventId(AgentRuntimeEvent event, GatewayEventType type, String runUid) {
|
||||
String eventRunUid = StringUtils.hasText(runUid) ? runUid : "run";
|
||||
if (event.getEventSequence() != null) {
|
||||
return eventRunUid + ":event:" + event.getEventSequence();
|
||||
}
|
||||
String stableId = Stream.of(event.getRequestId(), event.getApprovalId(), event.getItemId(),
|
||||
event.getToolCallId(), event.getTraceId())
|
||||
.filter(StringUtils::hasText)
|
||||
.findFirst()
|
||||
.orElse("event");
|
||||
String eventType = type.name().toLowerCase(Locale.ROOT);
|
||||
return eventRunUid + ":" + eventType + ":" + stableId;
|
||||
}
|
||||
|
||||
private String toolCompletedStatus(AgentRuntimeEvent event) {
|
||||
if (event.getStatus() != null) {
|
||||
return event.getStatus().externalName();
|
||||
}
|
||||
if (StringUtils.hasText(event.getErrorMessage())) {
|
||||
return EventStatus.FAILED.externalName();
|
||||
}
|
||||
if (org.apache.hertzbeat.ai.gateway.tool.core.AgentPolicyDecision.REQUIRE_APPROVAL
|
||||
== event.getPolicyDecision()) {
|
||||
return EventStatus.WAITING_APPROVAL.externalName();
|
||||
}
|
||||
return EventStatus.COMPLETED.externalName();
|
||||
}
|
||||
|
||||
private boolean isAssistantMessage(AgentRuntimeEvent event) {
|
||||
return event.getItemKind() == AgentRuntimeItemKind.ASSISTANT_MESSAGE;
|
||||
}
|
||||
|
||||
private boolean isTool(AgentRuntimeEvent event) {
|
||||
return event.getItemKind() == AgentRuntimeItemKind.TOOL_CALL;
|
||||
}
|
||||
|
||||
private Long timestamp(Instant timestamp) {
|
||||
return timestamp == null ? System.currentTimeMillis() : timestamp.toEpochMilli();
|
||||
}
|
||||
|
||||
private String externalName(Enum<?> value) {
|
||||
return value == null ? null : value.name();
|
||||
}
|
||||
|
||||
private String stringValue(Object value) {
|
||||
return value instanceof String text ? text : null;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<Map<String, Object>> inputFields(Object value) {
|
||||
if (!(value instanceof List<?> values)) {
|
||||
return List.of();
|
||||
}
|
||||
return values.stream()
|
||||
.filter(Map.class::isInstance)
|
||||
.map(item -> Map.copyOf((Map<String, Object>) item))
|
||||
.toList();
|
||||
}
|
||||
|
||||
}
|
||||
-153
@@ -1,153 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.application;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.CreateModelProviderConfigurationCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.DeleteModelProviderConfigurationCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.ListModelProviderConfigurationsCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.ListModelProviderOptionsCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.SwitchModelProviderCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.UpdateModelProviderConfigurationCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayResponse.GatewaySingleResponse;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayResponse.Meta;
|
||||
import org.apache.hertzbeat.ai.gateway.application.ModelProviderConfigurationView.Provider;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.ReloadableAgentRuntimeModelClient;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.provider.AgentModelProviderRegistry;
|
||||
import org.apache.hertzbeat.common.entity.dto.ModelProviderConfig;
|
||||
import org.apache.hertzbeat.common.entity.dto.ModelProviderConfigState;
|
||||
import org.apache.hertzbeat.manager.service.ModelProviderConfigurationService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* Channel-neutral model provider configuration commands.
|
||||
*/
|
||||
@Service
|
||||
public class ModelProviderCommandService {
|
||||
|
||||
private final AgentModelProviderRegistry providerRegistry;
|
||||
private final ModelProviderConfigurationService configurationService;
|
||||
private final ReloadableAgentRuntimeModelClient runtimeModelClient;
|
||||
|
||||
public ModelProviderCommandService(AgentModelProviderRegistry providerRegistry,
|
||||
ModelProviderConfigurationService configurationService,
|
||||
ReloadableAgentRuntimeModelClient runtimeModelClient) {
|
||||
this.providerRegistry = providerRegistry;
|
||||
this.configurationService = configurationService;
|
||||
this.runtimeModelClient = runtimeModelClient;
|
||||
}
|
||||
|
||||
public GatewaySingleResponse listOptions(ListModelProviderOptionsCommand command) {
|
||||
return response(command.commandId(), "model-provider-options", providerRegistry.options());
|
||||
}
|
||||
|
||||
public GatewaySingleResponse listConfigurations(ListModelProviderConfigurationsCommand command) {
|
||||
return stateResponse(command.commandId(), "model-provider-configurations",
|
||||
configurationService.getState());
|
||||
}
|
||||
|
||||
public GatewaySingleResponse createConfiguration(CreateModelProviderConfigurationCommand command) {
|
||||
ModelProviderConfig config = copyConfiguration(command.config());
|
||||
config.setUid(null);
|
||||
providerRegistry.createModel(config);
|
||||
return stateResponse(command.commandId(), "model-provider-configuration-created",
|
||||
configurationService.createConfiguration(config));
|
||||
}
|
||||
|
||||
public GatewaySingleResponse updateConfiguration(UpdateModelProviderConfigurationCommand command) {
|
||||
ModelProviderConfig existing = configurationService.getConfiguration(command.providerUid());
|
||||
ModelProviderConfig config = copyConfiguration(command.config());
|
||||
config.setUid(command.providerUid());
|
||||
boolean sameProvider = Objects.equals(existing.getCode(), config.getCode())
|
||||
&& Objects.equals(existing.getType(), config.getType());
|
||||
if (!StringUtils.hasText(config.getApiKey())
|
||||
&& sameProvider) {
|
||||
config.setApiKey(existing.getApiKey());
|
||||
}
|
||||
providerRegistry.createModel(config);
|
||||
ModelProviderConfigState state = configurationService.updateConfiguration(
|
||||
command.providerUid(), config);
|
||||
if (Objects.equals(command.providerUid(), state.getActiveProviderUid())) {
|
||||
runtimeModelClient.refreshConfiguration();
|
||||
}
|
||||
return stateResponse(command.commandId(), "model-provider-configuration-updated", state);
|
||||
}
|
||||
|
||||
public GatewaySingleResponse deleteConfiguration(DeleteModelProviderConfigurationCommand command) {
|
||||
boolean deletingActiveConfiguration = Objects.equals(
|
||||
command.providerUid(), configurationService.getState().getActiveProviderUid());
|
||||
ModelProviderConfigState state = configurationService.deleteConfiguration(command.providerUid());
|
||||
if (deletingActiveConfiguration) {
|
||||
runtimeModelClient.refreshConfiguration();
|
||||
}
|
||||
return stateResponse(command.commandId(), "model-provider-configuration-deleted", state);
|
||||
}
|
||||
|
||||
public GatewaySingleResponse switchConfiguration(SwitchModelProviderCommand command) {
|
||||
String previousProviderUid = configurationService.getState().getActiveProviderUid();
|
||||
if (StringUtils.hasText(command.providerUid())) {
|
||||
ModelProviderConfig config = configurationService.getConfiguration(command.providerUid());
|
||||
providerRegistry.createModel(config);
|
||||
}
|
||||
ModelProviderConfigState state = configurationService.switchActiveConfiguration(command.providerUid());
|
||||
if (!Objects.equals(previousProviderUid, state.getActiveProviderUid())) {
|
||||
runtimeModelClient.refreshConfiguration();
|
||||
}
|
||||
return stateResponse(command.commandId(), "model-provider-configuration-switched", state);
|
||||
}
|
||||
|
||||
private GatewaySingleResponse stateResponse(String commandId, String message,
|
||||
ModelProviderConfigState state) {
|
||||
List<Provider> providers = state.getProviders().stream()
|
||||
.map(config -> new Provider(
|
||||
config.getUid(),
|
||||
config.getType(),
|
||||
config.getCode(),
|
||||
config.getBaseUrl(),
|
||||
config.getModel(),
|
||||
StringUtils.hasText(config.getApiKey())))
|
||||
.toList();
|
||||
return response(commandId, message,
|
||||
new ModelProviderConfigurationView(state.getActiveProviderUid(), providers));
|
||||
}
|
||||
|
||||
private GatewaySingleResponse response(String commandId, String message, Object body) {
|
||||
return GatewaySingleResponse.builder()
|
||||
.meta(Meta.builder()
|
||||
.commandId(commandId)
|
||||
.terminal(true)
|
||||
.message(message)
|
||||
.build())
|
||||
.body(body)
|
||||
.events(List.of())
|
||||
.build();
|
||||
}
|
||||
|
||||
private ModelProviderConfig copyConfiguration(ModelProviderConfig source) {
|
||||
ModelProviderConfig copy = new ModelProviderConfig();
|
||||
copy.setUid(source.getUid());
|
||||
copy.setType(source.getType());
|
||||
copy.setCode(source.getCode());
|
||||
copy.setBaseUrl(source.getBaseUrl());
|
||||
copy.setModel(source.getModel());
|
||||
copy.setApiKey(source.getApiKey());
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
-57
@@ -1,57 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.application;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Secret-free provider configuration state returned to Gateway channels.
|
||||
*
|
||||
* @param activeProviderUid active saved configuration UID; null selects the YAML default
|
||||
* @param providers saved provider configurations without API keys
|
||||
*/
|
||||
@Schema(description = "Saved LLM provider configurations and active selection")
|
||||
public record ModelProviderConfigurationView(
|
||||
String activeProviderUid,
|
||||
List<Provider> providers) {
|
||||
|
||||
public ModelProviderConfigurationView {
|
||||
providers = List.copyOf(providers);
|
||||
}
|
||||
|
||||
/**
|
||||
* One secret-free saved provider configuration.
|
||||
*
|
||||
* @param uid stable saved configuration identifier
|
||||
* @param type runtime provider implementation type
|
||||
* @param code provider preset code
|
||||
* @param baseUrl provider API endpoint
|
||||
* @param model model identifier
|
||||
* @param apiKeyConfigured whether a secret is persisted
|
||||
*/
|
||||
@Schema(description = "Saved LLM provider configuration without its API key")
|
||||
public record Provider(
|
||||
String uid,
|
||||
String type,
|
||||
String code,
|
||||
String baseUrl,
|
||||
String model,
|
||||
boolean apiKeyConfigured) {
|
||||
}
|
||||
}
|
||||
-164
@@ -1,164 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.application;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.CancelRunCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.ReplyMode;
|
||||
import org.apache.hertzbeat.ai.gateway.conversation.AgentRunStatus;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayEvent.ErrorPayload;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayEvent.GatewayEventType;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayEvent.RunCompletedPayload;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayResponse.Meta;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayResponse.GatewaySingleResponse;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayResponse.GatewayStreamResponse;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.AgentRuntimeControlRegistry;
|
||||
import org.apache.hertzbeat.ai.gateway.conversation.AgentRunService;
|
||||
import org.apache.hertzbeat.ai.gateway.conversation.AgentSessionService;
|
||||
import org.apache.hertzbeat.ai.gateway.text.GatewayText;
|
||||
import org.apache.hertzbeat.common.entity.agent.AgentRun;
|
||||
import org.apache.hertzbeat.common.entity.agent.AgentSession;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
/**
|
||||
* Run lifecycle commands. Cancellation never invokes runtime.
|
||||
*/
|
||||
@Service
|
||||
public class RunCommandService {
|
||||
|
||||
private final AgentRunService runService;
|
||||
private final AgentSessionService sessionService;
|
||||
private final AgentRuntimeControlRegistry controlRegistry;
|
||||
|
||||
public RunCommandService(AgentRunService runService, AgentSessionService sessionService,
|
||||
AgentRuntimeControlRegistry controlRegistry) {
|
||||
this.runService = runService;
|
||||
this.sessionService = sessionService;
|
||||
this.controlRegistry = controlRegistry;
|
||||
}
|
||||
|
||||
public GatewayResponse cancel(CancelRunCommand command) {
|
||||
String runUid = requiredText(command.runUid(), "Agent run UID is required");
|
||||
String reason = GatewayText.isBlank(command.reason())
|
||||
? "Runtime cancellation requested."
|
||||
: command.reason();
|
||||
Optional<AgentRun> runOptional = runService.findRun(runUid);
|
||||
if (runOptional.isEmpty()) {
|
||||
GatewayEvent error = errorEvent(command.commandId(), null, runUid, "Agent run not found.");
|
||||
return response(command, null, runUid, List.of(error));
|
||||
}
|
||||
AgentRun run = runOptional.get();
|
||||
AgentSession session = session(run, command);
|
||||
List<GatewayEvent> events;
|
||||
if (session == null) {
|
||||
events = List.of(errorEvent(command.commandId(), null, runUid, "Agent run not found."));
|
||||
} else if (isTerminalRun(run)) {
|
||||
events = List.of(errorEvent(command.commandId(), session, runUid,
|
||||
"Agent run is already stopped."));
|
||||
} else if (AgentRunStatus.RUNNING.name().equals(run.getStatus())) {
|
||||
boolean cancelled = controlRegistry.cancel(runUid, reason);
|
||||
events = cancelled ? List.of() : List.of(errorEvent(command.commandId(), session, runUid,
|
||||
"Agent runtime run is not active or already stopped."));
|
||||
} else {
|
||||
AgentRun cancelledRun = runService.markCancelled(run, reason);
|
||||
events = List.of(runCompletedEvent(command.commandId(), session, cancelledRun,
|
||||
"Agent run cancelled."));
|
||||
}
|
||||
return response(command, session, runUid, events);
|
||||
}
|
||||
|
||||
private GatewayResponse response(CancelRunCommand command, AgentSession session, String runUid,
|
||||
List<GatewayEvent> events) {
|
||||
if (command.replyMode() == ReplyMode.STREAM) {
|
||||
return GatewayStreamResponse.builder()
|
||||
.meta(Meta.builder()
|
||||
.commandId(command.commandId())
|
||||
.sessionUid(session == null ? null : session.getSessionUid())
|
||||
.runUid(runUid)
|
||||
.terminal(true)
|
||||
.message("cancel")
|
||||
.build())
|
||||
.events(Flux.fromIterable(events))
|
||||
.build();
|
||||
}
|
||||
return GatewaySingleResponse.builder()
|
||||
.meta(Meta.builder()
|
||||
.commandId(command.commandId())
|
||||
.sessionUid(session == null ? null : session.getSessionUid())
|
||||
.runUid(runUid)
|
||||
.terminal(true)
|
||||
.message("cancel")
|
||||
.build())
|
||||
.body(Map.of("runUid", runUid))
|
||||
.events(events)
|
||||
.build();
|
||||
}
|
||||
|
||||
private GatewayEvent runCompletedEvent(String commandId, AgentSession session, AgentRun run, String message) {
|
||||
return GatewayEvent.builder()
|
||||
.type(GatewayEventType.RUN_COMPLETED)
|
||||
.eventId(commandId + ":run-completed")
|
||||
.sessionUid(session == null ? null : session.getSessionUid())
|
||||
.runUid(run == null ? null : run.getRunUid())
|
||||
.payload(RunCompletedPayload.builder().build())
|
||||
.timestamp(System.currentTimeMillis())
|
||||
.build();
|
||||
}
|
||||
|
||||
private GatewayEvent errorEvent(String commandId, AgentSession session, String runUid, String message) {
|
||||
return GatewayEvent.builder()
|
||||
.type(GatewayEventType.ERROR)
|
||||
.eventId(commandId + ":error")
|
||||
.sessionUid(session == null ? null : session.getSessionUid())
|
||||
.runUid(runUid)
|
||||
.payload(ErrorPayload.builder()
|
||||
.errorMessage(message)
|
||||
.build())
|
||||
.timestamp(System.currentTimeMillis())
|
||||
.build();
|
||||
}
|
||||
|
||||
private AgentSession session(AgentRun run, CancelRunCommand command) {
|
||||
if (run == null || run.getSessionId() == null) {
|
||||
return null;
|
||||
}
|
||||
return sessionService.findOwnedSession(String.valueOf(run.getSessionId()), command.envelope(),
|
||||
command.originEntryType()).orElse(null);
|
||||
}
|
||||
|
||||
private boolean isTerminalRun(AgentRun run) {
|
||||
String status = run == null ? null : run.getStatus();
|
||||
return AgentRunStatus.SUCCEEDED.name().equals(status)
|
||||
|| AgentRunStatus.FAILED.name().equals(status)
|
||||
|| AgentRunStatus.CANCELLED.name().equals(status)
|
||||
|| AgentRunStatus.RECOVERY_REQUIRED.name().equals(status);
|
||||
}
|
||||
|
||||
private String requiredText(String value, String message) {
|
||||
// Command identifiers originate in channel requests and may contain surrounding whitespace before lookup.
|
||||
String normalized = GatewayText.normalize(value);
|
||||
if (normalized == null) {
|
||||
throw new IllegalArgumentException(message);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
}
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.channel.core;
|
||||
|
||||
/** Built-in Agent Gateway ingress channel identifiers. */
|
||||
public enum ChannelId {
|
||||
|
||||
WEB_UI("web-ui"),
|
||||
SYSTEM("system");
|
||||
|
||||
private final String id;
|
||||
|
||||
ChannelId(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String id() {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
-174
@@ -1,174 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.channel.webui;
|
||||
|
||||
import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import java.util.List;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.CreateModelProviderConfigurationCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.DeleteModelProviderConfigurationCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.ListModelProviderConfigurationsCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.ListModelProviderOptionsCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.ReplyMode;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.SwitchModelProviderCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.UpdateModelProviderConfigurationCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommandRouter;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayResponse.GatewaySingleResponse;
|
||||
import org.apache.hertzbeat.ai.gateway.application.ModelProviderConfigurationView;
|
||||
import org.apache.hertzbeat.ai.gateway.channel.core.ChannelId;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.GatewayEnvelope;
|
||||
import org.apache.hertzbeat.ai.gateway.identity.ActorSupport;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.provider.AgentModelProviderOption;
|
||||
import org.apache.hertzbeat.common.entity.dto.Message;
|
||||
import org.apache.hertzbeat.common.entity.dto.ModelProviderConfig;
|
||||
import org.apache.hertzbeat.common.observability.gateway.AuthTokenRequestContext;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Agent model provider discovery and configuration API.
|
||||
*/
|
||||
@Tag(name = "Agent Model Provider API")
|
||||
@RestController
|
||||
@RequestMapping(path = "/api/agent/model-providers", produces = {APPLICATION_JSON_VALUE})
|
||||
public class ModelProviderConfigController {
|
||||
|
||||
private final GatewayCommandRouter commandRouter;
|
||||
|
||||
public ModelProviderConfigController(GatewayCommandRouter commandRouter) {
|
||||
this.commandRouter = commandRouter;
|
||||
}
|
||||
|
||||
@GetMapping("/options")
|
||||
@Operation(summary = "List registered Agent model provider configuration options")
|
||||
public ResponseEntity<Message<List<AgentModelProviderOption>>> getOptions() {
|
||||
GatewaySingleResponse response = (GatewaySingleResponse) commandRouter.handle(
|
||||
ListModelProviderOptionsCommand.builder()
|
||||
.envelope(webUiEnvelope())
|
||||
.replyMode(ReplyMode.FINAL_ONLY)
|
||||
.commandId("list-model-provider-options")
|
||||
.build());
|
||||
@SuppressWarnings("unchecked")
|
||||
List<AgentModelProviderOption> options = (List<AgentModelProviderOption>) response.body();
|
||||
return ResponseEntity.ok(Message.success(options));
|
||||
}
|
||||
|
||||
@GetMapping("/configurations")
|
||||
@Operation(summary = "List saved Agent model provider configurations")
|
||||
public ResponseEntity<Message<ModelProviderConfigurationView>> getConfigurations() {
|
||||
GatewaySingleResponse response = (GatewaySingleResponse) commandRouter.handle(
|
||||
ListModelProviderConfigurationsCommand.builder()
|
||||
.envelope(webUiEnvelope())
|
||||
.replyMode(ReplyMode.FINAL_ONLY)
|
||||
.commandId("list-model-provider-configurations")
|
||||
.build());
|
||||
return stateResponse(response);
|
||||
}
|
||||
|
||||
@PostMapping("/configurations")
|
||||
@Operation(summary = "Validate and create an Agent model provider configuration")
|
||||
public ResponseEntity<Message<ModelProviderConfigurationView>> createConfiguration(
|
||||
@RequestBody ModelProviderConfig config) {
|
||||
GatewaySingleResponse response = (GatewaySingleResponse) commandRouter.handle(
|
||||
CreateModelProviderConfigurationCommand.builder()
|
||||
.envelope(webUiEnvelope())
|
||||
.replyMode(ReplyMode.FINAL_ONLY)
|
||||
.commandId("create-model-provider-configuration")
|
||||
.config(config)
|
||||
.build());
|
||||
return stateResponse(response);
|
||||
}
|
||||
|
||||
@PutMapping("/configurations/{providerUid}")
|
||||
@Operation(summary = "Validate and update an Agent model provider configuration")
|
||||
public ResponseEntity<Message<ModelProviderConfigurationView>> updateConfiguration(
|
||||
@PathVariable String providerUid,
|
||||
@RequestBody ModelProviderConfig config) {
|
||||
GatewaySingleResponse response = (GatewaySingleResponse) commandRouter.handle(
|
||||
UpdateModelProviderConfigurationCommand.builder()
|
||||
.envelope(webUiEnvelope())
|
||||
.replyMode(ReplyMode.FINAL_ONLY)
|
||||
.commandId("update-model-provider-configuration")
|
||||
.providerUid(providerUid)
|
||||
.config(config)
|
||||
.build());
|
||||
return stateResponse(response);
|
||||
}
|
||||
|
||||
@DeleteMapping("/configurations/{providerUid}")
|
||||
@Operation(summary = "Delete an Agent model provider configuration")
|
||||
public ResponseEntity<Message<ModelProviderConfigurationView>> deleteConfiguration(
|
||||
@PathVariable String providerUid) {
|
||||
GatewaySingleResponse response = (GatewaySingleResponse) commandRouter.handle(
|
||||
DeleteModelProviderConfigurationCommand.builder()
|
||||
.envelope(webUiEnvelope())
|
||||
.replyMode(ReplyMode.FINAL_ONLY)
|
||||
.commandId("delete-model-provider-configuration")
|
||||
.providerUid(providerUid)
|
||||
.build());
|
||||
return stateResponse(response);
|
||||
}
|
||||
|
||||
@PutMapping("/active/{providerUid}")
|
||||
@Operation(summary = "Switch the active Agent model provider configuration")
|
||||
public ResponseEntity<Message<ModelProviderConfigurationView>> switchConfiguration(
|
||||
@PathVariable String providerUid) {
|
||||
return switchConfiguration(providerUid, "switch-model-provider-configuration");
|
||||
}
|
||||
|
||||
@DeleteMapping("/active")
|
||||
@Operation(summary = "Switch the active Agent model provider to the YAML default")
|
||||
public ResponseEntity<Message<ModelProviderConfigurationView>> switchToDefault() {
|
||||
return switchConfiguration(null, "switch-model-provider-to-default");
|
||||
}
|
||||
|
||||
private ResponseEntity<Message<ModelProviderConfigurationView>> switchConfiguration(
|
||||
String providerUid, String commandId) {
|
||||
GatewaySingleResponse response = (GatewaySingleResponse) commandRouter.handle(
|
||||
SwitchModelProviderCommand.builder()
|
||||
.envelope(webUiEnvelope())
|
||||
.replyMode(ReplyMode.FINAL_ONLY)
|
||||
.commandId(commandId)
|
||||
.providerUid(providerUid)
|
||||
.build());
|
||||
return stateResponse(response);
|
||||
}
|
||||
|
||||
private ResponseEntity<Message<ModelProviderConfigurationView>> stateResponse(
|
||||
GatewaySingleResponse response) {
|
||||
return ResponseEntity.ok(Message.success((ModelProviderConfigurationView) response.body()));
|
||||
}
|
||||
|
||||
private GatewayEnvelope webUiEnvelope() {
|
||||
return GatewayEnvelope.builder()
|
||||
.channelId(ChannelId.WEB_UI.id())
|
||||
.receivedAt(System.currentTimeMillis())
|
||||
.actor(ActorSupport.requireCurrentAdminSurenessActor())
|
||||
.workspaceId(AuthTokenRequestContext.currentWorkspaceId())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
-221
@@ -1,221 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.channel.webui;
|
||||
|
||||
import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.apache.hertzbeat.ai.gateway.channel.core.ChannelId;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommandRouter;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.GatewayEnvelope;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.GetSessionCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.GetRunCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.GetLatestSessionRunCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.GetSessionTranscriptCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.ListSessionsCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.ReplyMode;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayResponse.GatewaySingleResponse;
|
||||
import org.apache.hertzbeat.ai.gateway.identity.ActorSupport;
|
||||
import org.apache.hertzbeat.ai.gateway.identity.AgentActor;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.AgentRuntimeEntryType;
|
||||
import org.apache.hertzbeat.ai.gateway.conversation.AgentRunSnapshot;
|
||||
import org.apache.hertzbeat.ai.gateway.conversation.AgentSessionListItem;
|
||||
import org.apache.hertzbeat.common.entity.agent.AgentTranscriptEntry;
|
||||
import org.apache.hertzbeat.common.entity.dto.Message;
|
||||
import org.apache.hertzbeat.common.observability.gateway.AuthTokenRequestContext;
|
||||
import org.apache.hertzbeat.common.observability.gateway.AuthTokenScopes;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Read-only Agent Gateway query API.
|
||||
*/
|
||||
@Tag(name = "Agent Query API")
|
||||
@RestController
|
||||
@RequestMapping(path = "/api/agent", produces = {APPLICATION_JSON_VALUE})
|
||||
public class QueryController {
|
||||
|
||||
private final GatewayCommandRouter commandRouter;
|
||||
|
||||
public QueryController(GatewayCommandRouter commandRouter) {
|
||||
this.commandRouter = commandRouter;
|
||||
}
|
||||
|
||||
@GetMapping("/sessions/{sessionId}")
|
||||
@Operation(summary = "Get Agent Gateway session")
|
||||
public ResponseEntity<Message<GatewaySingleResponse>> getSession(
|
||||
@Parameter(description = "Session ID or UID") @PathVariable String sessionId) {
|
||||
return ResponseEntity.ok(Message.success((GatewaySingleResponse) commandRouter.handle(
|
||||
GetSessionCommand.builder()
|
||||
.envelope(webUiEnvelope())
|
||||
.replyMode(ReplyMode.FINAL_ONLY)
|
||||
.commandId("get-session:" + sessionId)
|
||||
.originEntryType(AgentRuntimeEntryType.USER_INPUT)
|
||||
.sessionUid(sessionId)
|
||||
.build())));
|
||||
}
|
||||
|
||||
@GetMapping("/runs/{runUid}")
|
||||
@Operation(summary = "Get an owned Agent Gateway run")
|
||||
public ResponseEntity<Message<AgentRunSnapshot>> getRun(
|
||||
@Parameter(description = "Run UID") @PathVariable String runUid) {
|
||||
GatewaySingleResponse response = (GatewaySingleResponse) commandRouter.handle(
|
||||
GetRunCommand.builder()
|
||||
.envelope(webUiEnvelope())
|
||||
.replyMode(ReplyMode.FINAL_ONLY)
|
||||
.commandId("get-run:" + runUid)
|
||||
.originEntryType(AgentRuntimeEntryType.USER_INPUT)
|
||||
.runUid(runUid)
|
||||
.build());
|
||||
return ResponseEntity.ok(Message.success((AgentRunSnapshot) response.body()));
|
||||
}
|
||||
|
||||
@GetMapping("/sessions/{sessionUid}/latest-run")
|
||||
@Operation(summary = "Get the latest owned Agent Gateway run for a session")
|
||||
public ResponseEntity<Message<AgentRunSnapshot>> getLatestSessionRun(
|
||||
@Parameter(description = "Session UID") @PathVariable String sessionUid) {
|
||||
GatewaySingleResponse response = (GatewaySingleResponse) commandRouter.handle(
|
||||
GetLatestSessionRunCommand.builder()
|
||||
.envelope(webUiEnvelope())
|
||||
.replyMode(ReplyMode.FINAL_ONLY)
|
||||
.commandId("get-latest-run:" + sessionUid)
|
||||
.originEntryType(AgentRuntimeEntryType.USER_INPUT)
|
||||
.sessionUid(sessionUid)
|
||||
.build());
|
||||
return ResponseEntity.ok(Message.success((AgentRunSnapshot) response.body()));
|
||||
}
|
||||
|
||||
@GetMapping("/sessions")
|
||||
@Operation(summary = "List current WebUI user's Agent Gateway sessions")
|
||||
@SuppressWarnings("unchecked")
|
||||
public ResponseEntity<Message<Page<AgentSessionListItem>>> listSessions(
|
||||
@RequestParam(defaultValue = "0") int pageIndex,
|
||||
@RequestParam(defaultValue = "50") int pageSize) {
|
||||
GatewaySingleResponse response = (GatewaySingleResponse) commandRouter.handle(
|
||||
ListSessionsCommand.builder()
|
||||
.envelope(webUiEnvelope())
|
||||
.replyMode(ReplyMode.FINAL_ONLY)
|
||||
.commandId("list-sessions:" + pageIndex)
|
||||
.originEntryType(AgentRuntimeEntryType.USER_INPUT)
|
||||
.title(null)
|
||||
.pageIndex(pageIndex)
|
||||
.pageSize(pageSize)
|
||||
.build());
|
||||
return ResponseEntity.ok(Message.success((Page<AgentSessionListItem>) response.body()));
|
||||
}
|
||||
|
||||
@GetMapping("/alert-analysis/sessions")
|
||||
@Operation(summary = "List automatic alert analysis sessions")
|
||||
@SuppressWarnings("unchecked")
|
||||
public ResponseEntity<Message<Page<AgentSessionListItem>>> listAlertAnalysisSessions(
|
||||
@RequestParam(defaultValue = "0") int pageIndex,
|
||||
@RequestParam(defaultValue = "50") int pageSize,
|
||||
@RequestParam(required = false) String search) {
|
||||
GatewaySingleResponse response = (GatewaySingleResponse) commandRouter.handle(
|
||||
ListSessionsCommand.builder()
|
||||
.envelope(alertAnalysisEnvelope())
|
||||
.replyMode(ReplyMode.FINAL_ONLY)
|
||||
.commandId("list-alert-analysis-sessions:" + pageIndex)
|
||||
.originEntryType(AgentRuntimeEntryType.ALERT_TRIGGER)
|
||||
.title(search)
|
||||
.pageIndex(pageIndex)
|
||||
.pageSize(pageSize)
|
||||
.build());
|
||||
return ResponseEntity.ok(Message.success((Page<AgentSessionListItem>) response.body()));
|
||||
}
|
||||
|
||||
@GetMapping("/alert-analysis/sessions/{sessionId}")
|
||||
@Operation(summary = "Get automatic alert analysis session")
|
||||
public ResponseEntity<Message<GatewaySingleResponse>> getAlertAnalysisSession(
|
||||
@Parameter(description = "Session ID or UID") @PathVariable String sessionId) {
|
||||
return ResponseEntity.ok(Message.success((GatewaySingleResponse) commandRouter.handle(
|
||||
GetSessionCommand.builder()
|
||||
.envelope(alertAnalysisEnvelope())
|
||||
.replyMode(ReplyMode.FINAL_ONLY)
|
||||
.commandId("get-alert-analysis-session:" + sessionId)
|
||||
.originEntryType(AgentRuntimeEntryType.ALERT_TRIGGER)
|
||||
.sessionUid(sessionId)
|
||||
.build())));
|
||||
}
|
||||
|
||||
@GetMapping("/sessions/{sessionUid}/transcript")
|
||||
@Operation(summary = "List Agent Gateway session transcript entries")
|
||||
@SuppressWarnings("unchecked")
|
||||
public ResponseEntity<Message<Page<AgentTranscriptEntry>>> listSessionTranscript(
|
||||
@Parameter(description = "Session UID or numeric ID") @PathVariable String sessionUid,
|
||||
@RequestParam(defaultValue = "0") int pageIndex,
|
||||
@RequestParam(defaultValue = "50") int pageSize) {
|
||||
GatewaySingleResponse response = (GatewaySingleResponse) commandRouter.handle(
|
||||
GetSessionTranscriptCommand.builder()
|
||||
.envelope(webUiEnvelope())
|
||||
.replyMode(ReplyMode.FINAL_ONLY)
|
||||
.commandId("get-session-transcript:" + sessionUid)
|
||||
.originEntryType(AgentRuntimeEntryType.USER_INPUT)
|
||||
.sessionUid(sessionUid)
|
||||
.pageIndex(pageIndex)
|
||||
.pageSize(pageSize)
|
||||
.build());
|
||||
return ResponseEntity.ok(Message.success((Page<AgentTranscriptEntry>) response.body()));
|
||||
}
|
||||
|
||||
@GetMapping("/alert-analysis/sessions/{sessionUid}/transcript")
|
||||
@Operation(summary = "List automatic alert analysis session transcript entries")
|
||||
@SuppressWarnings("unchecked")
|
||||
public ResponseEntity<Message<Page<AgentTranscriptEntry>>> listAlertAnalysisSessionTranscript(
|
||||
@Parameter(description = "Session UID or numeric ID") @PathVariable String sessionUid,
|
||||
@RequestParam(defaultValue = "0") int pageIndex,
|
||||
@RequestParam(defaultValue = "50") int pageSize) {
|
||||
GatewaySingleResponse response = (GatewaySingleResponse) commandRouter.handle(
|
||||
GetSessionTranscriptCommand.builder()
|
||||
.envelope(alertAnalysisEnvelope())
|
||||
.replyMode(ReplyMode.FINAL_ONLY)
|
||||
.commandId("get-alert-analysis-session-transcript:" + sessionUid)
|
||||
.originEntryType(AgentRuntimeEntryType.ALERT_TRIGGER)
|
||||
.sessionUid(sessionUid)
|
||||
.pageIndex(pageIndex)
|
||||
.pageSize(pageSize)
|
||||
.build());
|
||||
return ResponseEntity.ok(Message.success((Page<AgentTranscriptEntry>) response.body()));
|
||||
}
|
||||
|
||||
private GatewayEnvelope webUiEnvelope() {
|
||||
return GatewayEnvelope.builder()
|
||||
.channelId(ChannelId.WEB_UI.id())
|
||||
.receivedAt(System.currentTimeMillis())
|
||||
.actor(ActorSupport.requireCurrentSurenessActor())
|
||||
.workspaceId(AuthTokenRequestContext.currentWorkspaceId())
|
||||
.build();
|
||||
}
|
||||
|
||||
private GatewayEnvelope alertAnalysisEnvelope() {
|
||||
ActorSupport.requireCurrentAdminSurenessActor();
|
||||
return GatewayEnvelope.builder()
|
||||
.channelId(ChannelId.SYSTEM.id())
|
||||
.receivedAt(System.currentTimeMillis())
|
||||
.actor(AgentActor.alertAnalysisActor())
|
||||
.workspaceId(AuthTokenScopes.DEFAULT_WORKSPACE_ID)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
-217
@@ -1,217 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.channel.webui;
|
||||
|
||||
import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
|
||||
import static org.springframework.http.MediaType.TEXT_EVENT_STREAM_VALUE;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.ApprovalDecisionCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.CancelRunCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.InvokeCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand.ReplyMode;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommandRouter;
|
||||
import org.apache.hertzbeat.ai.gateway.application.AgentTargetCanonicalizationService.FailureKind;
|
||||
import org.apache.hertzbeat.ai.gateway.application.AgentTargetCanonicalizationService.TargetCanonicalizationException;
|
||||
import org.apache.hertzbeat.ai.gateway.channel.core.ChannelId;
|
||||
import org.apache.hertzbeat.ai.gateway.channel.webui.dto.WebUiChatStreamRequest;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentResponseLanguage;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.GatewayEnvelope;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.UserInput;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayEvent;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayEvent.ErrorPayload;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayEvent.GatewayEventType;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayResponse.GatewaySingleResponse;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayResponse.GatewayStreamResponse;
|
||||
import org.apache.hertzbeat.common.entity.dto.Message;
|
||||
import org.apache.hertzbeat.common.observability.gateway.AuthTokenRequestContext;
|
||||
import org.apache.hertzbeat.ai.gateway.identity.ActorSupport;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.AgentRuntimeEntryType;
|
||||
import org.apache.hertzbeat.ai.gateway.tool.core.AgentApprovalDecision;
|
||||
import org.apache.hertzbeat.ai.gateway.tool.interaction.AgentInteractionInputService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.codec.ServerSentEvent;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
/**
|
||||
* Interactive Agent Gateway endpoints used by the WebUI.
|
||||
*/
|
||||
@Tag(name = "Agent WebUI API")
|
||||
@RestController
|
||||
@RequestMapping(path = "/api/agent", produces = {APPLICATION_JSON_VALUE})
|
||||
public class WebUiController {
|
||||
|
||||
private final GatewayCommandRouter commandRouter;
|
||||
private final AgentInteractionInputService interactionInputService;
|
||||
|
||||
public WebUiController(GatewayCommandRouter commandRouter,
|
||||
AgentInteractionInputService interactionInputService) {
|
||||
this.commandRouter = commandRouter;
|
||||
this.interactionInputService = interactionInputService;
|
||||
}
|
||||
|
||||
@PostMapping("/webui/chat")
|
||||
@Operation(summary = "Send a WebUI chat message")
|
||||
public ResponseEntity<Message<GatewaySingleResponse>> chat(
|
||||
@Valid @RequestBody WebUiChatStreamRequest request,
|
||||
@RequestHeader(name = HttpHeaders.ACCEPT_LANGUAGE, required = false) String acceptLanguage) {
|
||||
return ResponseEntity.ok(Message.success((GatewaySingleResponse) commandRouter.handle(
|
||||
chatCommand(request, ReplyMode.FINAL_ONLY, acceptLanguage))));
|
||||
}
|
||||
|
||||
@PostMapping(value = "/webui/chat/stream", produces = TEXT_EVENT_STREAM_VALUE)
|
||||
@Operation(summary = "Stream a WebUI chat message")
|
||||
public Flux<ServerSentEvent<GatewayEvent>> streamChat(
|
||||
@Valid @RequestBody WebUiChatStreamRequest request,
|
||||
@RequestHeader(name = HttpHeaders.ACCEPT_LANGUAGE, required = false) String acceptLanguage) {
|
||||
InvokeCommand command = chatCommand(request, ReplyMode.STREAM, acceptLanguage);
|
||||
return Flux.defer(() -> ((GatewayStreamResponse) commandRouter.handle(command)).events())
|
||||
.map(this::toServerSentEvent)
|
||||
.onErrorResume(exception -> Flux.just(toServerSentEvent(errorEvent(exception))));
|
||||
}
|
||||
|
||||
@PostMapping("/runs/{runUid}/stop")
|
||||
@Operation(summary = "Stop an Agent Gateway run")
|
||||
public ResponseEntity<Message<GatewaySingleResponse>> stopRun(
|
||||
@Parameter(description = "Run UID") @PathVariable String runUid) {
|
||||
return ResponseEntity.ok(Message.success((GatewaySingleResponse) commandRouter.handle(
|
||||
CancelRunCommand.builder()
|
||||
.envelope(envelope())
|
||||
.replyMode(ReplyMode.FINAL_ONLY)
|
||||
.commandId("stop-run:" + runUid)
|
||||
.originEntryType(AgentRuntimeEntryType.USER_INPUT)
|
||||
.runUid(runUid)
|
||||
.reason("Stopped by the WebUI user.")
|
||||
.build())));
|
||||
}
|
||||
|
||||
@PostMapping("/approvals/{approvalId}/approve")
|
||||
@Operation(summary = "Approve Agent Gateway approval")
|
||||
public ResponseEntity<Message<GatewaySingleResponse>> approve(
|
||||
@Parameter(description = "Approval ID") @PathVariable String approvalId) {
|
||||
ApprovalDecisionCommand command = ApprovalDecisionCommand.builder()
|
||||
.envelope(envelope())
|
||||
.replyMode(ReplyMode.FINAL_ONLY)
|
||||
.commandId(approvalId)
|
||||
.originEntryType(AgentRuntimeEntryType.USER_INPUT)
|
||||
.approvalId(approvalId)
|
||||
.decision(AgentApprovalDecision.APPROVED)
|
||||
.build();
|
||||
return ResponseEntity.ok(Message.success((GatewaySingleResponse) commandRouter.handle(command)));
|
||||
}
|
||||
|
||||
@PostMapping("/approvals/{approvalId}/reject")
|
||||
@Operation(summary = "Reject Agent Gateway approval")
|
||||
public ResponseEntity<Message<GatewaySingleResponse>> reject(
|
||||
@Parameter(description = "Approval ID") @PathVariable String approvalId) {
|
||||
ApprovalDecisionCommand command = ApprovalDecisionCommand.builder()
|
||||
.envelope(envelope())
|
||||
.replyMode(ReplyMode.FINAL_ONLY)
|
||||
.commandId(approvalId)
|
||||
.originEntryType(AgentRuntimeEntryType.USER_INPUT)
|
||||
.approvalId(approvalId)
|
||||
.decision(AgentApprovalDecision.REJECTED)
|
||||
.build();
|
||||
return ResponseEntity.ok(Message.success((GatewaySingleResponse) commandRouter.handle(command)));
|
||||
}
|
||||
|
||||
@PostMapping("/interactions/{interactionId}/submit")
|
||||
@Operation(summary = "Submit values requested by an Agent interaction tool")
|
||||
public ResponseEntity<Message<String>> submitInteraction(
|
||||
@Parameter(description = "Interaction ID") @PathVariable String interactionId,
|
||||
@RequestBody InteractionSubmission submission) {
|
||||
GatewayEnvelope envelope = envelope();
|
||||
interactionInputService.submit(interactionId, envelope.getActor(), envelope.getWorkspaceId(),
|
||||
submission == null ? Map.of() : submission.values());
|
||||
return ResponseEntity.ok(Message.success("submitted"));
|
||||
}
|
||||
|
||||
private InvokeCommand chatCommand(WebUiChatStreamRequest request, ReplyMode replyMode, String acceptLanguage) {
|
||||
// WebUI attachments are optional; commands expose an immutable collection to downstream services.
|
||||
List<String> attachments = request.getAttachments() == null
|
||||
? List.of()
|
||||
: List.copyOf(request.getAttachments());
|
||||
UserInput input = UserInput.builder()
|
||||
.messageId(request.getMessageId())
|
||||
.conversationId(request.getConversationId())
|
||||
.target(request.getTarget())
|
||||
.message(UserInput.Message.builder()
|
||||
.text(request.getMessage())
|
||||
.attachments(attachments)
|
||||
.build())
|
||||
.build();
|
||||
return InvokeCommand.builder()
|
||||
.envelope(envelope().toBuilder()
|
||||
.preferredLanguage(AgentResponseLanguage.fromAcceptLanguage(acceptLanguage))
|
||||
.build())
|
||||
.replyMode(replyMode)
|
||||
.commandId(input.getMessageId())
|
||||
.userInput(input)
|
||||
.entryType(AgentRuntimeEntryType.USER_INPUT)
|
||||
.build();
|
||||
}
|
||||
|
||||
private GatewayEnvelope envelope() {
|
||||
return GatewayEnvelope.builder()
|
||||
.channelId(ChannelId.WEB_UI.id())
|
||||
.receivedAt(System.currentTimeMillis())
|
||||
.actor(ActorSupport.requireCurrentSurenessActor())
|
||||
.workspaceId(AuthTokenRequestContext.currentWorkspaceId())
|
||||
.build();
|
||||
}
|
||||
|
||||
private ServerSentEvent<GatewayEvent> toServerSentEvent(GatewayEvent item) {
|
||||
return ServerSentEvent.builder(item)
|
||||
.event(item.type().name().toLowerCase(Locale.ROOT))
|
||||
.id(item.eventId())
|
||||
.build();
|
||||
}
|
||||
|
||||
private GatewayEvent errorEvent(Throwable failure) {
|
||||
if (failure instanceof TargetCanonicalizationException canonicalization) {
|
||||
boolean mismatch = canonicalization.kind() == FailureKind.MISMATCH;
|
||||
return new GatewayEvent(GatewayEventType.ERROR, "webui:error", null, null, null, null,
|
||||
new ErrorPayload(null, mismatch ? "Investigation target does not match"
|
||||
: "Investigation target is unavailable",
|
||||
mismatch ? "TARGET_MISMATCH" : "TARGET_UNAVAILABLE"), System.currentTimeMillis());
|
||||
}
|
||||
return new GatewayEvent(GatewayEventType.ERROR, "webui:error", null, null, null, null,
|
||||
new ErrorPayload(null, "Agent Gateway stream failed", null), System.currentTimeMillis());
|
||||
}
|
||||
|
||||
/** Values supplied to a pending interaction request. */
|
||||
public record InteractionSubmission(Map<String, Object> values) {
|
||||
|
||||
public InteractionSubmission {
|
||||
values = values == null ? Map.of() : Map.copyOf(values);
|
||||
}
|
||||
}
|
||||
}
|
||||
-70
@@ -1,70 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.channel.webui.dto;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.util.List;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentTargetRef;
|
||||
|
||||
/**
|
||||
* WebUI chat stream request.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class WebUiChatStreamRequest {
|
||||
|
||||
/**
|
||||
* Stable conversation ID used to locate the Gateway session.
|
||||
*/
|
||||
@NotBlank
|
||||
@Size(max = 256)
|
||||
private String conversationId;
|
||||
|
||||
/**
|
||||
* Stable message ID used to locate the Gateway run on retries.
|
||||
*/
|
||||
@NotBlank
|
||||
@Size(max = 128)
|
||||
private String messageId;
|
||||
|
||||
/**
|
||||
* User input text.
|
||||
*/
|
||||
@NotBlank
|
||||
@Size(max = 8192)
|
||||
private String message;
|
||||
|
||||
/**
|
||||
* Optional target reference.
|
||||
*/
|
||||
@Valid
|
||||
private AgentTargetRef target;
|
||||
|
||||
/**
|
||||
* Optional attachment references.
|
||||
*/
|
||||
private List<String> attachments;
|
||||
}
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.contract;
|
||||
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import lombok.Builder;
|
||||
|
||||
/**
|
||||
* Trusted, non-persistent identifiers describing one automatically correlated alert incident.
|
||||
*/
|
||||
@Builder
|
||||
public record AgentAlertIncidentContext(
|
||||
Long analysisPolicyId,
|
||||
Long triggerAlertId,
|
||||
@Size(max = 256) List<Long> alertIds,
|
||||
int alertCount,
|
||||
long windowStartedAt) {
|
||||
|
||||
public AgentAlertIncidentContext {
|
||||
analysisPolicyId = Objects.requireNonNull(analysisPolicyId, "Analysis policy id is required");
|
||||
alertIds = alertIds == null ? List.of() : List.copyOf(alertIds);
|
||||
if (alertIds.size() > 256) {
|
||||
throw new IllegalArgumentException("Alert incident supports at most 256 alert ids");
|
||||
}
|
||||
if (alertIds.stream().anyMatch(Objects::isNull)) {
|
||||
throw new IllegalArgumentException("Alert incident ids must not contain null values");
|
||||
}
|
||||
if (alertCount <= 0) {
|
||||
throw new IllegalArgumentException("Alert incident count must be positive");
|
||||
}
|
||||
if (windowStartedAt < 0) {
|
||||
throw new IllegalArgumentException("Alert incident window start must not be negative");
|
||||
}
|
||||
}
|
||||
}
|
||||
-99
@@ -1,99 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.contract;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/** Exact non-live Log Explore page scope selected by an operator. */
|
||||
@Data
|
||||
@Builder(toBuilder = true)
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class AgentLogRef {
|
||||
|
||||
@Positive
|
||||
private Long start;
|
||||
|
||||
@Positive
|
||||
private Long end;
|
||||
|
||||
@Pattern(regexp = "[A-Za-z0-9][A-Za-z0-9._:-]{0,127}")
|
||||
private String traceId;
|
||||
|
||||
@Pattern(regexp = "[A-Za-z0-9][A-Za-z0-9._:-]{0,127}")
|
||||
private String spanId;
|
||||
|
||||
@Min(1)
|
||||
@Max(24)
|
||||
private Integer severityNumber;
|
||||
|
||||
@Pattern(regexp = "TRACE|DEBUG|INFO|WARN|ERROR|FATAL")
|
||||
private String severityText;
|
||||
|
||||
@Size(max = 256)
|
||||
private String search;
|
||||
|
||||
@Size(max = 512)
|
||||
private String serviceName;
|
||||
|
||||
@Size(max = 512)
|
||||
private String serviceNamespace;
|
||||
|
||||
@Size(max = 512)
|
||||
private String environment;
|
||||
|
||||
@Size(max = 2048)
|
||||
private String resourceFilter;
|
||||
|
||||
@Size(max = 2048)
|
||||
private String attributeFilter;
|
||||
|
||||
@NotNull
|
||||
private Boolean hideInternal;
|
||||
|
||||
@NotNull
|
||||
private Boolean hideNoise;
|
||||
|
||||
@NotNull
|
||||
@PositiveOrZero
|
||||
@Max(10_000)
|
||||
private Integer pageIndex;
|
||||
|
||||
@NotNull
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
private Integer pageSize;
|
||||
|
||||
@AssertTrue
|
||||
@JsonIgnore
|
||||
public boolean isTimeWindowValid() {
|
||||
return start != null && end != null && start < end;
|
||||
}
|
||||
}
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.contract;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* Resolves model response languages against the locales supported by the HertzBeat WebUI.
|
||||
*/
|
||||
public final class AgentResponseLanguage {
|
||||
|
||||
public static final String DEFAULT = "en-US";
|
||||
|
||||
private static final List<Locale> SUPPORTED = List.of(
|
||||
Locale.forLanguageTag("en-US"),
|
||||
Locale.forLanguageTag("zh-CN"),
|
||||
Locale.forLanguageTag("zh-TW"),
|
||||
Locale.forLanguageTag("ja-JP"),
|
||||
Locale.forLanguageTag("pt-BR"));
|
||||
|
||||
private AgentResponseLanguage() {
|
||||
}
|
||||
|
||||
public static String fromAcceptLanguage(String acceptLanguage) {
|
||||
if (StringUtils.hasText(acceptLanguage)) {
|
||||
try {
|
||||
Locale matched = Locale.lookup(Locale.LanguageRange.parse(acceptLanguage), SUPPORTED);
|
||||
if (matched != null) {
|
||||
return matched.toLanguageTag();
|
||||
}
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
// Malformed external headers fall back to the configured system locale.
|
||||
}
|
||||
}
|
||||
return systemDefault();
|
||||
}
|
||||
|
||||
public static String systemDefault() {
|
||||
Locale matched = Locale.lookup(
|
||||
Locale.LanguageRange.parse(Locale.getDefault().toLanguageTag()), SUPPORTED);
|
||||
return matched == null ? DEFAULT : matched.toLanguageTag();
|
||||
}
|
||||
}
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.contract;
|
||||
|
||||
import java.util.List;
|
||||
import lombok.Builder;
|
||||
|
||||
/** Versioned execution material persisted on the authoritative USER transcript entry. */
|
||||
@Builder(toBuilder = true)
|
||||
public record AgentRunRequestSnapshot(
|
||||
String version,
|
||||
String conversationId,
|
||||
String messageId,
|
||||
String entryType,
|
||||
AgentTargetRef target,
|
||||
AgentAlertIncidentContext alertIncident,
|
||||
String message,
|
||||
List<String> attachments,
|
||||
String preferredLanguage,
|
||||
String approvalHandling,
|
||||
String replyMode) {
|
||||
|
||||
public static final String VERSION = "1";
|
||||
|
||||
public AgentRunRequestSnapshot {
|
||||
attachments = attachments == null ? List.of() : List.copyOf(attachments);
|
||||
}
|
||||
}
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.contract;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/** Canonical service identity exported from persisted entity identities. */
|
||||
@Data
|
||||
@Builder
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class AgentServiceRef {
|
||||
|
||||
@NotBlank
|
||||
@Size(max = 512)
|
||||
private String name;
|
||||
|
||||
@Size(max = 512)
|
||||
private String namespace;
|
||||
|
||||
@Size(max = 512)
|
||||
private String environment;
|
||||
}
|
||||
-87
@@ -1,87 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.contract;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.time.DateTimeException;
|
||||
import java.time.ZoneId;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* Signal query and time-window context selected by an operator.
|
||||
*/
|
||||
@Data
|
||||
@Builder(toBuilder = true)
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class AgentSignalRef {
|
||||
|
||||
@NotBlank
|
||||
@Pattern(regexp = "metrics|logs|traces")
|
||||
private String type;
|
||||
|
||||
@Size(max = 2048)
|
||||
private String query;
|
||||
|
||||
@Size(max = 64)
|
||||
private String timeRange;
|
||||
|
||||
@PositiveOrZero
|
||||
private Long start;
|
||||
|
||||
@PositiveOrZero
|
||||
private Long end;
|
||||
|
||||
@Size(max = 64)
|
||||
private String timezone;
|
||||
|
||||
/**
|
||||
* Absolute windows must be complete and ordered; relative-only windows leave both boundaries absent.
|
||||
*/
|
||||
@AssertTrue
|
||||
@JsonIgnore
|
||||
public boolean isAbsoluteWindowValid() {
|
||||
if (start == null && end == null) {
|
||||
return true;
|
||||
}
|
||||
return start != null && end != null && start < end;
|
||||
}
|
||||
|
||||
/** A supplied timezone must be a real IANA or fixed-offset zone. */
|
||||
@AssertTrue
|
||||
@JsonIgnore
|
||||
public boolean isTimezoneValid() {
|
||||
if (timezone == null) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
ZoneId.of(timezone);
|
||||
return true;
|
||||
} catch (DateTimeException ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.contract;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/** Manager-issued authority snapshot for a canonical Agent target. */
|
||||
@Data
|
||||
@Builder
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class AgentTargetAuthority {
|
||||
|
||||
@Positive
|
||||
private Long bindingId;
|
||||
|
||||
@NotBlank
|
||||
@Size(max = 64)
|
||||
private String version;
|
||||
|
||||
@NotBlank
|
||||
@Pattern(regexp = "sha256:[0-9a-f]{64}")
|
||||
private String hash;
|
||||
}
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.contract;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* HertzBeat resource target referenced by an Agent Gateway request.
|
||||
*/
|
||||
@Data
|
||||
@Builder(toBuilder = true)
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class AgentTargetRef {
|
||||
|
||||
@Size(max = 64)
|
||||
private String version;
|
||||
|
||||
@Positive
|
||||
private Long monitorId;
|
||||
|
||||
@Positive
|
||||
private Long alertId;
|
||||
|
||||
@Size(max = 16)
|
||||
private String alertType;
|
||||
|
||||
@Positive
|
||||
private Long entityId;
|
||||
|
||||
@Size(max = 128)
|
||||
private String collector;
|
||||
|
||||
@Valid
|
||||
private AgentSignalRef signal;
|
||||
|
||||
@Valid
|
||||
private AgentTopologyRef topology;
|
||||
|
||||
@Valid
|
||||
private AgentTraceRef trace;
|
||||
|
||||
@Valid
|
||||
private AgentLogRef log;
|
||||
|
||||
@Valid
|
||||
private AgentServiceRef service;
|
||||
|
||||
@Valid
|
||||
private AgentTargetAuthority authority;
|
||||
}
|
||||
-90
@@ -1,90 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.contract;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* Topology scope and selection selected by an operator.
|
||||
*/
|
||||
@Data
|
||||
@Builder(toBuilder = true)
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class AgentTopologyRef {
|
||||
|
||||
@Positive
|
||||
private Long rootEntityId;
|
||||
|
||||
@Size(max = 512)
|
||||
private String nodeId;
|
||||
|
||||
@Size(max = 512)
|
||||
private String edgeId;
|
||||
|
||||
@Min(1)
|
||||
@Max(2)
|
||||
private Integer depth;
|
||||
|
||||
@Size(max = 128)
|
||||
private String environment;
|
||||
|
||||
@Size(max = 64)
|
||||
private String sourceKind;
|
||||
|
||||
@Positive
|
||||
private Long start;
|
||||
|
||||
@Positive
|
||||
private Long end;
|
||||
|
||||
@Size(max = 128)
|
||||
private String relationType;
|
||||
|
||||
private Boolean hideInternal;
|
||||
|
||||
@PositiveOrZero
|
||||
@Max(10_000)
|
||||
private Integer pageIndex;
|
||||
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
private Integer pageSize;
|
||||
|
||||
@AssertTrue
|
||||
@JsonIgnore
|
||||
public boolean isSingleSelectionValid() {
|
||||
return nodeId == null || edgeId == null;
|
||||
}
|
||||
|
||||
@AssertTrue
|
||||
@JsonIgnore
|
||||
public boolean isTimeWindowValid() {
|
||||
return start == null && end == null || start != null && end != null && start < end;
|
||||
}
|
||||
}
|
||||
-84
@@ -1,84 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.contract;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/** Exact Trace Explore detail scope selected by an operator. */
|
||||
@Data
|
||||
@Builder(toBuilder = true)
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class AgentTraceRef {
|
||||
|
||||
@NotBlank
|
||||
@Pattern(regexp = "[A-Za-z0-9][A-Za-z0-9._:-]{0,127}")
|
||||
private String traceId;
|
||||
|
||||
@Pattern(regexp = "[A-Za-z0-9][A-Za-z0-9._:-]{0,127}")
|
||||
private String spanId;
|
||||
|
||||
@Positive
|
||||
private Long start;
|
||||
|
||||
@Positive
|
||||
private Long end;
|
||||
|
||||
@Size(max = 512)
|
||||
private String serviceName;
|
||||
|
||||
@Size(max = 512)
|
||||
private String serviceNamespace;
|
||||
|
||||
@Size(max = 512)
|
||||
private String environment;
|
||||
|
||||
@Size(max = 2048)
|
||||
private String resourceFilter;
|
||||
|
||||
@Size(max = 2048)
|
||||
private String attributeFilter;
|
||||
|
||||
@PositiveOrZero
|
||||
private Long minDurationMs;
|
||||
|
||||
@PositiveOrZero
|
||||
private Long maxDurationMs;
|
||||
|
||||
@AssertTrue
|
||||
@JsonIgnore
|
||||
public boolean isTimeWindowValid() {
|
||||
return start != null && end != null && start < end;
|
||||
}
|
||||
|
||||
@AssertTrue
|
||||
@JsonIgnore
|
||||
public boolean isDurationRangeValid() {
|
||||
return minDurationMs == null || maxDurationMs == null || minDurationMs <= maxDurationMs;
|
||||
}
|
||||
}
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.contract;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.util.Objects;
|
||||
import lombok.Builder;
|
||||
import org.apache.hertzbeat.ai.gateway.identity.AgentActor;
|
||||
import org.apache.hertzbeat.common.observability.gateway.AuthTokenScopes;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* Trusted invocation metadata supplied by a channel boundary.
|
||||
*/
|
||||
@Builder(toBuilder = true)
|
||||
public record GatewayEnvelope(
|
||||
@Size(max = 64) String channelId,
|
||||
Long receivedAt,
|
||||
@Valid AgentActor actor,
|
||||
@Size(max = 16) String preferredLanguage,
|
||||
@Size(max = 128) String workspaceId) {
|
||||
|
||||
public GatewayEnvelope {
|
||||
if (!StringUtils.hasText(channelId)) {
|
||||
throw new IllegalArgumentException("Gateway envelope channel id is required");
|
||||
}
|
||||
receivedAt = Objects.requireNonNull(receivedAt, "Gateway envelope received time is required");
|
||||
if (receivedAt < 0) {
|
||||
throw new IllegalArgumentException("Gateway envelope received time must not be negative");
|
||||
}
|
||||
workspaceId = AuthTokenScopes.normalizeWorkspaceId(workspaceId);
|
||||
}
|
||||
|
||||
public String getChannelId() {
|
||||
return channelId;
|
||||
}
|
||||
|
||||
public Long getReceivedAt() {
|
||||
return receivedAt;
|
||||
}
|
||||
|
||||
public AgentActor getActor() {
|
||||
return actor;
|
||||
}
|
||||
|
||||
public String getPreferredLanguage() {
|
||||
return preferredLanguage;
|
||||
}
|
||||
|
||||
public String getWorkspaceId() {
|
||||
return workspaceId;
|
||||
}
|
||||
}
|
||||
-84
@@ -1,84 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.contract;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* Normalized user input accepted by Agent Gateway channels.
|
||||
*/
|
||||
@Getter
|
||||
public class UserInput {
|
||||
|
||||
@Size(max = 128)
|
||||
private final String messageId;
|
||||
|
||||
@Size(max = 256)
|
||||
private final String conversationId;
|
||||
|
||||
@Valid
|
||||
private final AgentTargetRef target;
|
||||
|
||||
@Valid
|
||||
private final AgentAlertIncidentContext alertIncident;
|
||||
|
||||
@Valid
|
||||
private final Message message;
|
||||
|
||||
@Builder(toBuilder = true)
|
||||
private UserInput(String messageId, String conversationId, AgentTargetRef target,
|
||||
AgentAlertIncidentContext alertIncident, Message message) {
|
||||
if (!StringUtils.hasText(conversationId)) {
|
||||
throw new IllegalArgumentException("Agent input conversation id is required");
|
||||
}
|
||||
this.messageId = messageId;
|
||||
this.conversationId = conversationId;
|
||||
this.target = target;
|
||||
this.alertIncident = alertIncident;
|
||||
// UserInput is the channel-normalization boundary consumed by session, run, transcript, and runtime services.
|
||||
this.message = Objects.requireNonNull(message, "Agent input message is required");
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalized message content owned by one user input.
|
||||
*/
|
||||
@Builder
|
||||
public record Message(String text, List<String> attachments) {
|
||||
|
||||
public Message {
|
||||
if (!StringUtils.hasText(text)) {
|
||||
throw new IllegalArgumentException("Agent message text is required");
|
||||
}
|
||||
attachments = attachments == null ? List.of() : List.copyOf(attachments);
|
||||
}
|
||||
|
||||
public String getText() {
|
||||
return text;
|
||||
}
|
||||
|
||||
public List<String> getAttachments() {
|
||||
return attachments;
|
||||
}
|
||||
}
|
||||
}
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.conversation;
|
||||
|
||||
import java.util.List;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentTargetRef;
|
||||
|
||||
/** Safe owner-scoped WebUI request material used for explicit fresh-ID retry. */
|
||||
public record AgentRetryRequest(
|
||||
String conversationId,
|
||||
String messageId,
|
||||
String message,
|
||||
AgentTargetRef target,
|
||||
List<String> attachments,
|
||||
String preferredLanguage) {
|
||||
|
||||
public AgentRetryRequest {
|
||||
attachments = attachments == null ? List.of() : List.copyOf(attachments);
|
||||
}
|
||||
}
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.conversation;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import org.apache.hertzbeat.common.entity.agent.AgentRun;
|
||||
|
||||
/** Latest durable run fields used to project and order a session-list row. */
|
||||
public record AgentRunListProjection(String status, LocalDateTime gmtUpdate) {
|
||||
|
||||
public static AgentRunListProjection from(AgentRun run) {
|
||||
LocalDateTime update = run.getGmtUpdate();
|
||||
if (update == null) {
|
||||
update = run.getCompletedAt() != null ? run.getCompletedAt()
|
||||
: run.getStartedAt() != null ? run.getStartedAt() : run.getGmtCreate();
|
||||
}
|
||||
return new AgentRunListProjection(run.getStatus(), update);
|
||||
}
|
||||
}
|
||||
-233
@@ -1,233 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.conversation;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import org.apache.hertzbeat.ai.gateway.conversation.persistence.AgentRunDao;
|
||||
import org.apache.hertzbeat.ai.gateway.conversation.persistence.AgentSessionDao;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentTargetRef;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.UserInput;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.AgentRuntimeEntryType;
|
||||
import org.apache.hertzbeat.ai.gateway.text.GatewayText;
|
||||
import org.apache.hertzbeat.common.entity.agent.AgentRun;
|
||||
import org.apache.hertzbeat.common.entity.agent.AgentSession;
|
||||
import org.apache.hertzbeat.common.util.JsonUtil;
|
||||
import org.apache.hertzbeat.common.util.SnowFlakeIdGenerator;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* Default run ledger service for Agent Gateway.
|
||||
*/
|
||||
@Service
|
||||
public class AgentRunService {
|
||||
|
||||
private final AgentRunDao runDao;
|
||||
private final AgentSessionDao sessionDao;
|
||||
private final EntityManager entityManager;
|
||||
|
||||
public AgentRunService(AgentRunDao runDao, AgentSessionDao sessionDao, EntityManager entityManager) {
|
||||
this.runDao = runDao;
|
||||
this.sessionDao = sessionDao;
|
||||
this.entityManager = entityManager;
|
||||
}
|
||||
|
||||
public AgentRun createOrResumeRun(AgentSession session, UserInput userInput,
|
||||
AgentRuntimeEntryType entryType) {
|
||||
String messageId = userInput.getMessageId();
|
||||
Optional<AgentRun> existingRun = runDao.findBySessionIdAndMessageId(session.getId(), messageId);
|
||||
if (existingRun.isPresent()) {
|
||||
return existingRun.get();
|
||||
}
|
||||
AgentRun run = buildRun(session, userInput, messageId, entryType);
|
||||
try {
|
||||
return runDao.saveAndFlush(run);
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
entityManager.clear();
|
||||
return runDao.findBySessionIdAndMessageId(session.getId(), messageId)
|
||||
.orElseThrow(() -> e);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AgentRun markRunning(AgentRun run) {
|
||||
LocalDateTime transitionAt = LocalDateTime.now();
|
||||
run.setStatus(AgentRunStatus.RUNNING.name());
|
||||
run.setStartedAt(transitionAt);
|
||||
run.setCompletedAt(null);
|
||||
run.setErrorMessage(null);
|
||||
AgentRun saved = runDao.save(run);
|
||||
touchSession(run, transitionAt);
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AgentRun markSucceeded(AgentRun run, String resultSummary) {
|
||||
LocalDateTime transitionAt = LocalDateTime.now();
|
||||
run.setStatus(AgentRunStatus.SUCCEEDED.name());
|
||||
run.setResultSummary(GatewayText.redactSecrets(resultSummary));
|
||||
run.setCompletedAt(transitionAt);
|
||||
AgentRun saved = runDao.save(run);
|
||||
touchSession(run, transitionAt);
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AgentRun markFailed(AgentRun run, String errorMessage) {
|
||||
LocalDateTime transitionAt = LocalDateTime.now();
|
||||
run.setStatus(AgentRunStatus.FAILED.name());
|
||||
run.setErrorMessage(GatewayText.safeSummary(errorMessage, 1024));
|
||||
run.setCompletedAt(transitionAt);
|
||||
AgentRun saved = runDao.save(run);
|
||||
touchSession(run, transitionAt);
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AgentRun markCancelled(AgentRun run, String reason) {
|
||||
LocalDateTime transitionAt = LocalDateTime.now();
|
||||
String safeReason = GatewayText.safeSummary(reason, 1024);
|
||||
int updated = runDao.cancelIfActive(run.getId(),
|
||||
List.of(AgentRunStatus.CREATED.name(), AgentRunStatus.RUNNING.name()),
|
||||
AgentRunStatus.CANCELLED.name(), safeReason, transitionAt);
|
||||
if (updated == 1) {
|
||||
touchSession(run, transitionAt);
|
||||
}
|
||||
return runDao.findById(run.getId()).orElseThrow(
|
||||
() -> new IllegalStateException("Agent run disappeared during cancellation"));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AgentRun markRecoveryRequired(AgentRun run, String reason) {
|
||||
LocalDateTime transitionAt = LocalDateTime.now();
|
||||
run.setStatus(AgentRunStatus.RECOVERY_REQUIRED.name());
|
||||
run.setErrorMessage(GatewayText.safeSummary(reason, 1024));
|
||||
run.setCompletedAt(transitionAt);
|
||||
AgentRun saved = runDao.save(run);
|
||||
touchSession(run, transitionAt);
|
||||
return saved;
|
||||
}
|
||||
|
||||
public Optional<AgentRun> findRun(String runUid) {
|
||||
// External run lookup values may contain surrounding whitespace; normalize before the persistence query.
|
||||
String normalized = GatewayText.normalize(runUid);
|
||||
if (normalized == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return runDao.findByRunUid(normalized);
|
||||
}
|
||||
|
||||
public Optional<AgentRun> findLatestRun(Long sessionId) {
|
||||
return sessionId == null ? Optional.empty() : runDao.findTopBySessionIdOrderByIdDesc(sessionId);
|
||||
}
|
||||
|
||||
public Map<Long, AgentRunListProjection> findLatestRunProjections(Collection<Long> sessionIds) {
|
||||
if (sessionIds == null || sessionIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<Long, AgentRunListProjection> projections = new LinkedHashMap<>();
|
||||
for (AgentRun run : runDao.findLatestBySessionIds(sessionIds)) {
|
||||
if (run.getSessionId() != null && StringUtils.hasText(run.getStatus())) {
|
||||
projections.put(run.getSessionId(), AgentRunListProjection.from(run));
|
||||
}
|
||||
}
|
||||
return Map.copyOf(projections);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the complete target snapshot recorded for a run, with a legacy-column fallback.
|
||||
*/
|
||||
public static AgentTargetRef targetFromRun(AgentRun run) {
|
||||
if (run == null) {
|
||||
return null;
|
||||
}
|
||||
if (StringUtils.hasText(run.getTargetContextJson())) {
|
||||
AgentTargetRef target = JsonUtil.fromJson(run.getTargetContextJson(), AgentTargetRef.class);
|
||||
if (target == null) {
|
||||
throw new IllegalStateException("Agent run target context cannot be decoded");
|
||||
}
|
||||
return target;
|
||||
}
|
||||
if (run.getTargetMonitorId() == null
|
||||
&& run.getTargetAlertId() == null
|
||||
&& !StringUtils.hasText(run.getTargetCollector())) {
|
||||
return null;
|
||||
}
|
||||
return AgentTargetRef.builder()
|
||||
.monitorId(run.getTargetMonitorId())
|
||||
.alertId(run.getTargetAlertId())
|
||||
.collector(run.getTargetCollector())
|
||||
.build();
|
||||
}
|
||||
|
||||
public Optional<AgentRun> findCreatedRun(Long sessionId) {
|
||||
return runDao.findFirstBySessionIdAndStatusOrderByGmtCreateAsc(
|
||||
sessionId, AgentRunStatus.CREATED.name());
|
||||
}
|
||||
|
||||
public Optional<AgentRun> findRunningRun(Long sessionId) {
|
||||
return runDao.findFirstBySessionIdAndStatusOrderByGmtCreateAsc(
|
||||
sessionId, AgentRunStatus.RUNNING.name());
|
||||
}
|
||||
|
||||
public boolean hasActiveRun(Long sessionId) {
|
||||
return runDao.existsBySessionIdAndStatusIn(sessionId,
|
||||
List.of(AgentRunStatus.CREATED.name(), AgentRunStatus.RUNNING.name()));
|
||||
}
|
||||
|
||||
private void touchSession(AgentRun run, LocalDateTime transitionAt) {
|
||||
if (sessionDao.advanceGmtUpdate(run.getSessionId(), transitionAt) != 1) {
|
||||
throw new IllegalStateException("Agent run session does not exist");
|
||||
}
|
||||
}
|
||||
|
||||
private AgentRun buildRun(AgentSession session, UserInput userInput, String messageId,
|
||||
AgentRuntimeEntryType entryType) {
|
||||
AgentTargetRef target = userInput.getTarget();
|
||||
return AgentRun.builder()
|
||||
.runUid("run_" + SnowFlakeIdGenerator.generateId())
|
||||
.sessionId(session.getId())
|
||||
.messageId(messageId)
|
||||
.entryType(entryType.name())
|
||||
.targetMonitorId(target == null ? null : target.getMonitorId())
|
||||
.targetAlertId(target == null ? null : target.getAlertId())
|
||||
.targetCollector(target == null ? null : target.getCollector())
|
||||
.targetContextJson(targetJson(target))
|
||||
.status(AgentRunStatus.CREATED.name())
|
||||
.build();
|
||||
}
|
||||
|
||||
private static String targetJson(AgentTargetRef target) {
|
||||
if (target == null) {
|
||||
return null;
|
||||
}
|
||||
String json = JsonUtil.toJson(target);
|
||||
if (!StringUtils.hasText(json)) {
|
||||
throw new IllegalArgumentException("Agent run target context cannot be serialized");
|
||||
}
|
||||
return GatewayText.redactSecrets(json);
|
||||
}
|
||||
}
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.conversation;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentTargetRef;
|
||||
|
||||
/**
|
||||
* Owner-checked durable projection of one Agent Gateway run.
|
||||
*/
|
||||
public record AgentRunSnapshot(
|
||||
String runUid,
|
||||
String sessionUid,
|
||||
String messageId,
|
||||
String status,
|
||||
AgentTargetRef target,
|
||||
String result,
|
||||
String errorMessage,
|
||||
boolean replayAvailable,
|
||||
LocalDateTime startedAt,
|
||||
LocalDateTime completedAt,
|
||||
AgentRetryRequest retryRequest) {
|
||||
}
|
||||
-149
@@ -1,149 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.conversation;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import org.apache.hertzbeat.ai.gateway.application.AgentRunRequestFingerprint;
|
||||
import org.apache.hertzbeat.ai.gateway.application.AgentTargetCanonicalizationService;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayCommand;
|
||||
import org.apache.hertzbeat.ai.gateway.channel.core.ChannelId;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentRunRequestSnapshot;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.AgentApprovalHandling;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.AgentRuntimeEntryType;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.AgentGroundingEvidenceVerifier;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.TranscriptMessage;
|
||||
import org.apache.hertzbeat.common.entity.agent.AgentRun;
|
||||
import org.apache.hertzbeat.common.entity.agent.AgentSession;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* Builds the durable result/error projection used by replay and run queries.
|
||||
*/
|
||||
@Service
|
||||
public class AgentRunSnapshotService {
|
||||
|
||||
private static final String LEGACY_COMPLETION_SUMMARY = "Runtime completed.";
|
||||
|
||||
private final AgentTranscriptRecorder transcriptRecorder;
|
||||
private final AgentGroundingEvidenceVerifier groundingVerifier;
|
||||
|
||||
public AgentRunSnapshotService(AgentTranscriptRecorder transcriptRecorder,
|
||||
AgentGroundingEvidenceVerifier groundingVerifier) {
|
||||
this.transcriptRecorder = transcriptRecorder;
|
||||
this.groundingVerifier = groundingVerifier;
|
||||
}
|
||||
|
||||
public AgentRunSnapshot snapshot(AgentSession session, AgentRun run) {
|
||||
AgentRunStatus status = AgentRunStatus.valueOf(run.getStatus());
|
||||
String result = successfulResult(run, status);
|
||||
String error = terminalError(run, status, result);
|
||||
boolean replayAvailable = switch (status) {
|
||||
case SUCCEEDED -> StringUtils.hasText(result);
|
||||
case FAILED, CANCELLED, RECOVERY_REQUIRED -> StringUtils.hasText(error);
|
||||
case CREATED, RUNNING -> true;
|
||||
};
|
||||
return new AgentRunSnapshot(
|
||||
run.getRunUid(),
|
||||
session.getSessionUid(),
|
||||
run.getMessageId(),
|
||||
run.getStatus(),
|
||||
AgentRunService.targetFromRun(run),
|
||||
result,
|
||||
error,
|
||||
replayAvailable,
|
||||
run.getStartedAt(),
|
||||
run.getCompletedAt(),
|
||||
retryRequest(session, run, status));
|
||||
}
|
||||
|
||||
private AgentRetryRequest retryRequest(AgentSession session, AgentRun run, AgentRunStatus status) {
|
||||
if (status == AgentRunStatus.SUCCEEDED || status == AgentRunStatus.RECOVERY_REQUIRED
|
||||
|| !Objects.equals(session.getId(), run.getSessionId())
|
||||
|| !Objects.equals(ChannelId.WEB_UI.id(), session.getChannel())
|
||||
|| !Objects.equals(AgentRuntimeEntryType.USER_INPUT.name(), session.getOriginEntryType())
|
||||
|| !Objects.equals(AgentRuntimeEntryType.USER_INPUT.name(), run.getEntryType())) {
|
||||
return null;
|
||||
}
|
||||
Optional<TranscriptMessage> marker = transcriptRecorder.findUniqueRunRequestMessage(run.getId());
|
||||
if (marker.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
TranscriptMessage message = marker.get();
|
||||
AgentRunRequestSnapshot request = message.getRequestSnapshot();
|
||||
if (message.getRole() != TranscriptMessage.TranscriptRole.USER || message.isPruned() || request == null
|
||||
|| !Objects.equals(AgentRunRequestSnapshot.VERSION, request.version())
|
||||
|| !Objects.equals(AgentRunRequestFingerprint.VERSION, message.getRequestFingerprintVersion())
|
||||
|| !Objects.equals(session.getConversationId(), request.conversationId())
|
||||
|| !Objects.equals(run.getMessageId(), request.messageId())
|
||||
|| !Objects.equals(run.getEntryType(), request.entryType())
|
||||
|| !Objects.equals(AgentRunService.targetFromRun(run), request.target())
|
||||
|| request.alertIncident() != null
|
||||
|| !Objects.equals(message.text(), request.message())
|
||||
|| !Objects.equals(AgentApprovalHandling.WAIT_FOR_DECISION.name(), request.approvalHandling())
|
||||
|| !Objects.equals(GatewayCommand.ReplyMode.STREAM.name(), request.replyMode())
|
||||
|| !StringUtils.hasText(request.message())
|
||||
|| !StringUtils.hasText(request.preferredLanguage())
|
||||
|| request.attachments().stream().anyMatch(value -> !StringUtils.hasText(value))) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (!Objects.equals(message.getRequestFingerprint(), AgentRunRequestFingerprint.from(request))) {
|
||||
return null;
|
||||
}
|
||||
} catch (RuntimeException ignored) {
|
||||
return null;
|
||||
}
|
||||
return new AgentRetryRequest(request.conversationId(), request.messageId(), request.message(),
|
||||
AgentTargetCanonicalizationService.retrySourceIntent(request.target()),
|
||||
request.attachments(), request.preferredLanguage());
|
||||
}
|
||||
|
||||
private String terminalError(AgentRun run, AgentRunStatus status, String result) {
|
||||
if (status == AgentRunStatus.SUCCEEDED && !StringUtils.hasText(result)) {
|
||||
return "Agent run result is no longer available.";
|
||||
}
|
||||
if ((status == AgentRunStatus.FAILED || status == AgentRunStatus.CANCELLED
|
||||
|| status == AgentRunStatus.RECOVERY_REQUIRED)
|
||||
&& !StringUtils.hasText(run.getErrorMessage())) {
|
||||
return "Agent run terminal reason is no longer available.";
|
||||
}
|
||||
return status == AgentRunStatus.FAILED || status == AgentRunStatus.CANCELLED
|
||||
|| status == AgentRunStatus.RECOVERY_REQUIRED
|
||||
? run.getErrorMessage() : null;
|
||||
}
|
||||
|
||||
private String successfulResult(AgentRun run, AgentRunStatus status) {
|
||||
if (status != AgentRunStatus.SUCCEEDED) {
|
||||
return null;
|
||||
}
|
||||
if (!groundingVerifier.hasDurableGrounding(run, AgentRunService.targetFromRun(run))) {
|
||||
return null;
|
||||
}
|
||||
if (StringUtils.hasText(run.getResultSummary())
|
||||
&& !LEGACY_COMPLETION_SUMMARY.equals(run.getResultSummary())) {
|
||||
return run.getResultSummary();
|
||||
}
|
||||
return transcriptRecorder.findRunFinalAssistantMessage(run.getId())
|
||||
.filter(message -> message.toolCalls().isEmpty())
|
||||
.map(TranscriptMessage::text)
|
||||
.filter(StringUtils::hasText)
|
||||
.orElse(null);
|
||||
}
|
||||
}
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.conversation;
|
||||
|
||||
/**
|
||||
* Agent run lifecycle states.
|
||||
*/
|
||||
public enum AgentRunStatus {
|
||||
CREATED,
|
||||
RUNNING,
|
||||
SUCCEEDED,
|
||||
FAILED,
|
||||
CANCELLED,
|
||||
RECOVERY_REQUIRED
|
||||
}
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.conversation;
|
||||
|
||||
import java.util.List;
|
||||
import org.apache.hertzbeat.ai.gateway.identity.AgentActor;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.GatewayEnvelope;
|
||||
import org.apache.hertzbeat.ai.gateway.text.GatewayText;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Builds deterministic session keys for Agent Gateway user inputs.
|
||||
*/
|
||||
@Component
|
||||
public class AgentSessionKeyBuilder {
|
||||
|
||||
private static final String KEY_VERSION = "v2:";
|
||||
|
||||
/**
|
||||
* Build a stable session key from transport metadata and user input identity.
|
||||
*/
|
||||
public String build(GatewayEnvelope envelope, String conversationId) {
|
||||
AgentActor actor = envelope.getActor();
|
||||
String canonical = String.join("", List.of(
|
||||
component("channel", envelope.getChannelId()),
|
||||
component("workspace", envelope.getWorkspaceId()),
|
||||
component("actorType", actor.getType()),
|
||||
component("actorId", actor.getId()),
|
||||
component("conversation", conversationId)
|
||||
));
|
||||
return KEY_VERSION + GatewayText.sha256(canonical);
|
||||
}
|
||||
|
||||
private String component(String name, Object source) {
|
||||
// Channel identity fields may contain surrounding whitespace; the session-key boundary canonicalizes them.
|
||||
String normalized = source == null ? null : GatewayText.normalize(String.valueOf(source));
|
||||
String value = normalized == null ? "-" : normalized;
|
||||
return name + "=" + value.length() + ":" + value + ";";
|
||||
}
|
||||
}
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.conversation;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import org.apache.hertzbeat.common.entity.agent.AgentSession;
|
||||
|
||||
/**
|
||||
* Session list projection whose status describes the latest durable run rather than session ownership lifecycle.
|
||||
*/
|
||||
public record AgentSessionListItem(
|
||||
Long id,
|
||||
String sessionUid,
|
||||
String conversationId,
|
||||
String status,
|
||||
String title,
|
||||
LocalDateTime gmtCreate,
|
||||
LocalDateTime gmtUpdate) {
|
||||
|
||||
public static AgentSessionListItem from(AgentSession session, AgentRunListProjection latestRun) {
|
||||
String projectedStatus = latestRun == null ? "NO_RUN" : latestRun.status();
|
||||
LocalDateTime projectedUpdate = session.getGmtUpdate();
|
||||
if (latestRun != null && latestRun.gmtUpdate() != null
|
||||
&& (projectedUpdate == null || latestRun.gmtUpdate().isAfter(projectedUpdate))) {
|
||||
projectedUpdate = latestRun.gmtUpdate();
|
||||
}
|
||||
return new AgentSessionListItem(
|
||||
session.getId(),
|
||||
session.getSessionUid(),
|
||||
session.getConversationId(),
|
||||
projectedStatus,
|
||||
session.getTitle(),
|
||||
session.getGmtCreate(),
|
||||
projectedUpdate);
|
||||
}
|
||||
}
|
||||
-392
@@ -1,392 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.conversation;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import org.apache.hertzbeat.ai.gateway.conversation.persistence.AgentSessionDao;
|
||||
import org.apache.hertzbeat.ai.gateway.conversation.persistence.AgentTranscriptEntryDao;
|
||||
import org.apache.hertzbeat.ai.gateway.identity.AgentActor;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.GatewayEnvelope;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.UserInput;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.UserInput.Message;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.AgentRuntimeHistoryWindow;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.AgentRuntimeEntryType;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.TranscriptMessage;
|
||||
import org.apache.hertzbeat.ai.gateway.identity.ActorSupport;
|
||||
import org.apache.hertzbeat.ai.gateway.text.GatewayText;
|
||||
import org.apache.hertzbeat.common.entity.agent.AgentSession;
|
||||
import org.apache.hertzbeat.common.entity.agent.AgentSessionStatus;
|
||||
import org.apache.hertzbeat.common.entity.agent.AgentTranscriptEntry;
|
||||
import org.apache.hertzbeat.common.util.JsonUtil;
|
||||
import org.apache.hertzbeat.common.util.SnowFlakeIdGenerator;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* Default session and transcript service for Agent Gateway.
|
||||
*/
|
||||
@Service
|
||||
public class AgentSessionService {
|
||||
|
||||
private static final int TRANSCRIPT_QUERY_PAGE_SIZE = 100;
|
||||
private static final int TRANSCRIPT_ROLE_LIMIT = 32;
|
||||
|
||||
private final AgentSessionDao sessionDao;
|
||||
private final AgentTranscriptEntryDao transcriptEntryDao;
|
||||
private final AgentSessionKeyBuilder sessionKeyBuilder;
|
||||
private final EntityManager entityManager;
|
||||
|
||||
public AgentSessionService(AgentSessionDao sessionDao, AgentTranscriptEntryDao transcriptEntryDao,
|
||||
AgentSessionKeyBuilder sessionKeyBuilder, EntityManager entityManager) {
|
||||
this.sessionDao = sessionDao;
|
||||
this.transcriptEntryDao = transcriptEntryDao;
|
||||
this.sessionKeyBuilder = sessionKeyBuilder;
|
||||
this.entityManager = entityManager;
|
||||
}
|
||||
|
||||
public AgentSession findOrCreateSession(GatewayEnvelope envelope, UserInput userInput,
|
||||
AgentRuntimeEntryType originEntryType) {
|
||||
AgentActor actor = envelope.getActor();
|
||||
String sessionKey = sessionKeyBuilder.build(envelope, userInput.getConversationId());
|
||||
Optional<AgentSession> existed = sessionDao.findBySessionKey(sessionKey);
|
||||
if (existed.isPresent()) {
|
||||
return existed.get();
|
||||
}
|
||||
Message message = userInput.getMessage();
|
||||
AgentSession session = AgentSession.builder()
|
||||
.sessionUid("ags_" + SnowFlakeIdGenerator.generateId())
|
||||
.sessionKey(sessionKey)
|
||||
.workspaceId(envelope.getWorkspaceId())
|
||||
.channel(envelope.getChannelId())
|
||||
.originEntryType(originEntryType.name())
|
||||
.conversationId(userInput.getConversationId())
|
||||
.actorType(actor.getType())
|
||||
.actorId(actor.getId())
|
||||
.actorRoles(ActorSupport.rolesJson(actor))
|
||||
.status(AgentSessionStatus.ACTIVE)
|
||||
.title(GatewayText.safeSummary(message.getText(), 128))
|
||||
.build();
|
||||
try {
|
||||
return sessionDao.saveAndFlush(session);
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
entityManager.clear();
|
||||
return sessionDao.findBySessionKey(sessionKey).orElseThrow(() -> e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Read-only session probe used before target canonicalization and admission locking. */
|
||||
public Optional<AgentSession> findSession(GatewayEnvelope envelope, String conversationId) {
|
||||
return sessionDao.findBySessionKey(sessionKeyBuilder.build(envelope, conversationId));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AgentTranscriptEntry recordTranscriptEntry(AgentTranscriptEntry entry) {
|
||||
if (entry == null) {
|
||||
throw new IllegalArgumentException("Transcript entry must not be null");
|
||||
}
|
||||
if (entry.getSessionId() == null) {
|
||||
throw new IllegalArgumentException("Transcript session id must not be null");
|
||||
}
|
||||
String rawPayload = entry.getPayloadJson();
|
||||
if (!StringUtils.hasText(rawPayload)) {
|
||||
throw new IllegalArgumentException("Transcript payload JSON must not be blank");
|
||||
}
|
||||
// The role is the persisted discriminator used by checkpoint queries and cannot be inferred from SQL.
|
||||
if (!StringUtils.hasText(entry.getMessageRole())) {
|
||||
throw new IllegalArgumentException("Transcript message role must not be blank");
|
||||
}
|
||||
entry.setSessionSequence(nextSessionSequence(entry.getSessionId()));
|
||||
entry.setPayloadJson(GatewayText.redactSecrets(rawPayload));
|
||||
entry.setMessageRole(GatewayText.requireBounded(
|
||||
entry.getMessageRole(), TRANSCRIPT_ROLE_LIMIT, "Transcript message role"));
|
||||
return transcriptEntryDao.save(entry);
|
||||
}
|
||||
|
||||
public Optional<AgentSession> findSession(String sessionId) {
|
||||
// External session lookup values may contain surrounding whitespace; normalize before numeric/UID lookup.
|
||||
String normalized = GatewayText.normalize(sessionId);
|
||||
if (normalized == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (normalized.chars().allMatch(Character::isDigit)) {
|
||||
return sessionDao.findById(Long.parseLong(normalized));
|
||||
}
|
||||
return sessionDao.findBySessionUid(normalized);
|
||||
}
|
||||
|
||||
public Optional<AgentSession> findOwnedSession(
|
||||
String sessionId, GatewayEnvelope envelope, AgentRuntimeEntryType originEntryType) {
|
||||
AgentActor actor = envelope.getActor();
|
||||
if (!ActorSupport.hasIdentity(actor)) {
|
||||
throw new IllegalArgumentException("Session query actor is required");
|
||||
}
|
||||
// External session lookup values may contain surrounding whitespace; normalize before owner-scoped lookup.
|
||||
String normalized = GatewayText.normalize(sessionId);
|
||||
if (normalized == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (normalized.chars().allMatch(Character::isDigit)) {
|
||||
return sessionDao.findByIdAndWorkspaceIdAndChannelAndActorTypeAndActorIdAndOriginEntryType(
|
||||
Long.parseLong(normalized), envelope.getWorkspaceId(), envelope.getChannelId(), actor.getType(),
|
||||
actor.getId(), originEntryType.name());
|
||||
}
|
||||
return sessionDao.findBySessionUidAndWorkspaceIdAndChannelAndActorTypeAndActorIdAndOriginEntryType(
|
||||
normalized, envelope.getWorkspaceId(), envelope.getChannelId(), actor.getType(), actor.getId(),
|
||||
originEntryType.name());
|
||||
}
|
||||
|
||||
public Page<AgentSession> findSessions(
|
||||
GatewayEnvelope envelope, AgentRuntimeEntryType originEntryType, String title, Pageable pageable) {
|
||||
AgentActor actor = envelope.getActor();
|
||||
if (!ActorSupport.hasIdentity(actor)) {
|
||||
throw new IllegalArgumentException("Session query actor is required");
|
||||
}
|
||||
if (!StringUtils.hasText(title)) {
|
||||
return sessionDao.findByWorkspaceIdAndChannelAndActorTypeAndActorIdAndOriginEntryTypeOrderByGmtUpdateDesc(
|
||||
envelope.getWorkspaceId(), envelope.getChannelId(), actor.getType(), actor.getId(),
|
||||
originEntryType.name(), pageable);
|
||||
}
|
||||
return sessionDao
|
||||
.findByWorkspaceIdAndChannelAndActorTypeAndActorIdAndOriginEntryTypeAndTitleContainingIgnoreCaseOrderByGmtUpdateDesc(
|
||||
envelope.getWorkspaceId(), envelope.getChannelId(), actor.getType(), actor.getId(),
|
||||
originEntryType.name(), title, pageable);
|
||||
}
|
||||
|
||||
public Page<AgentTranscriptEntry> findTranscriptEntries(Long sessionId, Pageable pageable) {
|
||||
return transcriptEntryDao.findBySessionIdOrderBySessionSequenceAsc(sessionId, pageable);
|
||||
}
|
||||
|
||||
public Page<AgentTranscriptEntry> findConversationTranscriptEntries(Long sessionId, Pageable pageable) {
|
||||
return transcriptEntryDao.findBySessionIdAndMessageRoleInOrderBySessionSequenceDesc(
|
||||
sessionId,
|
||||
List.of(TranscriptMessage.TranscriptRole.USER.wireValue(),
|
||||
TranscriptMessage.TranscriptRole.ASSISTANT.wireValue()),
|
||||
pageable);
|
||||
}
|
||||
|
||||
public Optional<TranscriptMessage> findFirstRunTranscriptMessage(Long runId, TranscriptMessage.TranscriptRole role) {
|
||||
if (runId == null || role == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return transcriptEntryDao.findFirstByRunIdAndMessageRoleOrderBySessionSequenceAsc(runId, role.wireValue())
|
||||
.map(this::transcriptMessage)
|
||||
.filter(Objects::nonNull);
|
||||
}
|
||||
|
||||
public Optional<TranscriptMessage> findUniqueRunTranscriptMessage(
|
||||
Long runId, TranscriptMessage.TranscriptRole role) {
|
||||
if (runId == null || role == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
List<AgentTranscriptEntry> entries = transcriptEntryDao
|
||||
.findTop2ByRunIdAndMessageRoleOrderBySessionSequenceAsc(runId, role.wireValue());
|
||||
if (entries.size() != 1) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.ofNullable(transcriptMessage(entries.getFirst()));
|
||||
}
|
||||
|
||||
public Optional<TranscriptMessage> findLatestRunTranscriptMessage(Long runId,
|
||||
TranscriptMessage.TranscriptRole role) {
|
||||
if (runId == null || role == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return transcriptEntryDao.findTopByRunIdAndMessageRoleOrderBySessionSequenceDesc(runId, role.wireValue())
|
||||
.map(this::transcriptMessage)
|
||||
.filter(Objects::nonNull);
|
||||
}
|
||||
|
||||
public List<TranscriptMessage> findRunTranscriptMessages(Long runId, TranscriptMessage.TranscriptRole role) {
|
||||
if (runId == null || role == null) {
|
||||
return List.of();
|
||||
}
|
||||
return transcriptEntryDao.findByRunIdAndMessageRoleOrderBySessionSequenceAsc(runId, role.wireValue())
|
||||
.stream()
|
||||
.map(this::transcriptMessage)
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public List<TranscriptMessage> findRecentTranscriptMessages(Long sessionId) {
|
||||
if (sessionId == null) {
|
||||
return List.of();
|
||||
}
|
||||
Optional<TranscriptCheckpointSource> latestCheckpoint = latestCompactionCheckpoint(sessionId);
|
||||
List<TranscriptMessage> selected = latestCheckpoint
|
||||
.map(checkpoint -> transcriptMessagesSinceCheckpoint(sessionId, checkpoint))
|
||||
.orElseGet(() -> recentTranscriptMessages(sessionId));
|
||||
if (selected.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return AgentRuntimeHistoryWindow.replayWindow(selected);
|
||||
}
|
||||
|
||||
private List<TranscriptMessage> recentTranscriptMessages(Long sessionId) {
|
||||
List<AgentTranscriptEntry> recentEntries = recentTranscriptEntries(sessionId);
|
||||
if (recentEntries.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
Collections.reverse(recentEntries);
|
||||
return transcriptMessages(recentEntries, false);
|
||||
}
|
||||
|
||||
private Optional<TranscriptCheckpointSource> latestCompactionCheckpoint(Long sessionId) {
|
||||
return transcriptEntryDao.findTopBySessionIdAndMessageRoleOrderBySessionSequenceDesc(
|
||||
sessionId, TranscriptMessage.TranscriptRole.COMPACTION_SUMMARY.wireValue())
|
||||
.flatMap(entry -> {
|
||||
TranscriptMessage message = transcriptMessage(entry);
|
||||
if (message == null
|
||||
|| message.getRole() != TranscriptMessage.TranscriptRole.COMPACTION_SUMMARY
|
||||
|| !message.hasReplayContent()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(new TranscriptCheckpointSource(entry, message));
|
||||
});
|
||||
}
|
||||
|
||||
private List<TranscriptMessage> transcriptMessagesSinceCheckpoint(Long sessionId,
|
||||
TranscriptCheckpointSource checkpoint) {
|
||||
List<TranscriptMessage> messages = new ArrayList<>();
|
||||
messages.add(checkpoint.message());
|
||||
Long firstKeptSessionSequence = checkpoint.message().compactionFirstKeptSessionSequence();
|
||||
if (firstKeptSessionSequence == null) {
|
||||
Long checkpointSessionSequence = checkpoint.entry().getSessionSequence();
|
||||
firstKeptSessionSequence = checkpointSessionSequence == null ? 1L : checkpointSessionSequence + 1L;
|
||||
}
|
||||
messages.addAll(transcriptMessages(transcriptEntriesFrom(sessionId, firstKeptSessionSequence), true));
|
||||
return messages;
|
||||
}
|
||||
|
||||
private List<AgentTranscriptEntry> recentTranscriptEntries(Long sessionId) {
|
||||
List<AgentTranscriptEntry> entries = new ArrayList<>();
|
||||
for (int page = 0; ; page++) {
|
||||
List<AgentTranscriptEntry> pageEntries =
|
||||
transcriptEntryDao.findBySessionIdOrderBySessionSequenceDesc(
|
||||
sessionId, PageRequest.of(page, TRANSCRIPT_QUERY_PAGE_SIZE));
|
||||
if (pageEntries.isEmpty()) {
|
||||
break;
|
||||
}
|
||||
entries.addAll(pageEntries);
|
||||
if (pageEntries.size() < TRANSCRIPT_QUERY_PAGE_SIZE) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
private List<AgentTranscriptEntry> transcriptEntriesFrom(Long sessionId, Long firstSessionSequence) {
|
||||
List<AgentTranscriptEntry> entries = new ArrayList<>();
|
||||
Long start = firstSessionSequence == null ? 1L : Math.max(1L, firstSessionSequence);
|
||||
for (int page = 0; ; page++) {
|
||||
List<AgentTranscriptEntry> pageEntries =
|
||||
transcriptEntryDao
|
||||
.findBySessionIdAndSessionSequenceGreaterThanEqualOrderBySessionSequenceAsc(
|
||||
sessionId, start,
|
||||
PageRequest.of(page, TRANSCRIPT_QUERY_PAGE_SIZE));
|
||||
if (pageEntries.isEmpty()) {
|
||||
break;
|
||||
}
|
||||
entries.addAll(pageEntries);
|
||||
if (pageEntries.size() < TRANSCRIPT_QUERY_PAGE_SIZE) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
private List<TranscriptMessage> transcriptMessages(List<AgentTranscriptEntry> entries,
|
||||
boolean skipCompactionSummaries) {
|
||||
List<TranscriptMessage> messages = new ArrayList<>(entries.size());
|
||||
for (AgentTranscriptEntry entry : entries) {
|
||||
TranscriptMessage message = transcriptMessage(entry);
|
||||
if (skipCompactionSummaries
|
||||
&& message != null
|
||||
&& message.getRole() == TranscriptMessage.TranscriptRole.COMPACTION_SUMMARY) {
|
||||
continue;
|
||||
}
|
||||
if (!isEmptyTranscriptMessage(message)) {
|
||||
messages.add(message);
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void persistCompactionCheckpoint(Long sessionId,
|
||||
AgentRuntimeHistoryWindow.CompactionCheckpoint checkpoint) {
|
||||
if (checkpoint == null || checkpoint.message() == null
|
||||
|| checkpoint.summarizedThroughSessionSequence() == null
|
||||
|| checkpoint.firstKeptSessionSequence() == null) {
|
||||
return;
|
||||
}
|
||||
Long latestSummarizedThrough = latestCompactionCheckpoint(sessionId)
|
||||
.map(TranscriptCheckpointSource::message)
|
||||
.map(TranscriptMessage::compactionSummarizedThroughSessionSequence)
|
||||
.orElse(null);
|
||||
if (latestSummarizedThrough != null
|
||||
&& checkpoint.summarizedThroughSessionSequence() <= latestSummarizedThrough) {
|
||||
return;
|
||||
}
|
||||
recordTranscriptEntry(AgentTranscriptEntry.builder()
|
||||
.sessionId(sessionId)
|
||||
.payloadJson(JsonUtil.toJson(checkpoint.message()))
|
||||
.messageRole(TranscriptMessage.TranscriptRole.COMPACTION_SUMMARY.wireValue())
|
||||
.build());
|
||||
}
|
||||
|
||||
private long nextSessionSequence(Long sessionId) {
|
||||
AgentSession session = sessionDao.findFirstById(sessionId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Transcript session does not exist: " + sessionId));
|
||||
long nextSequence = Optional.ofNullable(session.getTranscriptSequence()).orElse(0L) + 1L;
|
||||
session.setTranscriptSequence(nextSequence);
|
||||
return nextSequence;
|
||||
}
|
||||
|
||||
private TranscriptMessage transcriptMessage(AgentTranscriptEntry entry) {
|
||||
if (entry == null) {
|
||||
return null;
|
||||
}
|
||||
TranscriptMessage message = JsonUtil.fromJsonQuietly(entry.getPayloadJson(), TranscriptMessage.class);
|
||||
if (message == null) {
|
||||
return null;
|
||||
}
|
||||
return message.toBuilder()
|
||||
.sessionSequence(entry.getSessionSequence())
|
||||
.build();
|
||||
}
|
||||
|
||||
private boolean isEmptyTranscriptMessage(TranscriptMessage message) {
|
||||
return message == null
|
||||
|| message.getRole() == null && !message.hasReplayContent();
|
||||
}
|
||||
|
||||
private record TranscriptCheckpointSource(AgentTranscriptEntry entry, TranscriptMessage message) {
|
||||
}
|
||||
|
||||
}
|
||||
-321
@@ -1,321 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.conversation;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentAlertIncidentContext;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentLogRef;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentRunRequestSnapshot;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentSignalRef;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentServiceRef;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentTargetAuthority;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentTargetRef;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentTopologyRef;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentTraceRef;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.UserInput;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.AgentRuntimeHistoryWindow;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.AgentRuntimeTextSanitizer;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.TranscriptContent;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.TranscriptMessage;
|
||||
import org.apache.hertzbeat.ai.gateway.text.GatewaySecretRedactor;
|
||||
import org.apache.hertzbeat.ai.gateway.text.GatewayText;
|
||||
import org.apache.hertzbeat.common.entity.agent.AgentRun;
|
||||
import org.apache.hertzbeat.common.entity.agent.AgentSession;
|
||||
import org.apache.hertzbeat.common.entity.agent.AgentTranscriptEntry;
|
||||
import org.apache.hertzbeat.common.util.JsonUtil;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Records and rehydrates Agent Gateway transcript messages.
|
||||
*/
|
||||
@Service
|
||||
public class AgentTranscriptRecorder {
|
||||
|
||||
private static final int TRANSCRIPT_TOOL_NAME_LIMIT = 128;
|
||||
private static final int TRANSCRIPT_TOOL_CALL_ID_LIMIT = 128;
|
||||
private static final int TRANSCRIPT_TOOL_ERROR_LIMIT = 2048;
|
||||
private static final int MYSQL_TEXT_MAX_UTF8_BYTES = 65535;
|
||||
private static final String REDACTED_REQUEST_VALUE = "[REDACTED]";
|
||||
|
||||
private final AgentSessionService sessionService;
|
||||
|
||||
public AgentTranscriptRecorder(AgentSessionService sessionService) {
|
||||
// Transcript persistence is required for every recorder operation.
|
||||
this.sessionService = Objects.requireNonNull(sessionService, "sessionService must not be null");
|
||||
}
|
||||
|
||||
public List<TranscriptMessage> chatHistory(Long sessionId) {
|
||||
return List.copyOf(sessionService.findRecentTranscriptMessages(sessionId));
|
||||
}
|
||||
|
||||
public void recordCompactionCheckpoint(AgentSession session,
|
||||
AgentRuntimeHistoryWindow.CompactionCheckpoint checkpoint) {
|
||||
sessionService.persistCompactionCheckpoint(session.getId(), checkpoint);
|
||||
}
|
||||
|
||||
public AgentTranscriptEntry recordUserTranscriptEntry(AgentSession session, AgentRun run, UserInput userInput) {
|
||||
return recordUserTranscriptEntry(session, run, userInput, null, null);
|
||||
}
|
||||
|
||||
public AgentTranscriptEntry recordUserTranscriptEntry(AgentSession session, AgentRun run, UserInput userInput,
|
||||
String fingerprintVersion, String fingerprint) {
|
||||
return recordUserTranscriptEntry(session, run, userInput, fingerprintVersion, fingerprint, null);
|
||||
}
|
||||
|
||||
public AgentTranscriptEntry recordUserTranscriptEntry(AgentSession session, AgentRun run, UserInput userInput,
|
||||
String fingerprintVersion, String fingerprint,
|
||||
AgentRunRequestSnapshot requestSnapshot) {
|
||||
TranscriptMessage message = validateTranscriptMessage(TranscriptMessage.userText(
|
||||
userInput.getMessage().getText(), fingerprintVersion, fingerprint,
|
||||
safeRequestSnapshot(requestSnapshot)));
|
||||
if (message.getRequestSnapshot() != null && exceedsTranscriptPayloadBudget(message)) {
|
||||
message = message.toBuilder().requestSnapshot(null).build();
|
||||
}
|
||||
return persistTranscriptMessage(session, run, message);
|
||||
}
|
||||
|
||||
public Optional<TranscriptMessage> findRunRequestMessage(Long runId) {
|
||||
return sessionService.findFirstRunTranscriptMessage(runId, TranscriptMessage.TranscriptRole.USER);
|
||||
}
|
||||
|
||||
public Optional<TranscriptMessage> findUniqueRunRequestMessage(Long runId) {
|
||||
return sessionService.findUniqueRunTranscriptMessage(runId, TranscriptMessage.TranscriptRole.USER);
|
||||
}
|
||||
|
||||
public Optional<TranscriptMessage> findRunFinalAssistantMessage(Long runId) {
|
||||
return sessionService.findLatestRunTranscriptMessage(runId, TranscriptMessage.TranscriptRole.ASSISTANT);
|
||||
}
|
||||
|
||||
public List<TranscriptMessage> findRunGroundingMessages(Long runId) {
|
||||
return sessionService.findRunTranscriptMessages(runId, TranscriptMessage.TranscriptRole.TOOL_RESULT);
|
||||
}
|
||||
|
||||
/** Append a runtime message immediately using the owning session's durable sequence. */
|
||||
public AgentTranscriptEntry recordRunMessage(AgentSession session, AgentRun run, TranscriptMessage message) {
|
||||
return recordTranscriptMessage(session, run, message);
|
||||
}
|
||||
|
||||
private AgentTranscriptEntry recordTranscriptMessage(AgentSession session, AgentRun run,
|
||||
TranscriptMessage message) {
|
||||
return persistTranscriptMessage(session, run, validateTranscriptMessage(message));
|
||||
}
|
||||
|
||||
private AgentTranscriptEntry persistTranscriptMessage(AgentSession session, AgentRun run,
|
||||
TranscriptMessage validatedMessage) {
|
||||
return sessionService.recordTranscriptEntry(AgentTranscriptEntry.builder()
|
||||
.sessionId(session.getId())
|
||||
.runId(run.getId())
|
||||
.payloadJson(toJson(validatedMessage))
|
||||
.messageRole(validatedMessage.getRole().wireValue())
|
||||
.build());
|
||||
}
|
||||
|
||||
private AgentRunRequestSnapshot safeRequestSnapshot(AgentRunRequestSnapshot request) {
|
||||
try {
|
||||
return validateRequestSnapshot(request);
|
||||
} catch (RuntimeException ignored) {
|
||||
// Recovery metadata is optional and must never prevent the authoritative USER entry.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean exceedsTranscriptPayloadBudget(TranscriptMessage message) {
|
||||
String finalPayload = GatewayText.redactSecrets(toJson(message));
|
||||
return finalPayload.getBytes(StandardCharsets.UTF_8).length > MYSQL_TEXT_MAX_UTF8_BYTES;
|
||||
}
|
||||
|
||||
private TranscriptMessage validateTranscriptMessage(TranscriptMessage message) {
|
||||
// Every persisted payload must have a supported role so checkpoint queries and replay remain deterministic.
|
||||
if (message.getRole() == null) {
|
||||
throw new IllegalArgumentException("Transcript message role must not be null");
|
||||
}
|
||||
return message.toBuilder()
|
||||
.toolCallId(GatewayText.requireBounded(
|
||||
message.getToolCallId(), TRANSCRIPT_TOOL_CALL_ID_LIMIT,
|
||||
"Transcript model tool-call id"))
|
||||
.toolName(GatewayText.requireBounded(
|
||||
message.getToolName(), TRANSCRIPT_TOOL_NAME_LIMIT, "Transcript tool name"))
|
||||
.errorMessage(GatewayText.requireBounded(
|
||||
AgentRuntimeTextSanitizer.sanitizeAndLimit(
|
||||
message.getErrorMessage(), TRANSCRIPT_TOOL_ERROR_LIMIT),
|
||||
TRANSCRIPT_TOOL_ERROR_LIMIT,
|
||||
"Transcript tool error"))
|
||||
.content(validateTranscriptContent(message.getContent()))
|
||||
.build();
|
||||
}
|
||||
|
||||
private AgentRunRequestSnapshot validateRequestSnapshot(AgentRunRequestSnapshot request) {
|
||||
if (request == null) {
|
||||
return null;
|
||||
}
|
||||
List<String> attachments = request.attachments().stream()
|
||||
.map(value -> GatewayText.requireBounded(value, 8192, "Agent request attachment"))
|
||||
.toList();
|
||||
AgentRunRequestSnapshot bounded = request.toBuilder()
|
||||
.version(GatewayText.requireBounded(request.version(), 16, "Agent request snapshot version"))
|
||||
.conversationId(GatewayText.requireBounded(
|
||||
request.conversationId(), 256, "Agent request conversation id"))
|
||||
.messageId(GatewayText.requireBounded(request.messageId(), 128, "Agent request message id"))
|
||||
.entryType(GatewayText.requireBounded(request.entryType(), 32, "Agent request entry type"))
|
||||
.message(GatewayText.requireBounded(request.message(), 8192, "Agent request message"))
|
||||
.preferredLanguage(GatewayText.requireBounded(
|
||||
request.preferredLanguage(), 128, "Agent request preferred language"))
|
||||
.approvalHandling(GatewayText.requireBounded(
|
||||
request.approvalHandling(), 32, "Agent request approval handling"))
|
||||
.replyMode(GatewayText.requireBounded(request.replyMode(), 32, "Agent request reply mode"))
|
||||
.attachments(attachments)
|
||||
.build();
|
||||
return bounded.toBuilder()
|
||||
.conversationId(redactRequestValue(bounded.conversationId()))
|
||||
.messageId(redactRequestValue(bounded.messageId()))
|
||||
.entryType(redactRequestValue(bounded.entryType()))
|
||||
.target(redactTarget(bounded.target()))
|
||||
.alertIncident(redactIncident(bounded.alertIncident()))
|
||||
.message(redactRequestValue(bounded.message()))
|
||||
.attachments(bounded.attachments().stream().map(this::redactRequestValue).toList())
|
||||
.preferredLanguage(redactRequestValue(bounded.preferredLanguage()))
|
||||
.approvalHandling(redactRequestValue(bounded.approvalHandling()))
|
||||
.replyMode(redactRequestValue(bounded.replyMode()))
|
||||
.build();
|
||||
}
|
||||
|
||||
private AgentAlertIncidentContext redactIncident(AgentAlertIncidentContext incident) {
|
||||
if (incident == null) {
|
||||
return null;
|
||||
}
|
||||
String json = JsonUtil.toJson(incident);
|
||||
return Objects.equals(json, GatewayText.redactSecrets(json)) ? incident : null;
|
||||
}
|
||||
|
||||
private AgentTargetRef redactTarget(AgentTargetRef target) {
|
||||
if (target == null) {
|
||||
return null;
|
||||
}
|
||||
AgentSignalRef signal = target.getSignal();
|
||||
AgentTopologyRef topology = target.getTopology();
|
||||
AgentTraceRef trace = target.getTrace();
|
||||
AgentLogRef log = target.getLog();
|
||||
AgentServiceRef service = target.getService();
|
||||
AgentTargetAuthority authority = target.getAuthority();
|
||||
return AgentTargetRef.builder()
|
||||
.version(redactRequestValue(target.getVersion()))
|
||||
.monitorId(target.getMonitorId())
|
||||
.alertId(target.getAlertId())
|
||||
.entityId(target.getEntityId())
|
||||
.collector(redactRequestValue(target.getCollector()))
|
||||
.signal(signal == null ? null : AgentSignalRef.builder()
|
||||
.type(redactRequestValue(signal.getType()))
|
||||
.query(redactRequestValue(signal.getQuery()))
|
||||
.timeRange(redactRequestValue(signal.getTimeRange()))
|
||||
.start(signal.getStart())
|
||||
.end(signal.getEnd())
|
||||
.timezone(redactRequestValue(signal.getTimezone()))
|
||||
.build())
|
||||
.topology(topology == null ? null : AgentTopologyRef.builder()
|
||||
.rootEntityId(topology.getRootEntityId())
|
||||
.nodeId(redactRequestValue(topology.getNodeId()))
|
||||
.edgeId(redactRequestValue(topology.getEdgeId()))
|
||||
.depth(topology.getDepth())
|
||||
.environment(redactRequestValue(topology.getEnvironment()))
|
||||
.sourceKind(redactRequestValue(topology.getSourceKind()))
|
||||
.start(topology.getStart())
|
||||
.end(topology.getEnd())
|
||||
.relationType(redactRequestValue(topology.getRelationType()))
|
||||
.hideInternal(topology.getHideInternal())
|
||||
.pageIndex(topology.getPageIndex())
|
||||
.pageSize(topology.getPageSize())
|
||||
.build())
|
||||
.trace(trace == null ? null : AgentTraceRef.builder()
|
||||
.traceId(redactRequestValue(trace.getTraceId()))
|
||||
.spanId(redactRequestValue(trace.getSpanId()))
|
||||
.start(trace.getStart())
|
||||
.end(trace.getEnd())
|
||||
.serviceName(redactRequestValue(trace.getServiceName()))
|
||||
.serviceNamespace(redactRequestValue(trace.getServiceNamespace()))
|
||||
.environment(redactRequestValue(trace.getEnvironment()))
|
||||
.resourceFilter(redactRequestValue(trace.getResourceFilter()))
|
||||
.attributeFilter(redactRequestValue(trace.getAttributeFilter()))
|
||||
.minDurationMs(trace.getMinDurationMs())
|
||||
.maxDurationMs(trace.getMaxDurationMs())
|
||||
.build())
|
||||
.log(log == null ? null : AgentLogRef.builder()
|
||||
.start(log.getStart())
|
||||
.end(log.getEnd())
|
||||
.traceId(redactRequestValue(log.getTraceId()))
|
||||
.spanId(redactRequestValue(log.getSpanId()))
|
||||
.severityNumber(log.getSeverityNumber())
|
||||
.severityText(redactRequestValue(log.getSeverityText()))
|
||||
.search(redactRequestValue(log.getSearch()))
|
||||
.serviceName(redactRequestValue(log.getServiceName()))
|
||||
.serviceNamespace(redactRequestValue(log.getServiceNamespace()))
|
||||
.environment(redactRequestValue(log.getEnvironment()))
|
||||
.resourceFilter(redactRequestValue(log.getResourceFilter()))
|
||||
.attributeFilter(redactRequestValue(log.getAttributeFilter()))
|
||||
.hideInternal(log.getHideInternal())
|
||||
.hideNoise(log.getHideNoise())
|
||||
.pageIndex(log.getPageIndex())
|
||||
.pageSize(log.getPageSize())
|
||||
.build())
|
||||
.service(service == null ? null : AgentServiceRef.builder()
|
||||
.name(redactRequestValue(service.getName()))
|
||||
.namespace(redactRequestValue(service.getNamespace()))
|
||||
.environment(redactRequestValue(service.getEnvironment()))
|
||||
.build())
|
||||
.authority(authority == null ? null : AgentTargetAuthority.builder()
|
||||
.bindingId(authority.getBindingId())
|
||||
.version(redactRequestValue(authority.getVersion()))
|
||||
.hash(redactRequestValue(authority.getHash()))
|
||||
.build())
|
||||
.build();
|
||||
}
|
||||
|
||||
private String redactRequestValue(String value) {
|
||||
String redacted = GatewayText.redactSecrets(value);
|
||||
return Objects.equals(value, redacted) ? value : REDACTED_REQUEST_VALUE;
|
||||
}
|
||||
|
||||
private List<TranscriptContent> validateTranscriptContent(List<TranscriptContent> content) {
|
||||
if (content == null || content.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return content.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(block -> block.toBuilder()
|
||||
.id(GatewayText.requireBounded(
|
||||
block.getId(), TRANSCRIPT_TOOL_CALL_ID_LIMIT, "Transcript tool-call id"))
|
||||
.name(GatewayText.requireBounded(
|
||||
block.getName(), TRANSCRIPT_TOOL_NAME_LIMIT, "Transcript tool name"))
|
||||
.text(GatewayText.redactSecrets(block.getText()))
|
||||
.input(GatewaySecretRedactor.redactMap(
|
||||
block.getInput() == null ? Map.of() : block.getInput()))
|
||||
.build())
|
||||
.toList();
|
||||
}
|
||||
|
||||
private String toJson(TranscriptMessage message) {
|
||||
String json = JsonUtil.toJson(message);
|
||||
if (GatewayText.isBlank(json)) {
|
||||
throw new IllegalArgumentException("Agent transcript payload cannot be serialized");
|
||||
}
|
||||
return json;
|
||||
}
|
||||
}
|
||||
-67
@@ -1,67 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.conversation;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Objects;
|
||||
import org.apache.hertzbeat.ai.gateway.conversation.persistence.AgentTranscriptEntryDao;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.AgentRuntimeProperties;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Deletes durable Agent Gateway transcript entries after their configured retention period.
|
||||
*/
|
||||
@Service
|
||||
public class AgentTranscriptRetentionService {
|
||||
|
||||
private final AgentTranscriptEntryDao transcriptEntryDao;
|
||||
private final AgentRuntimeProperties properties;
|
||||
private final Clock clock;
|
||||
|
||||
@Autowired
|
||||
public AgentTranscriptRetentionService(AgentTranscriptEntryDao transcriptEntryDao,
|
||||
AgentRuntimeProperties properties) {
|
||||
this(transcriptEntryDao, properties, Clock.systemUTC());
|
||||
}
|
||||
|
||||
AgentTranscriptRetentionService(AgentTranscriptEntryDao transcriptEntryDao,
|
||||
AgentRuntimeProperties properties,
|
||||
Clock clock) {
|
||||
this.transcriptEntryDao = Objects.requireNonNull(
|
||||
transcriptEntryDao, "transcriptEntryDao must not be null");
|
||||
this.properties = Objects.requireNonNull(properties, "properties must not be null");
|
||||
this.clock = Objects.requireNonNull(clock, "clock must not be null");
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge transcript entries whose creation timestamp is older than the retention boundary.
|
||||
*
|
||||
* @return number of deleted transcript entries
|
||||
*/
|
||||
@Scheduled(cron = "${hertzbeat.agent.runtime.transcript-retention-cron:0 30 2 * * *}")
|
||||
@Transactional
|
||||
public long purgeExpiredTranscripts() {
|
||||
LocalDateTime cutoff = LocalDateTime.ofInstant(
|
||||
clock.instant().minus(properties.getTranscriptRetention()), clock.getZone());
|
||||
return transcriptEntryDao.deleteByGmtCreateBefore(cutoff);
|
||||
}
|
||||
}
|
||||
-78
@@ -1,78 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.conversation.persistence;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.apache.hertzbeat.common.entity.agent.AgentRun;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/**
|
||||
* Repository for Agent Gateway runs.
|
||||
*/
|
||||
@Repository
|
||||
public interface AgentRunDao extends JpaRepository<AgentRun, Long> {
|
||||
|
||||
/**
|
||||
* Find a run by public run UID.
|
||||
*/
|
||||
Optional<AgentRun> findByRunUid(String runUid);
|
||||
|
||||
/**
|
||||
* Find the existing run for a session event.
|
||||
*/
|
||||
Optional<AgentRun> findBySessionIdAndMessageId(Long sessionId, String messageId);
|
||||
|
||||
Optional<AgentRun> findTopBySessionIdOrderByIdDesc(Long sessionId);
|
||||
|
||||
@Query("""
|
||||
select run from AgentRun run
|
||||
where run.id in (
|
||||
select max(candidate.id) from AgentRun candidate
|
||||
where candidate.sessionId in :sessionIds
|
||||
group by candidate.sessionId
|
||||
)
|
||||
""")
|
||||
List<AgentRun> findLatestBySessionIds(@Param("sessionIds") Collection<Long> sessionIds);
|
||||
|
||||
Optional<AgentRun> findFirstBySessionIdAndStatusOrderByGmtCreateAsc(Long sessionId, String status);
|
||||
|
||||
boolean existsBySessionIdAndStatusIn(Long sessionId, List<String> statuses);
|
||||
|
||||
/** Cancel only a run that has not already reached a truthful terminal state. */
|
||||
@Modifying(clearAutomatically = true, flushAutomatically = true)
|
||||
@Query("""
|
||||
update AgentRun run
|
||||
set run.status = :cancelledStatus,
|
||||
run.errorMessage = :reason,
|
||||
run.completedAt = :transitionAt,
|
||||
run.gmtUpdate = :transitionAt
|
||||
where run.id = :runId and run.status in :activeStatuses
|
||||
""")
|
||||
int cancelIfActive(@Param("runId") Long runId,
|
||||
@Param("activeStatuses") Collection<String> activeStatuses,
|
||||
@Param("cancelledStatus") String cancelledStatus,
|
||||
@Param("reason") String reason,
|
||||
@Param("transitionAt") java.time.LocalDateTime transitionAt);
|
||||
|
||||
}
|
||||
-84
@@ -1,84 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.conversation.persistence;
|
||||
|
||||
import jakarta.persistence.LockModeType;
|
||||
import java.util.Optional;
|
||||
import org.apache.hertzbeat.common.entity.agent.AgentSession;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/**
|
||||
* Repository for Agent Gateway sessions.
|
||||
*/
|
||||
@Repository
|
||||
public interface AgentSessionDao extends JpaRepository<AgentSession, Long> {
|
||||
|
||||
/**
|
||||
* Find a session by deterministic session key.
|
||||
*/
|
||||
Optional<AgentSession> findBySessionKey(String sessionKey);
|
||||
|
||||
/**
|
||||
* Find a session by public session UID.
|
||||
*/
|
||||
Optional<AgentSession> findBySessionUid(String sessionUid);
|
||||
|
||||
Optional<AgentSession> findByIdAndWorkspaceIdAndChannelAndActorTypeAndActorIdAndOriginEntryType(
|
||||
Long id, String workspaceId, String channel, String actorType, String actorId, String originEntryType);
|
||||
|
||||
Optional<AgentSession> findBySessionUidAndWorkspaceIdAndChannelAndActorTypeAndActorIdAndOriginEntryType(
|
||||
String sessionUid, String workspaceId, String channel, String actorType, String actorId,
|
||||
String originEntryType);
|
||||
|
||||
Page<AgentSession> findByWorkspaceIdAndChannelAndActorTypeAndActorIdAndOriginEntryTypeOrderByGmtUpdateDesc(
|
||||
String workspaceId, String channel, String actorType, String actorId, String originEntryType,
|
||||
Pageable pageable);
|
||||
|
||||
Page<AgentSession>
|
||||
findByWorkspaceIdAndChannelAndActorTypeAndActorIdAndOriginEntryTypeAndTitleContainingIgnoreCaseOrderByGmtUpdateDesc(
|
||||
String workspaceId, String channel, String actorType, String actorId, String originEntryType,
|
||||
String title, Pageable pageable);
|
||||
|
||||
/**
|
||||
* Lock a session row while assigning transcript append sequence.
|
||||
*/
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
Optional<AgentSession> findFirstById(Long id);
|
||||
|
||||
/**
|
||||
* Advance session activity without merging a stale session entity over transcript sequence or title updates.
|
||||
*/
|
||||
@Modifying(flushAutomatically = true)
|
||||
@Query("""
|
||||
update AgentSession session
|
||||
set session.gmtUpdate = case
|
||||
when session.gmtUpdate is null or session.gmtUpdate < :transitionAt then :transitionAt
|
||||
else session.gmtUpdate
|
||||
end
|
||||
where session.id = :sessionId
|
||||
""")
|
||||
int advanceGmtUpdate(@Param("sessionId") Long sessionId,
|
||||
@Param("transitionAt") java.time.LocalDateTime transitionAt);
|
||||
}
|
||||
-79
@@ -1,79 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.conversation.persistence;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.apache.hertzbeat.common.entity.agent.AgentTranscriptEntry;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/**
|
||||
* Repository for Agent Gateway transcript entries.
|
||||
*/
|
||||
@Repository
|
||||
public interface AgentTranscriptEntryDao extends JpaRepository<AgentTranscriptEntry, Long> {
|
||||
|
||||
/**
|
||||
* Find transcript entries for a session in append order.
|
||||
*/
|
||||
Page<AgentTranscriptEntry> findBySessionIdOrderBySessionSequenceAsc(Long sessionId, Pageable pageable);
|
||||
|
||||
/**
|
||||
* Find conversation messages for a session from newest to oldest.
|
||||
*/
|
||||
Page<AgentTranscriptEntry> findBySessionIdAndMessageRoleInOrderBySessionSequenceDesc(
|
||||
Long sessionId, List<String> messageRoles, Pageable pageable);
|
||||
|
||||
/**
|
||||
* Find recent transcript entries for a session in reverse append order.
|
||||
*/
|
||||
List<AgentTranscriptEntry> findBySessionIdOrderBySessionSequenceDesc(Long sessionId, Pageable pageable);
|
||||
|
||||
/**
|
||||
* Find the latest transcript entry for a session and role.
|
||||
*/
|
||||
Optional<AgentTranscriptEntry> findTopBySessionIdAndMessageRoleOrderBySessionSequenceDesc(
|
||||
Long sessionId, String messageRole);
|
||||
|
||||
Optional<AgentTranscriptEntry> findFirstByRunIdAndMessageRoleOrderBySessionSequenceAsc(
|
||||
Long runId, String messageRole);
|
||||
|
||||
List<AgentTranscriptEntry> findTop2ByRunIdAndMessageRoleOrderBySessionSequenceAsc(
|
||||
Long runId, String messageRole);
|
||||
|
||||
Optional<AgentTranscriptEntry> findTopByRunIdAndMessageRoleOrderBySessionSequenceDesc(
|
||||
Long runId, String messageRole);
|
||||
|
||||
List<AgentTranscriptEntry> findByRunIdAndMessageRoleOrderBySessionSequenceAsc(
|
||||
Long runId, String messageRole);
|
||||
|
||||
/**
|
||||
* Find transcript entries from a session sequence in append order.
|
||||
*/
|
||||
List<AgentTranscriptEntry> findBySessionIdAndSessionSequenceGreaterThanEqualOrderBySessionSequenceAsc(
|
||||
Long sessionId, Long sessionSequence, Pageable pageable);
|
||||
|
||||
/**
|
||||
* Delete transcript entries older than the configured retention boundary.
|
||||
*/
|
||||
long deleteByGmtCreateBefore(LocalDateTime cutoff);
|
||||
}
|
||||
-161
@@ -1,161 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.identity;
|
||||
|
||||
import com.usthe.sureness.subject.PrincipalMap;
|
||||
import com.usthe.sureness.subject.SubjectSum;
|
||||
import com.usthe.sureness.util.SurenessContextHolder;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.hertzbeat.ai.gateway.text.GatewayText;
|
||||
import org.apache.hertzbeat.common.util.JsonUtil;
|
||||
|
||||
/**
|
||||
* Shared actor normalization and projection helpers for Agent Gateway.
|
||||
*/
|
||||
public final class ActorSupport {
|
||||
|
||||
public static final String TYPE_USER = "user";
|
||||
|
||||
public static final String TYPE_SYSTEM = "system";
|
||||
|
||||
public static final String ID_ALERT_ANALYSIS = "alert-analysis";
|
||||
|
||||
public static final String ID_SCHEDULE = "schedule";
|
||||
|
||||
public static final String ROLE_ADMIN = "admin";
|
||||
|
||||
public static final String ROLE_USER = "user";
|
||||
|
||||
public static final String ROLE_GUEST = "guest";
|
||||
|
||||
public static final String ROLE_ALERT_ANALYSIS = "alert-analysis";
|
||||
|
||||
public static final String ROLE_SCHEDULE = "schedule";
|
||||
|
||||
private static final String ROLES_CLAIM = "roles";
|
||||
|
||||
private static final List<String> WELL_KNOWN_ROLES = List.of(ROLE_ADMIN, ROLE_USER, ROLE_GUEST);
|
||||
|
||||
private ActorSupport() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a trusted user actor from the current Sureness context.
|
||||
*/
|
||||
public static AgentActor requireCurrentSurenessActor() {
|
||||
return requireSurenessActor(SurenessContextHolder.getBindSubject());
|
||||
}
|
||||
|
||||
/** Build an authenticated administrator actor from the current Sureness context. */
|
||||
public static AgentActor requireCurrentAdminSurenessActor() {
|
||||
AgentActor actor = requireCurrentSurenessActor();
|
||||
if (!actor.getRoles().contains(ROLE_ADMIN)) {
|
||||
throw new IllegalStateException("Administrator role is required");
|
||||
}
|
||||
return actor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a trusted user actor from a Sureness subject and validate principal and roles.
|
||||
*/
|
||||
public static AgentActor requireSurenessActor(SubjectSum subject) {
|
||||
if (subject == null) {
|
||||
throw new IllegalStateException("Authenticated subject is required");
|
||||
}
|
||||
String principal = normalizeSubjectPrincipal(subject);
|
||||
List<String> roles = extractSurenessRoles(subject);
|
||||
if (roles.isEmpty()) {
|
||||
throw new IllegalStateException("Authenticated subject roles are required");
|
||||
}
|
||||
return AgentActor.builder()
|
||||
.type(TYPE_USER)
|
||||
.id(principal)
|
||||
.roles(roles)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract Sureness roles from subject roles, JWT role claims, and hasRole checks.
|
||||
*/
|
||||
public static List<String> extractSurenessRoles(SubjectSum subject) {
|
||||
if (subject == null) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> roles = new ArrayList<>();
|
||||
addRoles(roles, subject.getRoles());
|
||||
PrincipalMap principalMap = subject.getPrincipalMap();
|
||||
if (principalMap != null) {
|
||||
addRoles(roles, principalMap.getPrincipal(ROLES_CLAIM));
|
||||
}
|
||||
WELL_KNOWN_ROLES.forEach(role -> addRoleIfPresent(subject, roles, role));
|
||||
return List.copyOf(roles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the actor carries both type and id.
|
||||
*/
|
||||
public static boolean hasIdentity(AgentActor actor) {
|
||||
return actor != null && StringUtils.isNoneBlank(actor.getType(), actor.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize actor roles as JSON for fields that already store JSON role arrays.
|
||||
*/
|
||||
public static String rolesJson(AgentActor actor) {
|
||||
return actor == null ? null : JsonUtil.toJson(actor.getRoles());
|
||||
}
|
||||
|
||||
private static String normalizeSubjectPrincipal(SubjectSum subject) {
|
||||
Object principal = subject.getPrincipal();
|
||||
// Security subjects may expose padded textual principals; canonicalize before using them as actor identity.
|
||||
String normalized = GatewayText.normalize(principal == null ? null : String.valueOf(principal));
|
||||
if (normalized == null) {
|
||||
throw new IllegalStateException("Authenticated subject principal is required");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static void addRoles(List<String> roles, Object rawRoles) {
|
||||
if (rawRoles instanceof Collection<?> values) {
|
||||
values.forEach(value -> addRole(roles, value));
|
||||
return;
|
||||
}
|
||||
if (rawRoles instanceof String value) {
|
||||
for (String role : value.split(",")) {
|
||||
addRole(roles, role);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void addRoleIfPresent(SubjectSum subject, List<String> roles, String role) {
|
||||
if (subject.hasRole(role)) {
|
||||
addRole(roles, role);
|
||||
}
|
||||
}
|
||||
|
||||
private static void addRole(List<String> roles, Object rawRole) {
|
||||
// Security frameworks may expose padded or comma-split role values; canonicalize before role comparison.
|
||||
String role = GatewayText.normalize(rawRole == null ? null : String.valueOf(rawRole));
|
||||
if (role != null && !roles.contains(role)) {
|
||||
roles.add(role);
|
||||
}
|
||||
}
|
||||
}
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.identity;
|
||||
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.util.List;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* Actor that initiated an Agent Gateway request.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class AgentActor {
|
||||
|
||||
@Size(max = 64)
|
||||
private String type;
|
||||
|
||||
@Size(max = 128)
|
||||
private String id;
|
||||
|
||||
private List<String> roles;
|
||||
|
||||
public static AgentActor alertAnalysisActor() {
|
||||
return AgentActor.builder()
|
||||
.type(ActorSupport.TYPE_SYSTEM)
|
||||
.id(ActorSupport.ID_ALERT_ANALYSIS)
|
||||
.roles(List.of(ActorSupport.ROLE_ALERT_ANALYSIS))
|
||||
.build();
|
||||
}
|
||||
|
||||
public static AgentActor scheduleActor() {
|
||||
return AgentActor.builder()
|
||||
.type(ActorSupport.TYPE_SYSTEM)
|
||||
.id(ActorSupport.ID_SCHEDULE)
|
||||
.roles(List.of(ActorSupport.ROLE_SCHEDULE))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.runtime;
|
||||
|
||||
/**
|
||||
* Runtime behavior when a tool policy requires an external approval decision.
|
||||
*/
|
||||
public enum AgentApprovalHandling {
|
||||
WAIT_FOR_DECISION,
|
||||
DENY
|
||||
}
|
||||
-130
@@ -1,130 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.runtime;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.json.JsonMapper;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import org.apache.hertzbeat.ai.gateway.application.AgentEntityTargetAuthorityService;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentTargetAuthority;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentTargetRef;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/** Production-shaped output semantics for one canonical Entity observation. */
|
||||
final class AgentEntityTargetGroundingSemantics {
|
||||
|
||||
private static final Set<String> OUTPUT_KEYS = Set.of(
|
||||
"entity", "status", "evidenceSummary", "alertSummary", "monitorSummary", "logSummary",
|
||||
"traceSummary", "signalEvidence", "triageRecommendation", "opsSummary", "nextActions",
|
||||
"topologyNeighbors");
|
||||
private static final Set<String> OUTPUT_REQUIRED_KEYS = Set.of("entity", "nextActions", "topologyNeighbors");
|
||||
private static final Set<String> ROW_KEYS = Set.of(
|
||||
"id", "type", "name", "displayName", "subtype", "namespace", "environment", "status",
|
||||
"criticality", "owner", "lifecycle", "tier", "system", "source", "description", "labels", "tags");
|
||||
private static final Set<String> ROW_REQUIRED_KEYS = Set.of("id", "type", "name", "labels", "tags");
|
||||
private static final int MAX_TEXT_LENGTH = 256;
|
||||
private static final int MAX_DESCRIPTION_LENGTH = 1_024;
|
||||
private static final int MAX_LABELS_LENGTH = 16_384;
|
||||
private static final int MAX_TAG_LENGTH = 128;
|
||||
private static final int MAX_COLLECTION_SIZE = 1_024;
|
||||
private static final ObjectMapper QUIET_JSON = JsonMapper.builder().build();
|
||||
|
||||
boolean isCanonicalTarget(AgentTargetRef target) {
|
||||
AgentTargetAuthority authority = target == null ? null : target.getAuthority();
|
||||
return target != null
|
||||
&& AgentEntityTargetAuthorityService.TARGET_VERSION.equals(target.getVersion())
|
||||
&& target.getEntityId() != null && target.getEntityId() > 0
|
||||
&& authority != null && Objects.equals(target.getEntityId(), authority.getBindingId())
|
||||
&& AgentEntityTargetAuthorityService.AUTHORITY_VERSION.equals(authority.getVersion())
|
||||
&& authority.getHash() != null && authority.getHash().matches("sha256:[0-9a-f]{64}")
|
||||
&& target.getMonitorId() == null && target.getAlertId() == null
|
||||
&& target.getAlertType() == null && target.getCollector() == null
|
||||
&& target.getTopology() == null && target.getTrace() == null && target.getLog() == null
|
||||
&& target.getSignal() == null && target.getService() == null;
|
||||
}
|
||||
|
||||
boolean matches(AgentTargetRef target, AgentRuntimeToolCall call, Map<String, Object> output) {
|
||||
Object entityValue = output.get("entity");
|
||||
return "entity.get".equals(call.getToolName())
|
||||
&& Set.of("entityId").equals(call.getArguments().keySet())
|
||||
&& exactLong(call.getArguments().get("entityId"), target.getEntityId())
|
||||
&& OUTPUT_KEYS.containsAll(output.keySet())
|
||||
&& output.keySet().containsAll(OUTPUT_REQUIRED_KEYS)
|
||||
&& entityValue instanceof Map<?, ?> entity
|
||||
&& entityRow(entity, target.getEntityId())
|
||||
&& output.get("nextActions") instanceof List<?>
|
||||
&& output.get("topologyNeighbors") instanceof List<?>;
|
||||
}
|
||||
|
||||
private boolean entityRow(Map<?, ?> row, Long expectedId) {
|
||||
return ROW_KEYS.containsAll(row.keySet())
|
||||
&& row.keySet().containsAll(ROW_REQUIRED_KEYS)
|
||||
&& exactLong(row.get("id"), expectedId)
|
||||
&& requiredText(row.get("type"), MAX_TEXT_LENGTH)
|
||||
&& requiredText(row.get("name"), MAX_TEXT_LENGTH)
|
||||
&& nullableText(row.get("displayName"), MAX_TEXT_LENGTH)
|
||||
&& nullableText(row.get("subtype"), MAX_TEXT_LENGTH)
|
||||
&& nullableText(row.get("namespace"), MAX_TEXT_LENGTH)
|
||||
&& nullableText(row.get("environment"), MAX_TEXT_LENGTH)
|
||||
&& nullableText(row.get("status"), MAX_TEXT_LENGTH)
|
||||
&& nullableText(row.get("criticality"), MAX_TEXT_LENGTH)
|
||||
&& nullableText(row.get("owner"), MAX_TEXT_LENGTH)
|
||||
&& nullableText(row.get("lifecycle"), MAX_TEXT_LENGTH)
|
||||
&& nullableText(row.get("tier"), MAX_TEXT_LENGTH)
|
||||
&& nullableText(row.get("system"), MAX_TEXT_LENGTH)
|
||||
&& nullableText(row.get("source"), MAX_TEXT_LENGTH)
|
||||
&& nullableText(row.get("description"), MAX_DESCRIPTION_LENGTH)
|
||||
&& stringMap(row.get("labels"), MAX_LABELS_LENGTH)
|
||||
&& boundedStringList(row.get("tags"));
|
||||
}
|
||||
|
||||
private boolean exactLong(Object value, Long expected) {
|
||||
if (expected == null || !(value instanceof Number number)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return new BigDecimal(number.toString()).longValueExact() == expected;
|
||||
} catch (ArithmeticException | NumberFormatException ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean requiredText(Object value, int maximumLength) {
|
||||
return value instanceof String text && StringUtils.hasText(text) && text.length() <= maximumLength;
|
||||
}
|
||||
|
||||
private boolean nullableText(Object value, int maximumLength) {
|
||||
return value == null || value instanceof String text && text.length() <= maximumLength;
|
||||
}
|
||||
|
||||
private boolean stringMap(Object value, int maximumSerializedLength) {
|
||||
if (!(value instanceof Map<?, ?> map) || map.size() > MAX_COLLECTION_SIZE
|
||||
|| map.entrySet().stream().anyMatch(entry -> !(entry.getKey() instanceof String key)
|
||||
|| key.length() > maximumSerializedLength
|
||||
|| !(entry.getValue() == null || entry.getValue() instanceof String text
|
||||
&& text.length() <= maximumSerializedLength))) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return QUIET_JSON.writeValueAsString(map).length() <= maximumSerializedLength;
|
||||
} catch (JsonProcessingException ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean boundedStringList(Object value) {
|
||||
return value instanceof List<?> values
|
||||
&& values.size() <= MAX_COLLECTION_SIZE
|
||||
&& values.stream().allMatch(item -> item instanceof String text && text.length() <= MAX_TAG_LENGTH);
|
||||
}
|
||||
}
|
||||
-169
@@ -1,169 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.runtime;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.json.JsonMapper;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import org.apache.hertzbeat.ai.gateway.conversation.AgentTranscriptRecorder;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentTargetRef;
|
||||
import org.apache.hertzbeat.ai.gateway.text.GatewayText;
|
||||
import org.apache.hertzbeat.ai.gateway.tool.core.AgentToolRisk;
|
||||
import org.apache.hertzbeat.ai.gateway.tool.core.AgentToolStatus;
|
||||
import org.apache.hertzbeat.ai.gateway.tool.core.persistence.AgentToolCallDao;
|
||||
import org.apache.hertzbeat.common.entity.agent.AgentRun;
|
||||
import org.apache.hertzbeat.common.entity.agent.AgentToolCall;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** Verifies that a durable grounding proof converges with the successful READ ledger row. */
|
||||
@Service
|
||||
public class AgentGroundingEvidenceVerifier {
|
||||
|
||||
private static final ObjectMapper QUIET_JSON = JsonMapper.builder().build();
|
||||
|
||||
private final AgentTranscriptRecorder transcriptRecorder;
|
||||
private final AgentToolCallDao toolCallDao;
|
||||
private final AgentReadGroundingEvaluator readEvaluator = new AgentReadGroundingEvaluator();
|
||||
private final AgentTargetGroundingEvaluator targetEvaluator = new AgentTargetGroundingEvaluator();
|
||||
|
||||
public AgentGroundingEvidenceVerifier(AgentTranscriptRecorder transcriptRecorder,
|
||||
AgentToolCallDao toolCallDao) {
|
||||
this.transcriptRecorder = transcriptRecorder;
|
||||
this.toolCallDao = toolCallDao;
|
||||
}
|
||||
|
||||
public boolean hasDurableGrounding(AgentRun run, AgentTargetRef target) {
|
||||
if (run == null || run.getId() == null || GatewayText.isBlank(run.getRunUid())) {
|
||||
return false;
|
||||
}
|
||||
if (!groundingEligible(run)) {
|
||||
return false;
|
||||
}
|
||||
List<TranscriptMessage> candidates = transcriptRecorder.findRunGroundingMessages(run.getId()).stream()
|
||||
.filter(Objects::nonNull)
|
||||
.filter(message -> message.getRole() == TranscriptMessage.TranscriptRole.TOOL_RESULT)
|
||||
.filter(message -> message.getGroundingProof() != null)
|
||||
.filter(message -> Objects.equals(run.getRunUid(), message.getGroundingProof().getRunUid()))
|
||||
.filter(this::isTypedProof)
|
||||
.toList();
|
||||
if (candidates.size() != 1) {
|
||||
return false;
|
||||
}
|
||||
TranscriptMessage message = candidates.getFirst();
|
||||
List<AgentToolCall> matchingLedger = toolCallDao.findByRunIdOrderByGmtCreateAsc(run.getId()).stream()
|
||||
.filter(Objects::nonNull)
|
||||
.filter(call -> Objects.equals(message.getToolCallId(), call.getToolCallId())
|
||||
&& Objects.equals(message.getToolName(), call.getToolName()))
|
||||
.toList();
|
||||
return matchingLedger.size() == 1 && ledgerMatches(run, target, message, matchingLedger.getFirst());
|
||||
}
|
||||
|
||||
public List<TranscriptMessage> verifiedHistory(AgentRun run, AgentTargetRef target,
|
||||
List<TranscriptMessage> history) {
|
||||
return verifyHistory(run, target, history).messages();
|
||||
}
|
||||
|
||||
public VerifiedHistory verifyHistory(AgentRun run, AgentTargetRef target,
|
||||
List<TranscriptMessage> history) {
|
||||
List<TranscriptMessage> bounded = history == null ? List.of() : List.copyOf(history);
|
||||
if (hasDurableGrounding(run, target)) {
|
||||
return new VerifiedHistory(bounded, true);
|
||||
}
|
||||
return new VerifiedHistory(bounded.stream()
|
||||
.map(message -> clearCurrentRunProof(run, message))
|
||||
.toList(), false);
|
||||
}
|
||||
|
||||
private boolean ledgerMatches(AgentRun run, AgentTargetRef target, TranscriptMessage message,
|
||||
AgentToolCall ledger) {
|
||||
AgentGroundingProof proof = message.getGroundingProof();
|
||||
if (!(Objects.equals(run.getId(), ledger.getRunId())
|
||||
&& Objects.equals(run.getRunUid(), ledger.getRunUid())
|
||||
&& Objects.equals(AgentToolStatus.SUCCEEDED.name(), ledger.getStatus())
|
||||
&& Objects.equals(AgentToolRisk.READ.name(), ledger.getRisk())
|
||||
&& Objects.equals(proof.getInputHash(), ledger.getInputHash())
|
||||
&& Objects.equals(proof.getOutputHash(), outputHash(ledger.getResultOutput()))
|
||||
&& Objects.equals(proof.getOutputHash(), outputHash(message.text())))) {
|
||||
return false;
|
||||
}
|
||||
Map<String, Object> arguments = arguments(ledger.getInputJson());
|
||||
if (arguments == null) {
|
||||
return false;
|
||||
}
|
||||
return target == null
|
||||
? readEvaluator.restores(message, run.getRunUid(), arguments, ledger.getInputHash())
|
||||
: targetEvaluator.restoresVerifiedResult(
|
||||
message, run.getRunUid(), target, arguments, ledger.getInputHash());
|
||||
}
|
||||
|
||||
private boolean groundingEligible(AgentRun run) {
|
||||
return Objects.equals(AgentRuntimeEntryType.USER_INPUT.name(), run.getEntryType())
|
||||
|| Objects.equals(AgentRuntimeEntryType.SCHEDULE_TRIGGER.name(), run.getEntryType());
|
||||
}
|
||||
|
||||
private boolean isTypedProof(TranscriptMessage message) {
|
||||
String version = message.getGroundingProof().getVersion();
|
||||
return Objects.equals(AgentGroundingProof.VERSION, version)
|
||||
|| Objects.equals(AgentReadGroundingEvaluator.VERSION, version);
|
||||
}
|
||||
|
||||
private TranscriptMessage clearCurrentRunProof(AgentRun run, TranscriptMessage message) {
|
||||
if (message == null || message.getGroundingProof() == null
|
||||
|| !Objects.equals(run.getRunUid(), message.getGroundingProof().getRunUid())) {
|
||||
return message;
|
||||
}
|
||||
return message.toBuilder().groundingProof(null).groundingRunUid(null).build();
|
||||
}
|
||||
|
||||
private String outputHash(String output) {
|
||||
return GatewayText.sha256(AgentRuntimeTextSanitizer.redact(output));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> arguments(String json) {
|
||||
try {
|
||||
Object value = QUIET_JSON.readValue(json, Object.class);
|
||||
return value instanceof Map<?, ?> map ? (Map<String, Object>) map : null;
|
||||
} catch (IOException | RuntimeException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** History projection plus the trusted durable-grounding decision made from proof and ledger together. */
|
||||
static final class VerifiedHistory {
|
||||
|
||||
private final List<TranscriptMessage> messages;
|
||||
private final boolean grounded;
|
||||
|
||||
private VerifiedHistory(List<TranscriptMessage> messages, boolean grounded) {
|
||||
this.messages = messages == null ? List.of() : List.copyOf(messages);
|
||||
this.grounded = grounded;
|
||||
}
|
||||
|
||||
List<TranscriptMessage> messages() {
|
||||
return messages;
|
||||
}
|
||||
|
||||
boolean grounded() {
|
||||
return grounded;
|
||||
}
|
||||
}
|
||||
}
|
||||
-45
@@ -1,45 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.runtime;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/** Durable, target-correlated proof for one successful read observation. */
|
||||
@Data
|
||||
@Builder
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class AgentGroundingProof {
|
||||
|
||||
public static final String VERSION = "grounding.v2";
|
||||
|
||||
private String version;
|
||||
private String runUid;
|
||||
private String targetFingerprint;
|
||||
private String targetVersion;
|
||||
private Long entityId;
|
||||
private String toolName;
|
||||
private String toolCallId;
|
||||
private String inputHash;
|
||||
private String outputHash;
|
||||
private String observationKind;
|
||||
private Long monitorId;
|
||||
private Long alertId;
|
||||
private String alertType;
|
||||
private String metricKey;
|
||||
private String traceId;
|
||||
private String spanId;
|
||||
private Long start;
|
||||
private Long end;
|
||||
private String timezone;
|
||||
private String authorityHash;
|
||||
private Integer observationCount;
|
||||
}
|
||||
-222
@@ -1,222 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.runtime;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.json.JsonMapper;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import org.apache.hertzbeat.ai.gateway.application.AgentLogTargetAuthorityService;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentLogRef;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentTargetAuthority;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.AgentTargetRef;
|
||||
|
||||
/** Producer-shaped output semantics for one exact canonical Log Explore page target. */
|
||||
final class AgentLogTargetGroundingSemantics {
|
||||
|
||||
private static final Set<String> OUTPUT_KEYS = Set.of(
|
||||
"content", "pageIndex", "pageSize", "totalElements", "totalPages", "start", "end");
|
||||
private static final Set<String> ROW_KEYS = Set.of(
|
||||
"timeUnixNano", "observedTimeUnixNano", "severityNumber", "severityText", "body",
|
||||
"traceId", "spanId", "traceFlags", "attributes", "resource");
|
||||
private static final Pattern SIMPLE_FILTER = Pattern.compile("^\\s*([^:=\\s]+)\\s*[:=]\\s*(.+?)\\s*$");
|
||||
private static final ObjectMapper QUIET_JSON = JsonMapper.builder().build();
|
||||
|
||||
boolean isCanonicalTarget(AgentTargetRef target) {
|
||||
AgentTargetAuthority authority = target == null ? null : target.getAuthority();
|
||||
return target != null && AgentLogTargetAuthorityService.TARGET_VERSION.equals(target.getVersion())
|
||||
&& target.getLog() != null && authority != null && authority.getBindingId() == null
|
||||
&& AgentLogTargetAuthorityService.AUTHORITY_VERSION.equals(authority.getVersion())
|
||||
&& authority.getHash() != null && authority.getHash().matches("sha256:[0-9a-f]{64}")
|
||||
&& target.getMonitorId() == null && target.getAlertId() == null && target.getAlertType() == null
|
||||
&& target.getEntityId() == null && target.getCollector() == null && target.getSignal() == null
|
||||
&& target.getTopology() == null && target.getTrace() == null && target.getService() == null;
|
||||
}
|
||||
|
||||
int matchingObservationCount(AgentTargetRef target, AgentRuntimeToolCall call, Map<String, Object> output) {
|
||||
AgentLogRef log = target == null ? null : target.getLog();
|
||||
if (!isCanonicalTarget(target) || log == null || !"logs.query".equals(call.getToolName())
|
||||
|| !argumentsMatch(log, call.getArguments()) || !OUTPUT_KEYS.equals(output.keySet())
|
||||
|| !exactLong(output.get("start"), log.getStart()) || !exactLong(output.get("end"), log.getEnd())
|
||||
|| !exactLong(output.get("pageIndex"), log.getPageIndex())
|
||||
|| !exactLong(output.get("pageSize"), log.getPageSize())) {
|
||||
return 0;
|
||||
}
|
||||
long totalElements = nonnegativeLong(output.get("totalElements"));
|
||||
long totalPages = nonnegativeLong(output.get("totalPages"));
|
||||
if (totalElements <= 0 || totalPages != (totalElements + log.getPageSize() - 1) / log.getPageSize()
|
||||
|| !(output.get("content") instanceof List<?> rows) || rows.isEmpty()
|
||||
|| rows.size() > log.getPageSize()) {
|
||||
return 0;
|
||||
}
|
||||
long remaining = totalElements - (long) log.getPageIndex() * log.getPageSize();
|
||||
if (remaining <= 0 || rows.size() > Math.min(log.getPageSize(), remaining)) {
|
||||
return 0;
|
||||
}
|
||||
return rows.stream().allMatch(row -> row instanceof Map<?, ?> map && rowMatches(log, map))
|
||||
? rows.size() : 0;
|
||||
}
|
||||
|
||||
private boolean argumentsMatch(AgentLogRef log, Map<String, Object> arguments) {
|
||||
Map<String, Object> expected = new LinkedHashMap<>();
|
||||
expected.put("start", log.getStart());
|
||||
expected.put("end", log.getEnd());
|
||||
put(expected, "traceId", log.getTraceId());
|
||||
put(expected, "spanId", log.getSpanId());
|
||||
put(expected, "severityNumber", log.getSeverityNumber());
|
||||
put(expected, "severityText", log.getSeverityText());
|
||||
put(expected, "search", log.getSearch());
|
||||
put(expected, "serviceName", log.getServiceName());
|
||||
put(expected, "serviceNamespace", log.getServiceNamespace());
|
||||
put(expected, "environment", log.getEnvironment());
|
||||
put(expected, "resourceFilter", log.getResourceFilter());
|
||||
put(expected, "attributeFilter", log.getAttributeFilter());
|
||||
expected.put("hideInternal", log.getHideInternal());
|
||||
expected.put("hideNoise", log.getHideNoise());
|
||||
expected.put("pageIndex", log.getPageIndex());
|
||||
expected.put("pageSize", log.getPageSize());
|
||||
if (!expected.keySet().equals(arguments.keySet())) {
|
||||
return false;
|
||||
}
|
||||
return expected.entrySet().stream().allMatch(entry -> entry.getValue() instanceof Number number
|
||||
? exactLong(arguments.get(entry.getKey()), number.longValue())
|
||||
: Objects.equals(entry.getValue(), arguments.get(entry.getKey())));
|
||||
}
|
||||
|
||||
private boolean rowMatches(AgentLogRef log, Map<?, ?> row) {
|
||||
if (!ROW_KEYS.equals(row.keySet()) || !boundedText(row.get("body"), 4096)
|
||||
|| !boundedText(row.get("severityText"), 128) || !boundedText(row.get("traceId"), 128)
|
||||
|| !boundedText(row.get("spanId"), 128) || !nullableNonnegative(row.get("timeUnixNano"))
|
||||
|| !nullableNonnegative(row.get("observedTimeUnixNano"))
|
||||
|| !nullableInteger(row.get("severityNumber"), 1, 24)
|
||||
|| !nullableInteger(row.get("traceFlags"), 0, Integer.MAX_VALUE)) {
|
||||
return false;
|
||||
}
|
||||
Long timeNanos = nullableLong(row.get("timeUnixNano"));
|
||||
Long observedNanos = nullableLong(row.get("observedTimeUnixNano"));
|
||||
if (!withinWindow(log, timeNanos) && !withinWindow(log, observedNanos)) {
|
||||
return false;
|
||||
}
|
||||
Map<String, Object> attributes = jsonMap(row.get("attributes"), 4096);
|
||||
Map<String, Object> resource = jsonMap(row.get("resource"), 2048);
|
||||
if (attributes == null || resource == null
|
||||
|| log.getTraceId() != null && !Objects.equals(log.getTraceId(), row.get("traceId"))
|
||||
|| log.getSpanId() != null && !Objects.equals(log.getSpanId(), row.get("spanId"))
|
||||
|| log.getSeverityNumber() != null && !exactLong(row.get("severityNumber"), log.getSeverityNumber())
|
||||
|| log.getSeverityText() != null && !equalsIgnoreCase(log.getSeverityText(), row.get("severityText"))
|
||||
|| !containsIgnoreCase(row.get("body"), log.getSearch())
|
||||
|| !resourceValue(resource, "service.name", log.getServiceName())
|
||||
|| !resourceValue(resource, "service.namespace", log.getServiceNamespace())
|
||||
|| !resourceValue(resource, "deployment.environment.name", log.getEnvironment())
|
||||
|| !simpleFilterMatches(resource, log.getResourceFilter())
|
||||
|| !simpleFilterMatches(attributes, log.getAttributeFilter())) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean withinWindow(AgentLogRef log, Long nanos) {
|
||||
if (nanos == null || nanos < 0) {
|
||||
return false;
|
||||
}
|
||||
long millis = nanos / 1_000_000L;
|
||||
return millis >= log.getStart() && millis <= log.getEnd();
|
||||
}
|
||||
|
||||
private boolean simpleFilterMatches(Map<String, Object> values, String filter) {
|
||||
if (filter == null) {
|
||||
return true;
|
||||
}
|
||||
Matcher matcher = SIMPLE_FILTER.matcher(filter);
|
||||
if (!matcher.matches()) {
|
||||
return false;
|
||||
}
|
||||
Object actual = values.get(matcher.group(1).trim());
|
||||
return actual != null && Objects.equals(matcher.group(2).trim(), String.valueOf(actual));
|
||||
}
|
||||
|
||||
private boolean resourceValue(Map<String, Object> resource, String key, String expected) {
|
||||
return expected == null || Objects.equals(expected, resource.get(key));
|
||||
}
|
||||
|
||||
private boolean containsIgnoreCase(Object value, String search) {
|
||||
return search == null || value instanceof String text
|
||||
&& text.toLowerCase(Locale.ROOT).contains(search.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
private boolean equalsIgnoreCase(String expected, Object actual) {
|
||||
return actual instanceof String text && expected.equalsIgnoreCase(text);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> jsonMap(Object value, int maximumLength) {
|
||||
if (!(value instanceof String text) || text.length() > maximumLength) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
Object parsed = QUIET_JSON.readValue(text, Object.class);
|
||||
return parsed instanceof Map<?, ?> map ? (Map<String, Object>) map : Map.of();
|
||||
} catch (IOException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean boundedText(Object value, int maximumLength) {
|
||||
return value == null || value instanceof String text && text.length() <= maximumLength;
|
||||
}
|
||||
|
||||
private boolean nullableNonnegative(Object value) {
|
||||
return value == null || nonnegativeLong(value) >= 0;
|
||||
}
|
||||
|
||||
private boolean nullableInteger(Object value, int minimum, int maximum) {
|
||||
if (value == null) {
|
||||
return true;
|
||||
}
|
||||
long converted = nonnegativeLong(value);
|
||||
return converted >= minimum && converted <= maximum;
|
||||
}
|
||||
|
||||
private Long nullableLong(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
long converted = nonnegativeLong(value);
|
||||
return converted < 0 ? null : converted;
|
||||
}
|
||||
|
||||
private boolean exactLong(Object value, long expected) {
|
||||
return nonnegativeLong(value) == expected;
|
||||
}
|
||||
|
||||
private long nonnegativeLong(Object value) {
|
||||
if (!(value instanceof Number number)) {
|
||||
return -1;
|
||||
}
|
||||
try {
|
||||
long converted = new BigDecimal(number.toString()).longValueExact();
|
||||
return converted >= 0 ? converted : -1;
|
||||
} catch (ArithmeticException | NumberFormatException ignored) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
private void put(Map<String, Object> values, String key, Object value) {
|
||||
if (value != null) {
|
||||
values.put(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
-71
@@ -1,71 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.runtime;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import lombok.ToString;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* Agent model provider configuration fallback.
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@ToString
|
||||
@ConfigurationProperties(prefix = AgentProviderProperties.PREFIX)
|
||||
public class AgentProviderProperties {
|
||||
|
||||
public static final String PREFIX = "hertzbeat.agent.provider";
|
||||
|
||||
private String type = "openai-compatible";
|
||||
|
||||
private String code = "";
|
||||
|
||||
private String model = "";
|
||||
|
||||
private String baseUrl = "";
|
||||
|
||||
@ToString.Exclude
|
||||
private String apiKey = "";
|
||||
|
||||
public void setType(String type) {
|
||||
// Empty properties may bind as null and environment values may be padded; canonicalize both here.
|
||||
this.type = type == null ? "" : type.trim();
|
||||
}
|
||||
|
||||
public void setCode(String code) {
|
||||
// Empty properties may bind as null and environment values may be padded; canonicalize both here.
|
||||
this.code = code == null ? "" : code.trim();
|
||||
}
|
||||
|
||||
public void setModel(String model) {
|
||||
// Empty properties may bind as null and environment values may be padded; canonicalize both here.
|
||||
this.model = model == null ? "" : model.trim();
|
||||
}
|
||||
|
||||
public void setBaseUrl(String baseUrl) {
|
||||
// Empty properties may bind as null and environment values may be padded; canonicalize both here.
|
||||
this.baseUrl = baseUrl == null ? "" : baseUrl.trim();
|
||||
}
|
||||
|
||||
public void setApiKey(String apiKey) {
|
||||
// Empty properties may bind as null and environment values may be padded; canonicalize both here.
|
||||
this.apiKey = apiKey == null ? "" : apiKey.trim();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user