Compare commits
2 Commits
main
...
fix/valida
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6acdb01eb4 | ||
|
|
1f617e9c63 |
128
.github/workflows/pipeline.yml
vendored
128
.github/workflows/pipeline.yml
vendored
@ -1,9 +1,5 @@
|
||||
name: Pipeline
|
||||
|
||||
env:
|
||||
VAULT_ADDR: https://vault.svc.plus
|
||||
DEFAULT_TARGET_HOST: jp-xhttp-contabo.svc.plus
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
@ -21,7 +17,7 @@ on:
|
||||
required: true
|
||||
default: true
|
||||
type: boolean
|
||||
ai_workspace_auth_token:
|
||||
internal_service_token:
|
||||
description: "Optional ACP auth token for deploy"
|
||||
required: false
|
||||
default: ""
|
||||
@ -30,7 +26,6 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pipeline-${{ github.ref }}
|
||||
@ -40,6 +35,9 @@ defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
DEFAULT_TARGET_HOST: jp-xhttp-contabo.svc.plus
|
||||
|
||||
jobs:
|
||||
production_state:
|
||||
name: Production State
|
||||
@ -53,40 +51,12 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Load Vault secrets
|
||||
id: vault
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
continue-on-error: true
|
||||
uses: hashicorp/vault-action@v2
|
||||
with:
|
||||
url: ${{ env.VAULT_ADDR }}
|
||||
method: jwt
|
||||
role: github-actions-xworkmate-bridge
|
||||
jwtGithubAudience: vault
|
||||
ignoreNotFound: true
|
||||
secrets: |
|
||||
kv/data/github-actions/xworkmate-bridge AI_WORKSPACE_AUTH_TOKEN | AI_WORKSPACE_AUTH_TOKEN
|
||||
|
||||
- name: Export bridge auth token
|
||||
if: ${{ steps.vault.outcome == 'success' && steps.vault.outputs.AI_WORKSPACE_AUTH_TOKEN != '' }}
|
||||
run: echo "AI_WORKSPACE_AUTH_TOKEN=${{ steps.vault.outputs.AI_WORKSPACE_AUTH_TOKEN }}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Probe current production bridge
|
||||
id: production_state
|
||||
env:
|
||||
BRIDGE_SERVER_URL: https://xworkmate-bridge.svc.plus
|
||||
BRIDGE_AUTH_TOKEN: ${{ secrets.INTERNAL_SERVICE_TOKEN }}
|
||||
run: |
|
||||
if [[ -z "${AI_WORKSPACE_AUTH_TOKEN:-}" ]]; then
|
||||
echo "::notice title=Production state skipped::AI_WORKSPACE_AUTH_TOKEN is unavailable from Vault; continuing without production bridge metadata."
|
||||
{
|
||||
echo "production_image="
|
||||
echo "production_tag="
|
||||
echo "production_commit="
|
||||
echo "production_version="
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
while IFS='=' read -r key value; do
|
||||
echo "${key}=${value}" >> "$GITHUB_OUTPUT"
|
||||
done < <(bash ./scripts/github-actions/report-production-state.sh "${BRIDGE_SERVER_URL}")
|
||||
@ -136,19 +106,6 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Load Vault secrets
|
||||
id: vault
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
uses: hashicorp/vault-action@v2
|
||||
with:
|
||||
url: ${{ env.VAULT_ADDR }}
|
||||
method: jwt
|
||||
role: github-actions-xworkmate-bridge
|
||||
jwtGithubAudience: vault
|
||||
ignoreNotFound: true
|
||||
secrets: |
|
||||
kv/data/github-actions/xworkmate-bridge GHCR_TOKEN | GHCR_TOKEN
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||
|
||||
@ -161,7 +118,7 @@ jobs:
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ vars.GHCR_USERNAME || github.repository_owner }}
|
||||
password: ${{ steps.vault.outputs.GHCR_TOKEN || github.token }}
|
||||
password: ${{ secrets.GHCR_TOKEN || github.token }}
|
||||
|
||||
- name: Resolve service image ref
|
||||
id: service_ref
|
||||
@ -212,55 +169,25 @@ jobs:
|
||||
deploy:
|
||||
name: Deploy
|
||||
needs: build
|
||||
if: ${{ github.event_name != 'pull_request' && github.event_name != 'push' }}
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
run_apply: ${{ steps.deploy_meta.outputs.run_apply }}
|
||||
env:
|
||||
INTERNAL_SERVICE_TOKEN: ${{ github.event_name == 'workflow_dispatch' && inputs.internal_service_token || secrets.INTERNAL_SERVICE_TOKEN }}
|
||||
GHCR_USERNAME: ${{ vars.GHCR_USERNAME || github.repository_owner }}
|
||||
GHCR_PASSWORD: ${{ secrets.GHCR_TOKEN || github.token }}
|
||||
steps:
|
||||
- name: Checkout service repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
path: xworkmate-bridge
|
||||
|
||||
- name: Load Vault secrets
|
||||
id: vault
|
||||
uses: hashicorp/vault-action@v2
|
||||
with:
|
||||
url: ${{ env.VAULT_ADDR }}
|
||||
method: jwt
|
||||
role: github-actions-xworkmate-bridge
|
||||
jwtGithubAudience: vault
|
||||
ignoreNotFound: true
|
||||
secrets: |
|
||||
kv/data/github-actions/xworkmate-bridge AI_WORKSPACE_AUTH_TOKEN | AI_WORKSPACE_AUTH_TOKEN ;
|
||||
kv/data/github-actions/xworkmate-bridge WORKSPACE_REPO_TOKEN | WORKSPACE_REPO_TOKEN ;
|
||||
kv/data/github-actions/xworkmate-bridge SINGLE_NODE_VPS_SSH_PRIVATE_KEY | SINGLE_NODE_VPS_SSH_PRIVATE_KEY ;
|
||||
kv/data/github-actions/xworkmate-bridge SINGLE_NODE_VPS_SSH_PRIVATE_KEY_B64 | SINGLE_NODE_VPS_SSH_PRIVATE_KEY_B64 ;
|
||||
kv/data/github-actions/xworkmate-bridge SSH_KNOWN_HOSTS | SSH_KNOWN_HOSTS
|
||||
|
||||
- name: Export deploy secrets
|
||||
run: |
|
||||
{
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" && -n "${{ inputs.ai_workspace_auth_token }}" ]]; then
|
||||
echo "AI_WORKSPACE_AUTH_TOKEN=${{ inputs.ai_workspace_auth_token }}"
|
||||
else
|
||||
echo "AI_WORKSPACE_AUTH_TOKEN=${{ steps.vault.outputs.AI_WORKSPACE_AUTH_TOKEN }}"
|
||||
fi
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Validate deploy secrets
|
||||
run: |
|
||||
if [[ -z "${AI_WORKSPACE_AUTH_TOKEN}" ]]; then
|
||||
echo "::error::AI_WORKSPACE_AUTH_TOKEN is empty. Provide it via the workflow_dispatch input, or ensure kv/data/github-actions/xworkmate-bridge AI_WORKSPACE_AUTH_TOKEN is readable from Vault."
|
||||
exit 1
|
||||
fi
|
||||
echo "AI_WORKSPACE_AUTH_TOKEN length=${#AI_WORKSPACE_AUTH_TOKEN}"
|
||||
|
||||
- name: Checkout playbooks repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: x-evor/playbooks
|
||||
token: ${{ steps.vault.outputs.WORKSPACE_REPO_TOKEN || github.token }}
|
||||
token: ${{ secrets.WORKSPACE_REPO_TOKEN || github.token }}
|
||||
path: playbooks
|
||||
|
||||
- name: Download build image ref artifact
|
||||
@ -284,7 +211,6 @@ jobs:
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install "ansible-core==2.18.3"
|
||||
ansible-galaxy collection install ansible.posix
|
||||
|
||||
- name: Resolve deployment settings
|
||||
id: deploy_meta
|
||||
@ -307,9 +233,8 @@ jobs:
|
||||
- name: Prepare runner SSH access
|
||||
working-directory: xworkmate-bridge
|
||||
env:
|
||||
SINGLE_NODE_VPS_SSH_PRIVATE_KEY: ${{ steps.vault.outputs.SINGLE_NODE_VPS_SSH_PRIVATE_KEY }}
|
||||
SINGLE_NODE_VPS_SSH_PRIVATE_KEY_B64: ${{ steps.vault.outputs.SINGLE_NODE_VPS_SSH_PRIVATE_KEY_B64 }}
|
||||
SSH_KNOWN_HOSTS: ${{ steps.vault.outputs.SSH_KNOWN_HOSTS }}
|
||||
SINGLE_NODE_VPS_SSH_PRIVATE_KEY: ${{ secrets.SINGLE_NODE_VPS_SSH_PRIVATE_KEY }}
|
||||
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
|
||||
run: bash ./scripts/github-actions/prepare-ssh.sh "${{ steps.deploy_meta.outputs.target_host }}" "${SSH_KNOWN_HOSTS}"
|
||||
|
||||
- name: Install native bridge binary on target
|
||||
@ -321,6 +246,7 @@ jobs:
|
||||
working-directory: playbooks
|
||||
env:
|
||||
ANSIBLE_CONFIG: ./ansible.cfg
|
||||
BRIDGE_AUTH_TOKEN: ${{ env.INTERNAL_SERVICE_TOKEN }}
|
||||
run: |
|
||||
CHECK_MODE_FLAG=""
|
||||
if [[ "${{ steps.deploy_meta.outputs.run_apply }}" != "true" ]]; then
|
||||
@ -378,34 +304,26 @@ jobs:
|
||||
needs:
|
||||
- build
|
||||
- deploy
|
||||
if: ${{ github.event_name != 'push' && needs.deploy.result == 'success' && needs.deploy.outputs.run_apply == 'true' }}
|
||||
if: ${{ needs.deploy.result == 'success' && needs.deploy.outputs.run_apply == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
BRIDGE_SERVER_URL: https://xworkmate-bridge.svc.plus
|
||||
INTERNAL_SERVICE_TOKEN: ${{ secrets.INTERNAL_SERVICE_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Load Vault secrets
|
||||
id: vault
|
||||
uses: hashicorp/vault-action@v2
|
||||
with:
|
||||
url: ${{ env.VAULT_ADDR }}
|
||||
method: jwt
|
||||
role: github-actions-xworkmate-bridge
|
||||
jwtGithubAudience: vault
|
||||
ignoreNotFound: true
|
||||
secrets: |
|
||||
kv/data/github-actions/xworkmate-bridge AI_WORKSPACE_AUTH_TOKEN | AI_WORKSPACE_AUTH_TOKEN
|
||||
|
||||
- name: Export bridge auth token
|
||||
run: echo "AI_WORKSPACE_AUTH_TOKEN=${{ steps.vault.outputs.AI_WORKSPACE_AUTH_TOKEN }}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Validate deployed endpoints
|
||||
env:
|
||||
BRIDGE_AUTH_TOKEN: ${{ env.INTERNAL_SERVICE_TOKEN }}
|
||||
run: bash ./scripts/github-actions/validate-deploy.sh "$(git rev-parse --short HEAD)" "${BRIDGE_SERVER_URL}"
|
||||
|
||||
- name: Validate public ACP contract
|
||||
env:
|
||||
BRIDGE_AUTH_TOKEN: ${{ env.INTERNAL_SERVICE_TOKEN }}
|
||||
run: bash ./scripts/github-actions/verify-public-rpc-contract.sh
|
||||
|
||||
- name: Validate OpenClaw session contract
|
||||
env:
|
||||
BRIDGE_AUTH_TOKEN: ${{ env.INTERNAL_SERVICE_TOKEN }}
|
||||
run: bash ./scripts/github-actions/validate-openclaw-session.sh
|
||||
|
||||
109
.github/workflows/runtime-release.yml
vendored
109
.github/workflows/runtime-release.yml
vendored
@ -1,109 +0,0 @@
|
||||
name: Build XWorkmate Bridge Runtime Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, release/**]
|
||||
paths:
|
||||
- "**/*.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .github/workflows/runtime-release.yml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: xworkmate-bridge-runtime-release-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.os }}-${{ matrix.arch }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [linux, darwin]
|
||||
arch: [amd64, arm64]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Test
|
||||
run: go test ./...
|
||||
|
||||
- name: Build runtime asset
|
||||
env:
|
||||
TARGET_OS: ${{ matrix.os }}
|
||||
TARGET_ARCH: ${{ matrix.arch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
root="dist/runtime/xworkmate-bridge"
|
||||
mkdir -p "${root}/bin" dist/assets
|
||||
CGO_ENABLED=0 GOOS="${TARGET_OS}" GOARCH="${TARGET_ARCH}" \
|
||||
go build -buildvcs=false -trimpath \
|
||||
-ldflags "-X main.buildCommit=${GITHUB_SHA}" \
|
||||
-o "${root}/bin/xworkmate-go-core" .
|
||||
cat > "${root}/manifest.json" <<JSON
|
||||
{
|
||||
"component": "xworkmate-bridge",
|
||||
"commit": "${GITHUB_SHA}",
|
||||
"os": "${TARGET_OS}",
|
||||
"arch": "${TARGET_ARCH}",
|
||||
"binary": "bin/xworkmate-go-core"
|
||||
}
|
||||
JSON
|
||||
tar -czf "dist/assets/xworkmate-bridge-${TARGET_OS}-${TARGET_ARCH}.tar.gz" \
|
||||
-C dist/runtime xworkmate-bridge
|
||||
(
|
||||
cd dist/assets
|
||||
# Name the per-job checksum file by OS *and* ARCH. Keying on ARCH
|
||||
# alone makes the linux/darwin jobs of the same arch both emit
|
||||
# SHA256SUMS-<arch>, which then clobber each other under the
|
||||
# publish job's merge-multiple download — leaving SHA256SUMS with
|
||||
# only 2 of the 4 platforms and breaking arm64 (and darwin-amd64)
|
||||
# consumers with "missing checksum".
|
||||
sha256sum -- ./*.tar.gz | sed 's# \./# #' > "SHA256SUMS-${TARGET_OS}-${TARGET_ARCH}"
|
||||
)
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: xworkmate-bridge-${{ matrix.os }}-${{ matrix.arch }}
|
||||
path: |
|
||||
dist/assets/*.tar.gz
|
||||
dist/assets/SHA256SUMS-*
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: xworkmate-bridge-*
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Publish assets
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="runtime-${GITHUB_SHA::12}"
|
||||
cat dist/SHA256SUMS-* | sort -u > dist/SHA256SUMS
|
||||
rm -f dist/SHA256SUMS-*
|
||||
if gh release view "${tag}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then
|
||||
gh release upload "${tag}" dist/*.tar.gz dist/SHA256SUMS \
|
||||
--repo "${GITHUB_REPOSITORY}" --clobber
|
||||
else
|
||||
gh release create "${tag}" dist/*.tar.gz dist/SHA256SUMS \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--target "${GITHUB_SHA}" \
|
||||
--title "XWorkmate Bridge runtime ${GITHUB_SHA::12}" \
|
||||
--notes "Prebuilt bridge binaries. No target-host Go build is required."
|
||||
fi
|
||||
44
.github/workflows/validate-release-pr.yml
vendored
44
.github/workflows/validate-release-pr.yml
vendored
@ -1,44 +0,0 @@
|
||||
name: Validate Release PR
|
||||
|
||||
# release/* 分支的发布策略门禁:仅接受 hotfix/* 或带 cherry-pick/backport 标签的 PR。
|
||||
# 详见 iac_modules/docs/tldr-github-branch-model.md
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened, labeled, unlabeled]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
validate-release-source:
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.base_ref, 'release/')
|
||||
steps:
|
||||
- name: Check PR source branch
|
||||
run: |
|
||||
SRC="${{ github.head_ref }}"
|
||||
TGT="${{ github.base_ref }}"
|
||||
LABELS="${{ join(github.event.pull_request.labels.*.name, ',') }}"
|
||||
|
||||
echo "🔍 Validating PR into release branch"
|
||||
echo " source: $SRC"
|
||||
echo " target: $TGT"
|
||||
echo " labels: $LABELS"
|
||||
|
||||
if [[ "$SRC" =~ ^hotfix/ ]]; then
|
||||
echo "✅ Allowed: hotfix/* branch"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$LABELS" =~ (^|,)(cherry-pick|backport)(,|$) ]]; then
|
||||
echo "✅ Allowed: cherry-pick/backport labeled PR"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "❌ Rejected."
|
||||
echo "release/* 仅接受:"
|
||||
echo " - 来自 hotfix/* 的 PR"
|
||||
echo " - 带 cherry-pick 或 backport 标签的 PR(已验证 feature 的 backport/cherry-pick)"
|
||||
echo "禁止从 main / develop / feature/* 直接合并到 release/*。"
|
||||
exit 1
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,5 +1,4 @@
|
||||
build/
|
||||
dist/
|
||||
.env
|
||||
xworkmate-bridge
|
||||
xworkmate-go-core-linux
|
||||
|
||||
13
README.md
13
README.md
@ -62,16 +62,7 @@ The deploy stage checks out:
|
||||
- this service repository into `xworkmate-bridge/`
|
||||
- the `x-evor/playbooks` repository into `playbooks/`
|
||||
|
||||
Then it installs the native `linux/amd64` bridge binary with
|
||||
`scripts/github-actions/deploy-native-binary.sh`. The native bridge runs as the
|
||||
`ubuntu` user's systemd user service:
|
||||
|
||||
- binary: `/home/ubuntu/.local/bin/xworkmate-go-core`
|
||||
- unit: `/home/ubuntu/.config/systemd/user/xworkmate-bridge.service`
|
||||
- restart: `systemctl --user restart xworkmate-bridge.service`
|
||||
|
||||
During migration the script performs a one-time stop/disable of the old system
|
||||
unit, then deploys and restarts through `ubuntu@<target>`.
|
||||
Then it runs `playbooks/deploy_xworkmate_bridge_vhosts.yml`, which builds the service for `linux/amd64` and deploys it to the target host with Ansible.
|
||||
|
||||
### Validate stage
|
||||
|
||||
@ -94,7 +85,7 @@ Optional GitHub secrets:
|
||||
|
||||
Optional workflow input:
|
||||
|
||||
- `ai_workspace_auth_token`: manual dispatch input that is forwarded as `AI_WORKSPACE_AUTH_TOKEN`
|
||||
- `internal_service_token`: manual dispatch input that is forwarded to Ansible as `INTERNAL_SERVICE_TOKEN`
|
||||
|
||||
## Environment
|
||||
|
||||
|
||||
@ -39,8 +39,7 @@
|
||||
|
||||
环境变量:
|
||||
|
||||
- `AI_WORKSPACE_AUTH_TOKEN`:主共享 token,用于 bridge 入站鉴权、上游 provider 转发、OpenClaw Gateway 重签发与任务转发 fallback。
|
||||
- `BRIDGE_AUTH_TOKEN`:旧主 token。没有 `AI_WORKSPACE_AUTH_TOKEN` 时继续生效并参与上游转发,用于存量租户兼容,直到 `AI_WORKSPACE_AUTH_TOKEN` 完成彻底替代后下线。
|
||||
- `BRIDGE_AUTH_TOKEN`
|
||||
- `BRIDGE_REVIEW_AUTH_TOKEN`(可选):Apple review / beta 工测专用临时 token。清空该环境变量并重启/reload bridge 即可单独关停,不影响主 token。
|
||||
- `ACP_ALLOWED_ORIGINS`
|
||||
|
||||
@ -49,9 +48,8 @@
|
||||
- `/acp` 与 `/acp/rpc` 都做 origin allowlist 校验
|
||||
- 空 `Origin` 默认允许
|
||||
- `/api/ping`、`/acp`、`/acp/rpc` 在任一 bridge token 非空时都要求 bearer header
|
||||
- `AI_WORKSPACE_AUTH_TOKEN`、`BRIDGE_AUTH_TOKEN` 与 `BRIDGE_REVIEW_AUTH_TOKEN` 都为空时默认放行
|
||||
- `BRIDGE_AUTH_TOKEN` 与 `BRIDGE_REVIEW_AUTH_TOKEN` 都为空时默认放行
|
||||
- token 非空时,接受裸 token 或 `Bearer <token>`
|
||||
- 线上 Caddy 入口必须与 bridge origin 保持同一 token set:主 `AI_WORKSPACE_AUTH_TOKEN`、兼容 `BRIDGE_AUTH_TOKEN` 与可选 `BRIDGE_REVIEW_AUTH_TOKEN` 都应放行;无 token 仍返回 `401`
|
||||
- `xworkmate-app` 生产 Origin 固定为 `https://xworkmate.svc.plus`
|
||||
|
||||
## 3.1 Lightweight Distributed Task Forwarding
|
||||
@ -140,7 +138,7 @@ distributed:
|
||||
- `bridge_endpoint` 是 peer bridge base URL,bridge 会按当前请求路径拼接 `/acp/rpc` 或 `/gateway/openclaw`
|
||||
- 同步消息不能走公网;`bridge_endpoint` 必须是 loopback、private、link-local 这类本机或 VPN 内网地址,用于 WireGuard over VLESS 等隧道已经提供加密的场景
|
||||
- 只要求本机网络能路由到 endpoint;bridge 不依赖 config center 或额外注册中心
|
||||
- `task_forward_token` 为空时复用本机 `AI_WORKSPACE_AUTH_TOKEN`;未配置时兼容复用 `BRIDGE_AUTH_TOKEN`
|
||||
- `task_forward_token` 为空时复用本机 `BRIDGE_AUTH_TOKEN`
|
||||
- 转发请求会带 `X-XWorkmate-Bridge-Forwarded: 1`
|
||||
- `X-XWorkmate-Forward-Source` 是源节点,`X-XWorkmate-Forward-Target` 是最终目标节点
|
||||
- `X-XWorkmate-Forward-Hop` 逐跳递增,超过 `forwarding.hop_limit` 时拒绝转发,避免循环
|
||||
@ -158,7 +156,7 @@ distributed:
|
||||
BRIDGE_SERVER_URL=https://xworkmate-bridge.svc.plus
|
||||
BRIDGE_WS_URL=wss://xworkmate-bridge.svc.plus/acp
|
||||
BRIDGE_HTTP_RPC_URL=https://xworkmate-bridge.svc.plus/acp/rpc
|
||||
Authorization: Bearer $AI_WORKSPACE_AUTH_TOKEN
|
||||
Authorization: Bearer $BRIDGE_AUTH_TOKEN
|
||||
Origin: https://xworkmate.svc.plus
|
||||
```
|
||||
|
||||
|
||||
@ -109,7 +109,7 @@ Gateway access remains bridge-owned via JSON-RPC methods:
|
||||
|
||||
Upstream authentication is unified for both ACP and gateway routes:
|
||||
|
||||
- `Authorization: Bearer $AI_WORKSPACE_AUTH_TOKEN`
|
||||
- `Authorization: Bearer $INTERNAL_SERVICE_TOKEN`
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@ -272,7 +272,7 @@ OpenClaw 的 `session.message` 复用同一 `sessionId` / `threadId`,继续提
|
||||
|
||||
## 8. 线上环境事实
|
||||
|
||||
以下为 2026-06-06 通过 `ssh ubuntu@xworkmate-bridge.svc.plus` 核对的部署事实。它们用于 bridge 运维和验证,不属于 APP contract。
|
||||
以下为 2026-05-03 通过 `ssh root@xworkmate-bridge.svc.plus` 核对的部署事实。它们用于 bridge 运维和验证,不属于 APP contract。
|
||||
|
||||
### Caddy
|
||||
|
||||
@ -292,23 +292,11 @@ Caddy 层要求:
|
||||
Authorization: Bearer $BRIDGE_AUTH_TOKEN
|
||||
```
|
||||
|
||||
如果配置了 Apple review / beta token,Caddy 层也必须接受:
|
||||
|
||||
```http
|
||||
Authorization: Bearer $BRIDGE_REVIEW_AUTH_TOKEN
|
||||
```
|
||||
|
||||
验证标准:
|
||||
|
||||
- 无 token:`401`
|
||||
- 主 token:`200`
|
||||
- review token:`200`
|
||||
|
||||
### Systemd / Local Listeners
|
||||
|
||||
| Unit / Runtime | Listener | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `~/.config/systemd/user/xworkmate-bridge.service` | `127.0.0.1:8787` | Public bridge origin, runs as `ubuntu`, binary at `/home/ubuntu/.local/bin/xworkmate-go-core` |
|
||||
| `xworkmate-bridge.service` | `127.0.0.1:8787` | Public bridge origin |
|
||||
| `acp-codex.service` | `127.0.0.1:9001` | Codex ACP backend |
|
||||
| `acp-gemini.service` | `127.0.0.1:8791` | Gemini adapter |
|
||||
| `acp-hermes.service` | `127.0.0.1:3920` | Hermes adapter |
|
||||
@ -317,7 +305,7 @@ Authorization: Bearer $BRIDGE_REVIEW_AUTH_TOKEN
|
||||
|
||||
这些地址只允许 bridge 内部使用。APP 不保存、不展示、不请求这些地址。
|
||||
|
||||
验证时 `systemctl --user status xworkmate-bridge.service` 为 `active`,系统级 `/etc/systemd/system/xworkmate-bridge.service` 为 `disabled/inactive`。`acp-codex`、`acp-gemini`、`acp-hermes`、`acp-opencode` 仍由现有本机 service 提供。APP contract 只记录 provider/gateway 能力,不把 systemd unit 类型作为 APP 可见状态。
|
||||
验证时 `xworkmate-bridge`、`acp-codex`、`acp-gemini`、`acp-hermes`、`acp-opencode` 均为 `active`;`openclaw-gateway.service` 返回 `inactive`,但 `ss` 显示 `openclaw` 进程仍监听 `127.0.0.1:18789` 和 `[::1]:18789`。因此 APP contract 只记录 `openclaw` 作为 `gatewayProviders` 能力,不把 systemd unit 状态作为 APP 可见状态。
|
||||
|
||||
## 9. 线上验证结果
|
||||
|
||||
|
||||
@ -33,10 +33,6 @@
|
||||
- [Remote Agent Local Workspace Test Matrix](./testing/remote-agent-local-workspace-test-matrix.md)
|
||||
- [Gemini ACP Adapter Notes](./gemini-acp-adapter.md)
|
||||
|
||||
### 5. Runbook
|
||||
|
||||
- [WebRTC Remote Desktop White Screen Runbook](./runbooks/webrtc-remote-desktop-white-screen-runbook.md)
|
||||
|
||||
## 文档组织原则
|
||||
|
||||
- `docs/api-reference.md` 是对外运行契约的唯一真相来源。
|
||||
|
||||
@ -1,632 +0,0 @@
|
||||
# WebRTC Remote Desktop White Screen Runbook
|
||||
|
||||
本文档用于排查与修复 `xworkmate-bridge` 的 WebRTC 远程桌面频发白屏问题,覆盖编码、RTP、WebRTC 协商、远端部署验证和回滚流程。
|
||||
|
||||
适用范围:
|
||||
|
||||
- Bridge 仓库:`/Users/shenlan/workspaces/ai-workspace-lab/xworkmate-bridge`
|
||||
- 远端主机:`ubuntu@xworkmate-bridge.svc.plus`
|
||||
- user service:`xworkmate-bridge.service`
|
||||
- APP 侧入口:`xworkmate-app` 的 Remote Desktop 面板
|
||||
|
||||
## 目标
|
||||
|
||||
- 把“已连接但无画面 / 长时间等待首帧”拆解到编码、RTP、WebRTC、前端显示四层。
|
||||
- 让 Bridge 输出 browser-friendly H.264:
|
||||
- `baseline` / `constrained-baseline`
|
||||
- `yuv420p` / `I420` / 4:2:0
|
||||
- `zerolatency`
|
||||
- `key-int-max=30`
|
||||
- 定期 SPS / PPS
|
||||
- 在白屏现场拿到可判定证据,而不是只看“connected”状态。
|
||||
|
||||
## 症状定义
|
||||
|
||||
- APP 显示 `已连接`,但视频区域白屏。
|
||||
- APP 显示 `WebRTC 已连接,正在等待远程桌面首帧...`,长时间不消失。
|
||||
- 首次连接偶发成功,断开重连后更容易白屏。
|
||||
|
||||
## 前置检查:先排除账号同步 / 鉴权问题
|
||||
|
||||
如果 APP 还没进入 WebRTC 连接状态,不要直接按白屏处理。下面这些信号说明请求尚未进入远程桌面 offer / RTP 链路:
|
||||
|
||||
- APP 远程桌面面板显示 `已断开`,文案为 `未开启 AI 工作空间流。点击“连接AI工作空间”启动视频流。`
|
||||
- APP 账号同步显示 `账号同步状态:失败`
|
||||
- APP 同步说明显示 `Bridge token expired or rejected. Please re-sync the account token.`
|
||||
- APP 关于页显示 Bridge runtime `Status: unauthorized`
|
||||
- Bridge 日志里点击 APP 后没有新的 `Starting Remote Desktop session`、`xworkmate.desktop.offer` 或 `WebRTC RTP stats`
|
||||
|
||||
这类现场的首要结论是:App 侧 managed bridge token 已过期、被拒绝或未重新同步。此时 bridge 服务可能已经是最新版本且运行正常,但 APP 没有可用凭据调用受保护接口。
|
||||
|
||||
2026-06-07 现场确认过一个容易漏掉的变体:Go bridge origin 已接受 `BRIDGE_REVIEW_AUTH_TOKEN`,但 Caddy 公网入口只放行主 `BRIDGE_AUTH_TOKEN`,导致 `review@svc.plus` 走公网 `/api/ping` 和 `/acp/rpc` 返回 `401`。这同样表现为 APP token 被拒绝,且不会进入 WebRTC/RTP 层。
|
||||
|
||||
处理步骤:
|
||||
|
||||
1. 在 APP 设置页执行 `重新同步`。
|
||||
2. 同步成功后刷新版本信息,确认 Bridge runtime 不再是 `unauthorized`。
|
||||
3. 如果账号是 `review@svc.plus` 或使用 review/beta token,确认公网 Caddy 入口也放行 review token。
|
||||
4. 再回到远程桌面面板点击连接,进入 WebRTC / RTP 排查。
|
||||
|
||||
远端校验方式:
|
||||
|
||||
```bash
|
||||
ssh ubuntu@xworkmate-bridge.svc.plus '
|
||||
curl -sS -o /dev/null -w "%{http_code}\n" https://xworkmate-bridge.svc.plus/api/ping
|
||||
'
|
||||
```
|
||||
|
||||
无 token 返回 `401` 是预期结果;不要把它误判为部署失败。要确认服务端版本,需要使用 user service 环境里的 token,且不要把 token 打印到日志或文档:
|
||||
|
||||
```bash
|
||||
ssh ubuntu@xworkmate-bridge.svc.plus '
|
||||
TOKEN=$(systemctl --user show -p Environment --value xworkmate-bridge.service |
|
||||
tr " " "\n" |
|
||||
sed -n "s/^BRIDGE_AUTH_TOKEN=//p")
|
||||
curl -sS -H "Authorization: Bearer ${TOKEN}" https://xworkmate-bridge.svc.plus/api/ping
|
||||
unset TOKEN
|
||||
'
|
||||
```
|
||||
|
||||
期望看到 `status=ok`,并且 `commit` 等于最新部署 commit。
|
||||
|
||||
如果配置了 `BRIDGE_REVIEW_AUTH_TOKEN`,必须额外验证公网入口也接受 review token:
|
||||
|
||||
```bash
|
||||
ssh ubuntu@xworkmate-bridge.svc.plus '
|
||||
TOKEN=$(systemctl --user show -p Environment --value xworkmate-bridge.service |
|
||||
tr " " "\n" |
|
||||
sed -n "s/^BRIDGE_REVIEW_AUTH_TOKEN=//p")
|
||||
if [ -n "${TOKEN}" ]; then
|
||||
curl -sS -o /dev/null -w "%{http_code}\n" \
|
||||
-H "Authorization: Bearer ${TOKEN}" \
|
||||
https://xworkmate-bridge.svc.plus/api/ping
|
||||
fi
|
||||
unset TOKEN
|
||||
'
|
||||
```
|
||||
|
||||
期望返回 `200`。如果本机 `127.0.0.1:8787` 返回 `200` 但公网 HTTPS 返回 `401`,问题在 Caddy / ingress token allowlist,不在 WebRTC。
|
||||
|
||||
## 根因判断速查
|
||||
|
||||
### 1. ICE connected,但 Bridge RTP 包不增长
|
||||
|
||||
判断:
|
||||
|
||||
- Bridge 日志中 `WebRTC RTP stats: packets=0`
|
||||
- 或 `Capture pipeline exited with error`
|
||||
|
||||
说明:
|
||||
|
||||
- 问题在 Bridge capture / encoder / 本机 RTP 发送侧,不是公网网络抖动。
|
||||
|
||||
### 2. RTP 包增长,但 APP `packetsReceived` 不增长
|
||||
|
||||
判断:
|
||||
|
||||
- Bridge 端 `packets`、`bytes` 持续增长
|
||||
- APP 侧 inbound stats 里 `packetsReceived` 不增长
|
||||
|
||||
说明:
|
||||
|
||||
- 问题在 ICE candidate、NAT、TURN、链路可达性或浏览器收包侧。
|
||||
|
||||
### 3. `packetsReceived` 增长,但 `framesDecoded` 不增长
|
||||
|
||||
判断:
|
||||
|
||||
- APP 侧 inbound video stats 里 `packetsReceived > 0`
|
||||
- `framesDecoded == 0`
|
||||
|
||||
说明:
|
||||
|
||||
- 问题高度集中在 H.264 profile / pixel format / SPS/PPS / 解码兼容。
|
||||
|
||||
### 4. `framesDecoded` 增长,但仍白屏
|
||||
|
||||
判断:
|
||||
|
||||
- APP 侧 stats 能看到 decoded frames
|
||||
- UI 仍然空白
|
||||
|
||||
说明:
|
||||
|
||||
- 问题在 Flutter renderer / track attach / view lifecycle / stale stream。
|
||||
|
||||
### 5. Bridge RTP 增长,但同一 `sessionId` 被反复 stop/start
|
||||
|
||||
判断:
|
||||
|
||||
- Bridge 日志里 `WebRTC RTP stats` 持续增长,`writeErrors=0`
|
||||
- 同一时间段频繁出现:
|
||||
|
||||
```text
|
||||
Stopping Remote Desktop session: remote-desktop-session
|
||||
Starting Remote Desktop session: remote-desktop-session
|
||||
Closing WebRTC server...
|
||||
```
|
||||
|
||||
- 本机同时存在多个 `XWorkmate` 进程,或快速断开 / 重连 / 重开窗口
|
||||
- APP 仍停在 `WebRTC 已连接,正在等待远程桌面首帧...`
|
||||
|
||||
说明:
|
||||
|
||||
- 问题不是编码器或公网 RTP 发送层,而是客户端会话抢占 / stale PeerConnection。
|
||||
- APP 旧版本固定使用 `remote-desktop-session`,多个 app 实例或重连会互相关闭同一个远端 desktop session。被抢占的客户端可能还短暂保持 `connected` 状态,但远端 RTP pipeline 已经被新 offer 替换,表现为等待首帧。
|
||||
- 修复方式是 APP 每个 DesktopView / PeerConnection 使用唯一 desktop session id,并且 video-only desktop offer 不再声明无用 audio recvonly transceiver。
|
||||
|
||||
### 6. 画面已稳定,但远程桌面操作不流畅
|
||||
|
||||
判断:
|
||||
|
||||
- 视频区域能显示远程桌面,白屏明显缓解
|
||||
- Bridge 端 `WebRTC RTP stats` 连续增长,`writeErrors=0`
|
||||
- APP 操作表现为鼠标跟手性差、点击延迟、拖动不顺、键盘输入偶发滞后
|
||||
- Bridge 日志中没有 `Capture pipeline exited with error`,也没有明显 RTP write error
|
||||
|
||||
说明:
|
||||
|
||||
- 此时不要继续优先怀疑 H.264 profile 或 RTP 发送层。应转向输入链路排查:
|
||||
- APP `PointerHoverEvent` / `PointerMoveEvent` 是否把每个移动事件都发到 data channel
|
||||
- WebRTC data channel 是否出现 `bufferedAmount` 堆积
|
||||
- Bridge data channel 收到的 input 事件是否能及时写入 persistent `xdotool`
|
||||
- `xdotool` stdin 写失败后的恢复路径是否会卡住 injector
|
||||
- 鼠标移动是可丢弃的高频状态事件,应以“最新位置优先”为原则;点击、键盘、滚轮是关键动作,不能因背压被丢弃。
|
||||
- 修复方式是 APP 对 mouse move 做节流 / 去重 / 背压丢弃,Bridge 修复 xdotool 写失败后的恢复死锁。
|
||||
|
||||
## 本次修复结论
|
||||
|
||||
本次真实根因不是单纯网络延迟,而是两段串联问题:
|
||||
|
||||
1. 旧 GStreamer pipeline 输出 `high-4:4:4` H.264,`profile-level-id=f40020`,对 WebRTC / browser 解码不友好。
|
||||
2. 修成 `I420` 后暴露远端桌面真实尺寸 `1352x847`,高度为奇数,4:2:0 编码链路无法稳定工作,pipeline 直接退出,导致 RTP 始终为 0。
|
||||
|
||||
本次二次排查还发现一个入口层问题:
|
||||
|
||||
3. Caddy 公网入口曾只放行主 `BRIDGE_AUTH_TOKEN`,未放行 user service 中的 `BRIDGE_REVIEW_AUTH_TOKEN`。因此 `review@svc.plus` 会看到 `Bridge token expired or rejected`,并且无法发起 `xworkmate.desktop.offer`。
|
||||
|
||||
2026-06-08 复发排查结论:
|
||||
|
||||
4. Bridge 运行版本 `v1.0-beta2` / commit `0a0d04f` 的 H.264 和 RTP 发送链路是健康的:远端日志确认 `format=(string)I420`、`profile=(string)baseline`、`profile-level-id=(string)42c01f`,并且 `WebRTC RTP stats` 持续增长、`writeErrors=0`。
|
||||
5. 本机同时运行了多个 `XWorkmate` 进程,且 APP 侧仍使用固定 `sessionId='remote-desktop-session'`。远端日志在同一时间段反复出现同一个 session 的 stop/start,说明新连接抢占并关闭旧 PeerConnection / capture pipeline。这个链路会让被抢占的 APP 视图停在“WebRTC 已连接,正在等待远程桌面首帧...”,属于客户端会话生命周期问题。
|
||||
6. 同步修复 APP:为每个 DesktopView 生成唯一 `remote-desktop-*` session id,并将 desktop SDP offer 简化为 video recvonly,避免 video-only Bridge 被无用 audio m-line 干扰。
|
||||
|
||||
2026-06-09 操作不流畅排查结论:
|
||||
|
||||
7. 白屏缓解后,远端日志显示视频发送链路仍然稳定:`WebRTC RTP stats` 每 5 秒约 1100-1200 包,`byteDelta` 约 0.95MB,`writeErrors=0`,没有新的 H.264 profile / I420 / RTP 发送异常。
|
||||
8. 操作不流畅的高概率链路转移到输入通道:APP 侧鼠标 hover / move 事件过密,容易把 data channel 塞成旧轨迹队列;Bridge 侧虽然已有 16ms mouse move worker,但 APP 仍会持续发送大量可丢弃的中间位置。
|
||||
9. 还发现 Bridge `xdotool` stdin 写失败恢复路径存在风险:旧代码持有 injector mutex 时调用 `Start()`,`Start()` 会再次获取同一把锁。一旦 xdotool pipe 异常,输入注入可能死锁,表现为画面仍动但远程操作卡住。
|
||||
10. 同步修复 APP 与 Bridge:APP 对 mouse move 做 16ms 节流、坐标去重、data channel 背压时只丢弃 mouse move;Bridge 修复 xdotool 写失败后的无锁重启路径。
|
||||
|
||||
修复后的稳定策略:
|
||||
|
||||
- 强制把 capture 输出转换到 `I420`
|
||||
- 强制缩放到偶数分辨率
|
||||
- H.264 限制为 `baseline`
|
||||
- `rtph264pay config-interval=1`
|
||||
- `x264enc` 启用 `zerolatency`
|
||||
- Bridge 定期输出 RTP stats
|
||||
- APP 在等待首帧时输出 inbound video stats
|
||||
- APP 每个远程桌面视图使用唯一 desktop session id,避免多实例 / 重连抢占同一个 Bridge session
|
||||
- APP desktop offer 只声明 video recvonly transceiver,避免无用 audio m-line 增加协商不确定性
|
||||
- APP mouse move 事件按约 60fps 节流并去重,点击前强制同步最新坐标
|
||||
- APP data channel 发生背压时只丢弃过期 `mouse_move`,不丢 `mouse_down` / `mouse_up` / `key_*` / `scroll`
|
||||
- Bridge persistent `xdotool` 写失败时必须释放 mutex 后再重启 injector,避免输入链路死锁
|
||||
- Caddy 公网入口同时放行主 token 与 review token,并验证无 token 仍为 `401`
|
||||
- 只保留 user service 作为当前 bridge origin,避免 system service 与 user service 抢占 `127.0.0.1:8787`
|
||||
|
||||
注意:如果 APP 当前显示 `Bridge token expired or rejected` 或 Bridge runtime `unauthorized`,这是鉴权前置问题,不是本次 H.264 / RTP 白屏根因的复发。必须先重新同步账号 token,再验证 WebRTC 链路。
|
||||
|
||||
## 代码落点
|
||||
|
||||
Bridge:
|
||||
|
||||
- [internal/desktop/pipeline.go](/Users/shenlan/workspaces/ai-workspace-lab/xworkmate-bridge/internal/desktop/pipeline.go)
|
||||
- [internal/desktop/webrtc.go](/Users/shenlan/workspaces/ai-workspace-lab/xworkmate-bridge/internal/desktop/webrtc.go)
|
||||
- [internal/desktop/pipeline_test.go](/Users/shenlan/workspaces/ai-workspace-lab/xworkmate-bridge/internal/desktop/pipeline_test.go)
|
||||
|
||||
APP 诊断:
|
||||
|
||||
- `/Users/shenlan/workspaces/ai-workspace-lab/xworkmate-app/lib/features/desktop/desktop_client.dart`
|
||||
- `/Users/shenlan/workspaces/ai-workspace-lab/xworkmate-app/lib/features/desktop/desktop_input_handler.dart`
|
||||
- `/Users/shenlan/workspaces/ai-workspace-lab/xworkmate-app/lib/features/desktop/desktop_view.dart`
|
||||
- `/Users/shenlan/workspaces/ai-workspace-lab/xworkmate-app/test/features/desktop/desktop_client_test.dart`
|
||||
- `/Users/shenlan/workspaces/ai-workspace-lab/xworkmate-app/test/features/desktop/desktop_input_handler_test.dart`
|
||||
|
||||
2026-06-08 复发修复 APP 落点:
|
||||
|
||||
- `desktop_client.dart`:新增唯一 desktop session id helper;desktop offer 只添加 video recvonly transceiver。
|
||||
- `desktop_view.dart`:不再硬编码 `remote-desktop-session`。
|
||||
- `desktop_client_test.dart`:覆盖并行 app 实例生成不同 session id。
|
||||
|
||||
2026-06-09 操作流畅度修复落点:
|
||||
|
||||
- APP `desktop_input_handler.dart`:mouse move / hover 按 16ms 节流,重复坐标去重,点击前强制发送最新坐标。
|
||||
- APP `desktop_client.dart`:data channel `bufferedAmount` 超过阈值时只丢弃过期 `mouse_move`。
|
||||
- APP `desktop_input_handler_test.dart` / `desktop_client_test.dart`:覆盖节流、去重、点击前坐标同步与背压丢弃策略。
|
||||
- Bridge `internal/desktop/input.go`:修复 xdotool stdin 写失败后恢复路径的 mutex 重入死锁。
|
||||
|
||||
## 期望日志
|
||||
|
||||
健康的编码与 RTP 发送链路应该出现下面这类信号:
|
||||
|
||||
```text
|
||||
Starting capture pipeline: gst-launch-1.0 ... videoconvert ! videoscale ! video/x-raw,format=I420,width=1280,height=720,framerate=30/1 ! x264enc ... tune=zerolatency ... key-int-max=30 ! video/x-h264,profile=baseline ! rtph264pay config-interval=1 pt=96
|
||||
... GstVideoScale: caps = video/x-raw, width=(int)1280, height=(int)720, format=(string)I420
|
||||
... GstX264Enc: caps = video/x-h264 ... profile=(string)baseline
|
||||
... GstRtpH264Pay: caps = application/x-rtp ... profile-level-id=(string)42c01f, profile=(string)constrained-baseline
|
||||
WebRTC RTP stats: packets=1471 bytes=1236907 packetDelta=1471 byteDelta=1236907 writeErrors=0
|
||||
```
|
||||
|
||||
不健康的旧信号:
|
||||
|
||||
```text
|
||||
profile=(string)high-4:4:4
|
||||
profile-level-id=(string)f40020
|
||||
Capture pipeline exited with error: exit status 1
|
||||
WebRTC RTP stats: packets=0 bytes=0
|
||||
```
|
||||
|
||||
## 远端部署步骤
|
||||
|
||||
### 1. 本地测试
|
||||
|
||||
```bash
|
||||
cd /Users/shenlan/workspaces/ai-workspace-lab/xworkmate-bridge
|
||||
go test ./internal/desktop ./internal/acp
|
||||
```
|
||||
|
||||
如果 APP 侧同步有诊断改动,同时执行:
|
||||
|
||||
```bash
|
||||
cd /Users/shenlan/workspaces/ai-workspace-lab/xworkmate-app
|
||||
flutter test test/features/desktop/desktop_client_test.dart
|
||||
flutter analyze lib/features/desktop/desktop_client.dart lib/features/desktop/desktop_view.dart test/features/desktop/desktop_client_test.dart
|
||||
```
|
||||
|
||||
### 2. 构建 Linux binary
|
||||
|
||||
远端服务运行在 Linux x86_64。不要把 macOS 本机构建物直接部署到远端,否则会出现 `Exec format error`。
|
||||
|
||||
推荐命令:
|
||||
|
||||
```bash
|
||||
cd /Users/shenlan/workspaces/ai-workspace-lab/xworkmate-bridge
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 OUTPUT_PATH=build/bin/xworkmate-go-core-linux-amd64 make build
|
||||
file build/bin/xworkmate-go-core-linux-amd64
|
||||
```
|
||||
|
||||
### 3. 部署远端
|
||||
|
||||
```bash
|
||||
cd /Users/shenlan/workspaces/ai-workspace-lab/xworkmate-bridge
|
||||
scripts/github-actions/deploy-native-binary.sh xworkmate-bridge.svc.plus build/bin/xworkmate-go-core-linux-amd64 <short-commit>
|
||||
```
|
||||
|
||||
脚本会:
|
||||
|
||||
- 恢复 `BRIDGE_AUTH_TOKEN`
|
||||
- 保留/恢复 `BRIDGE_REVIEW_AUTH_TOKEN`
|
||||
- 上传 binary 到远端
|
||||
- 安装到 `/home/ubuntu/.local/bin/xworkmate-go-core`
|
||||
- 重启 `systemctl --user` 的 `xworkmate-bridge.service`
|
||||
- 校验部署后的 commit
|
||||
|
||||
### 4. 确认远端版本
|
||||
|
||||
```bash
|
||||
ssh ubuntu@xworkmate-bridge.svc.plus '
|
||||
systemctl --user --no-pager --full status xworkmate-bridge
|
||||
/home/ubuntu/.local/bin/xworkmate-go-core version
|
||||
'
|
||||
```
|
||||
|
||||
期望看到:
|
||||
|
||||
- `Active: active (running)`
|
||||
- `commit` 等于刚部署的提交
|
||||
|
||||
GitHub Actions 发布成功只能说明 CI/CD 完成。现场仍要确认远端二进制和 HTTPS API:
|
||||
|
||||
```bash
|
||||
gh run view 27095512721 --repo ai-workspace-lab/xworkmate-bridge
|
||||
ssh ubuntu@xworkmate-bridge.svc.plus '/home/ubuntu/.local/bin/xworkmate-go-core version'
|
||||
```
|
||||
|
||||
如果 APP 关于页仍显示 `unauthorized`,继续执行“前置检查:先排除账号同步 / 鉴权问题”,不要进入 RTP 判定。
|
||||
|
||||
### 5. 确认 Caddy / service 没有漂移
|
||||
|
||||
当前生产形态以 user service 为准:
|
||||
|
||||
```bash
|
||||
ssh ubuntu@xworkmate-bridge.svc.plus '
|
||||
echo "system=$(systemctl is-active xworkmate-bridge.service 2>/dev/null || true)"
|
||||
echo "user=$(systemctl --user is-active xworkmate-bridge.service)"
|
||||
'
|
||||
```
|
||||
|
||||
期望:
|
||||
|
||||
- `system=inactive`
|
||||
- `user=active`
|
||||
|
||||
如果 system service 处于 `activating` / `failed` / 自动重启,可能与 user service 抢占 `127.0.0.1:8787`,需要先清理 system service 冲突。
|
||||
|
||||
## 白屏排查步骤
|
||||
|
||||
### A. 先看服务与版本
|
||||
|
||||
```bash
|
||||
ssh ubuntu@xworkmate-bridge.svc.plus '
|
||||
systemctl --user --no-pager --full status xworkmate-bridge
|
||||
ps -fp $(systemctl --user show -p MainPID --value xworkmate-bridge.service)
|
||||
'
|
||||
```
|
||||
|
||||
同时确认 APP 侧账号同步已成功。如果 APP 显示 `Bridge token expired or rejected`,先重新同步账号 token。此时日志里通常不会出现新的远程桌面 session,说明请求没有进入 WebRTC 层。
|
||||
|
||||
### B. 跟日志
|
||||
|
||||
```bash
|
||||
ssh ubuntu@xworkmate-bridge.svc.plus '
|
||||
journalctl --user -f -u xworkmate-bridge
|
||||
'
|
||||
```
|
||||
|
||||
重点观察:
|
||||
|
||||
- `Starting Remote Desktop session`
|
||||
- `Starting capture pipeline`
|
||||
- `Capture pipeline exited with error`
|
||||
- `profile=(string)baseline`
|
||||
- `profile-level-id=(string)42c01f`
|
||||
- `WebRTC RTP stats`
|
||||
- `WebRTC RTP final stats`
|
||||
|
||||
### C. 连接 APP,重复执行
|
||||
|
||||
建议至少跑三轮:
|
||||
|
||||
1. 首次连接
|
||||
2. 断开再连接
|
||||
3. 快速重连
|
||||
|
||||
如果问题只在重连出现,要重点看:
|
||||
|
||||
- 旧 session 是否被 `Stopping Remote Desktop session` 正常清理
|
||||
- 旧 pipeline 是否退出
|
||||
- 新 session 是否出现新的 RTP stats 增长
|
||||
|
||||
### D. 判定编码是否兼容
|
||||
|
||||
以下信号说明编码兼容性已进入 WebRTC 友好区间:
|
||||
|
||||
- `format=(string)I420`
|
||||
- `profile=(string)baseline`
|
||||
- `profile-level-id=(string)42c01f`
|
||||
- `packetization-mode=(string)1`
|
||||
- `config-interval=1`
|
||||
|
||||
### E. 判定 RTP 是否真的在发
|
||||
|
||||
看 `WebRTC RTP stats`:
|
||||
|
||||
- `packetDelta > 0`
|
||||
- `byteDelta > 0`
|
||||
- `writeErrors=0`
|
||||
|
||||
如果这些值连续多个周期都不增长:
|
||||
|
||||
- 优先查 GStreamer / FFmpeg capture 是否退出
|
||||
- 再查 display / X11 / encoder 参数
|
||||
|
||||
## APP 侧 stats 判读
|
||||
|
||||
APP 在等待首帧时会定期打印 inbound video stats 摘要。
|
||||
|
||||
关键字段:
|
||||
|
||||
- `packetsReceived`
|
||||
- `bytesReceived`
|
||||
- `framesDecoded`
|
||||
- `framesDropped`
|
||||
- `keyFramesDecoded`
|
||||
- `jitter`
|
||||
- `jitterBufferDelay`
|
||||
|
||||
判读:
|
||||
|
||||
- `packetsReceived == 0`
|
||||
- Bridge 在发,但对端没收到,优先查 ICE / candidate / 网络。
|
||||
- `packetsReceived > 0 && framesDecoded == 0`
|
||||
- 收到 RTP 但解码不了,优先查 H.264 profile / SPS/PPS / browser 兼容。
|
||||
- `framesDecoded > 0`
|
||||
- 编码与网络基本通,继续查 renderer / stale stream / attach。
|
||||
|
||||
## 操作流畅度链路判读
|
||||
|
||||
当画面已经出来,但远程桌面操作不跟手时,按下面顺序排查。
|
||||
|
||||
### 1. 先确认视频链路不是主因
|
||||
|
||||
远端日志应满足:
|
||||
|
||||
```text
|
||||
WebRTC RTP stats: packets=... packetDelta=1100 byteDelta=950000 writeErrors=0
|
||||
WebRTC RTP stats: packets=... packetDelta=1100 byteDelta=950000 writeErrors=0
|
||||
```
|
||||
|
||||
判读:
|
||||
|
||||
- `packetDelta` 和 `byteDelta` 连续增长,且 `writeErrors=0`
|
||||
- 视频 capture / encoder / RTP 写入 WebRTC track 基本健康。
|
||||
- `packetDelta` 大幅抖动、连续归零或 `writeErrors > 0`
|
||||
- 先回到 RTP / encoder / PeerConnection 层排查,不要先改输入。
|
||||
|
||||
### 2. 再看 APP 输入事件是否过密
|
||||
|
||||
高频鼠标移动属于状态同步,不应排队传输所有中间点。APP 侧应满足:
|
||||
|
||||
- `mouse_move` / hover 约 16ms 最多发送一次
|
||||
- 同一归一化坐标重复移动不发送
|
||||
- `mouse_down` 前强制发送最新坐标,保证点击命中
|
||||
- `mouse_move` 走独立 `input-move` data channel,配置为 unordered + 短 packet lifetime
|
||||
- 点击、键盘、滚轮走可靠有序 `input` data channel
|
||||
- `input-move` channel `bufferedAmount` 过高时只丢弃 `mouse_move`
|
||||
- `mouse_down`、`mouse_up`、`key_down`、`key_up`、`scroll` 不因背压丢弃
|
||||
|
||||
判读:
|
||||
|
||||
- 鼠标轨迹延迟但点击最终准确,通常是 `input-move` 背压或 Bridge 侧 mouse move coalescing 积压。
|
||||
- 点击也延迟或丢失,要查 data channel 状态、Bridge input injector、xdotool 写入。
|
||||
|
||||
### 3. 再看 Bridge input injector
|
||||
|
||||
Bridge 侧输入链路是:
|
||||
|
||||
```text
|
||||
APP Pointer/Key event -> WebRTC input/input-move data channel -> Bridge OnDataChannel -> XdotoolInjector.Inject -> persistent xdotool stdin -> X11
|
||||
```
|
||||
|
||||
重点日志:
|
||||
|
||||
```text
|
||||
Data channel 'input'-'...' opened
|
||||
Data channel 'input-move'-'...' opened
|
||||
xdotool write error: ...
|
||||
xdotool mousemove write error: ...
|
||||
```
|
||||
|
||||
判读:
|
||||
|
||||
- 有 `Data channel opened`,但操作没反应:
|
||||
- 优先查 xdotool 是否写失败、DISPLAY 是否解析正确、X11 session 是否仍可注入。
|
||||
- 出现 `xdotool write error` 后操作完全卡住:
|
||||
- 检查 Bridge 是否包含 2026-06-09 的修复:写失败后释放 mutex,再调用 `Start()` 重启 injector。
|
||||
- 没有 xdotool 错误,RTP 也健康,但操作仍延迟:
|
||||
- 优先查 APP data channel 背压与 mouse move 发送频率。
|
||||
|
||||
## 现场验证模板
|
||||
|
||||
### 一次健康验证应满足
|
||||
|
||||
1. Bridge answer / RTP caps 中 H264 协商属于 baseline family:
|
||||
|
||||
```text
|
||||
profile-level-id=42c01f
|
||||
packetization-mode=1
|
||||
```
|
||||
|
||||
说明:`profile-level-id` 以 `42` 开头是 baseline family 的关键特征。现场日志中 `rtph264pay` 常见值为 `42c01f`;不应再出现旧的 `f40020`。
|
||||
|
||||
2. GStreamer caps 中包含:
|
||||
|
||||
```text
|
||||
format=(string)I420
|
||||
profile=(string)baseline
|
||||
width=(int)1280
|
||||
height=(int)720
|
||||
```
|
||||
|
||||
3. RTP 统计连续增长:
|
||||
|
||||
```text
|
||||
WebRTC RTP stats: packets=1471 ...
|
||||
WebRTC RTP stats: packets=2977 ...
|
||||
WebRTC RTP stats: packets=4496 ...
|
||||
```
|
||||
|
||||
4. 结束会话时看到 final stats:
|
||||
|
||||
```text
|
||||
WebRTC RTP final stats: packets=9470 bytes=7962965 writeErrors=0
|
||||
```
|
||||
|
||||
5. 操作流畅度验证:
|
||||
|
||||
- 鼠标快速移动 10 秒,远端指针应跟随最新位置,不应明显执行旧轨迹。
|
||||
- 快速点击按钮 / 菜单,点击前坐标应准确同步,不应出现点击偏移。
|
||||
- 连续输入文本,键盘事件不应因为鼠标 move 背压被丢弃。
|
||||
- Bridge 日志不应出现新的 `xdotool write error` 或 `xdotool mousemove write error`。
|
||||
- 如果远端日志中 RTP 持续健康但操作仍卡,继续抓 APP data channel `bufferedAmount` 与 input event 发送频率。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 1. `cannot execute binary file: Exec format error`
|
||||
|
||||
原因:
|
||||
|
||||
- 把 macOS binary 部署到了 Linux 远端。
|
||||
|
||||
修复:
|
||||
|
||||
- 用 `CGO_ENABLED=0 GOOS=linux GOARCH=amd64` 重新构建。
|
||||
|
||||
### 2. `Capture pipeline exited with error: exit status 1`
|
||||
|
||||
高概率原因:
|
||||
|
||||
- 4:2:0 输入尺寸为奇数
|
||||
- profile / format 约束与实际 caps 冲突
|
||||
|
||||
修复:
|
||||
|
||||
- 强制 `videoscale`
|
||||
- 输出归一化到偶数 `width/height`
|
||||
|
||||
### 3. 旧 service inactive,但 8787 仍被占用
|
||||
|
||||
说明:
|
||||
|
||||
- 需要区分 system service 和 user service
|
||||
- 以 `systemctl --user status xworkmate-bridge` 为准
|
||||
|
||||
### 4. `packetsReceived` 增长但仍白屏
|
||||
|
||||
优先看:
|
||||
|
||||
- `framesDecoded`
|
||||
- `videoWidth` / `videoHeight`
|
||||
- 前端 renderer 是否拿到首帧
|
||||
|
||||
### 5. APP 显示 `Bridge token expired or rejected`
|
||||
|
||||
说明:
|
||||
|
||||
- 这是账号同步 / token 前置问题,不是 H.264 编码或 RTP 发送问题。
|
||||
- Bridge 可能已经部署到最新 commit,且带 token 的 `/api/ping` 正常。
|
||||
- APP 不会成功发起 `xworkmate.desktop.offer`,所以 Bridge 日志中不会出现新的 desktop session。
|
||||
- 对 `review@svc.plus`,还要确认 Caddy 公网入口接受 `BRIDGE_REVIEW_AUTH_TOKEN`。只测 origin `127.0.0.1:8787` 不够。
|
||||
|
||||
修复:
|
||||
|
||||
- 在 APP 设置页点击 `重新同步`。
|
||||
- 刷新版本信息,确认 Bridge runtime `Status` 不再是 `unauthorized`。
|
||||
- 确认公网 `/api/ping` 对主 token 和 review token 都返回 `200`,无 token 返回 `401`。
|
||||
- 再重新连接远程桌面并观察 RTP / stats。
|
||||
|
||||
## 回滚
|
||||
|
||||
1. 用上一个稳定 commit 重新构建 Linux binary。
|
||||
2. 重新执行部署脚本。
|
||||
3. 重启 user service。
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
cd /Users/shenlan/workspaces/ai-workspace-lab/xworkmate-bridge
|
||||
git checkout <stable-commit>
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 OUTPUT_PATH=build/bin/xworkmate-go-core-linux-amd64 make build
|
||||
scripts/github-actions/deploy-native-binary.sh xworkmate-bridge.svc.plus build/bin/xworkmate-go-core-linux-amd64 <stable-commit>
|
||||
```
|
||||
|
||||
## 文档维护要求
|
||||
|
||||
- 新增 WebRTC 白屏修复时,优先补本 runbook,不要只留在聊天记录里。
|
||||
- 新增关键日志时,必须说明默认是否开启、是否限频、如何关闭。
|
||||
- 如果 H.264 参数再调整,务必同步更新:
|
||||
- 期望 profile
|
||||
- 期望 pixel format
|
||||
- RTP 判读标准
|
||||
- 远端部署命令
|
||||
Binary file not shown.
@ -13,6 +13,13 @@ type CapabilityCatalog struct {
|
||||
ProviderProbeSummary []any `json:"providerProbeSummary"`
|
||||
}
|
||||
|
||||
func (c *CapabilityCatalog) Update(providers []any, targets []any) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.ProviderCatalog = providers
|
||||
c.AvailableExecutionTargets = targets
|
||||
}
|
||||
|
||||
func (c *CapabilityCatalog) Get() map[string]any {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
@ -109,43 +109,15 @@ func resolveURL(yamlVal string, envKeys ...string) string {
|
||||
}
|
||||
|
||||
func bridgeUpstreamAuthorizationHeader() string {
|
||||
token := bridgePublicAuthToken()
|
||||
token := bridgeSharedAuthToken()
|
||||
if token != "" && !strings.HasPrefix(strings.ToLower(token), "bearer ") {
|
||||
return "Bearer " + token
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func bridgePublicAuthToken() string {
|
||||
if token := strings.TrimSpace(os.Getenv("AI_WORKSPACE_AUTH_TOKEN")); token != "" {
|
||||
return token
|
||||
}
|
||||
return strings.TrimSpace(shared.EnvOrDefault("BRIDGE_AUTH_TOKEN", ""))
|
||||
}
|
||||
|
||||
func bridgeSharedAuthToken() string {
|
||||
return bridgePublicAuthToken()
|
||||
}
|
||||
|
||||
func bridgeInboundAuthTokens() []string {
|
||||
var tokens []string
|
||||
seen := map[string]struct{}{}
|
||||
for _, token := range []string{
|
||||
os.Getenv("AI_WORKSPACE_AUTH_TOKEN"),
|
||||
os.Getenv("BRIDGE_AUTH_TOKEN"),
|
||||
os.Getenv("BRIDGE_REVIEW_AUTH_TOKEN"),
|
||||
} {
|
||||
trimmed := strings.TrimSpace(token)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[trimmed]; ok {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
tokens = append(tokens, trimmed)
|
||||
}
|
||||
return tokens
|
||||
return strings.TrimSpace(shared.EnvOrDefault("BRIDGE_AUTH_TOKEN", ""))
|
||||
}
|
||||
|
||||
func resolveDistributedTaskForwardToken(config *BridgeConfig) string {
|
||||
|
||||
@ -192,7 +192,7 @@ func sessionStart(sessionID string) shared.RPCRequest {
|
||||
return shared.RPCRequest{
|
||||
ID: sessionID,
|
||||
Method: "session.start",
|
||||
Params: map[string]any{"sessionId": sessionID, "openclawSessionKey": "thread-" + sessionID, "threadId": "thread-" + sessionID},
|
||||
Params: map[string]any{"sessionId": sessionID, "threadId": "thread-" + sessionID},
|
||||
}
|
||||
}
|
||||
|
||||
@ -200,6 +200,6 @@ func sessionMessage(sessionID string) shared.RPCRequest {
|
||||
return shared.RPCRequest{
|
||||
ID: sessionID + "-message",
|
||||
Method: "session.message",
|
||||
Params: map[string]any{"sessionId": sessionID, "openclawSessionKey": "thread-" + sessionID, "threadId": "thread-" + sessionID},
|
||||
Params: map[string]any{"sessionId": sessionID, "threadId": "thread-" + sessionID},
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,10 +5,14 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"xworkmate-bridge/internal/shared"
|
||||
)
|
||||
|
||||
func TestResolveSingleAgentForwardEndpointFromExampleConfig(t *testing.T) {
|
||||
@ -557,3 +561,116 @@ func TestExternalACPNotificationCollectorIgnoresCodexCommentaryMessages(t *testi
|
||||
t.Fatalf("expected commentary to be hidden and duplicate final line collapsed, got %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeOpenClawTaskFailsAfterMaxAllowedSilentDuration(t *testing.T) {
|
||||
t.Setenv("XWORKMATE_BRIDGE_OPENCLAW_GATEWAY_MAX_SILENT_DURATION", "2s")
|
||||
t.Setenv("BRIDGE_CONFIG_PATH", filepath.Join(t.TempDir(), "missing-config.yaml"))
|
||||
|
||||
server := NewServer()
|
||||
orchestrator := server.orchestrator
|
||||
sess := server.getOrCreateSession("silent-session", "silent-thread")
|
||||
startedAt := time.Now().Add(-time.Minute)
|
||||
sess.mu.Lock()
|
||||
sess.task = QueuedTask{
|
||||
SessionID: "silent-session",
|
||||
ThreadID: "silent-thread",
|
||||
TurnID: "silent-turn",
|
||||
RunID: "silent-run",
|
||||
SessionKey: "silent-session",
|
||||
GatewayProviderID: "openclaw",
|
||||
State: TaskStateRunning,
|
||||
Kind: TaskKindGateway,
|
||||
RuntimeBudgetMinutes: openClawLongTaskMinutes,
|
||||
StartedAt: startedAt,
|
||||
DeadlineAt: time.Now().Add(time.Minute),
|
||||
}
|
||||
sess.openClaw = &OpenClawTaskRecord{
|
||||
SessionID: "silent-session",
|
||||
ThreadID: "silent-thread",
|
||||
TurnID: "silent-turn",
|
||||
RunID: "silent-run",
|
||||
SessionKey: "silent-session",
|
||||
GatewayProviderID: "openclaw",
|
||||
TaskLoadClass: "long_task",
|
||||
RuntimeBudgetMinutes: openClawLongTaskMinutes,
|
||||
StartedAt: startedAt,
|
||||
DeadlineAt: time.Now().Add(time.Minute),
|
||||
FirstSilentFailureAt: time.Now().Add(-3 * time.Second),
|
||||
}
|
||||
sess.mu.Unlock()
|
||||
|
||||
result := orchestrator.probeOpenClawTask(context.Background(), sess, nil, false)
|
||||
|
||||
if got := result["status"]; got != string(TaskStateFailed) {
|
||||
t.Fatalf("expected failed status after silent duration, got %#v", result)
|
||||
}
|
||||
if got := result["code"]; got != "OPENCLAW_GATEWAY_LOST" {
|
||||
t.Fatalf("expected OPENCLAW_GATEWAY_LOST, got %#v", result)
|
||||
}
|
||||
sess.mu.Lock()
|
||||
state := sess.task.State
|
||||
sess.mu.Unlock()
|
||||
if state != TaskStateFailed {
|
||||
t.Fatalf("task state = %s, want %s", state, TaskStateFailed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalOpenClawTaskRemovesInlineAttachmentDirectory(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
turnID := "turn-inline-gc"
|
||||
chatParams, rpcErr := openClawChatSendParams(map[string]any{
|
||||
"threadId": "thread-inline-gc",
|
||||
"taskPrompt": "inspect uploaded file",
|
||||
"workingDirectory": workspace,
|
||||
"inlineAttachments": []any{
|
||||
map[string]any{
|
||||
"name": "note.txt",
|
||||
"mimeType": "text/plain",
|
||||
"content": "bm90ZQ==",
|
||||
},
|
||||
},
|
||||
}, turnID)
|
||||
if rpcErr != nil {
|
||||
t.Fatalf("expected chat params, got rpc error: %#v", rpcErr)
|
||||
}
|
||||
attachments := shared.ListArg(chatParams, "attachments")
|
||||
if len(attachments) != 1 {
|
||||
t.Fatalf("expected materialized attachment, got %#v", attachments)
|
||||
}
|
||||
attachmentPath := shared.StringArg(shared.AsMap(attachments[0]), "path", "")
|
||||
attachmentDirectory := filepath.Dir(attachmentPath)
|
||||
if _, err := os.Stat(attachmentDirectory); err != nil {
|
||||
t.Fatalf("expected attachment directory before terminal task state: %v", err)
|
||||
}
|
||||
|
||||
server := NewServer()
|
||||
sess := server.getOrCreateSession("gc-session", "gc-thread")
|
||||
now := time.Now()
|
||||
sess.mu.Lock()
|
||||
sess.task = QueuedTask{
|
||||
SessionID: "gc-session",
|
||||
ThreadID: "gc-thread",
|
||||
TurnID: turnID,
|
||||
RunID: "gc-run",
|
||||
State: TaskStateRunning,
|
||||
Kind: TaskKindGateway,
|
||||
StartedAt: now,
|
||||
}
|
||||
sess.openClaw = &OpenClawTaskRecord{
|
||||
SessionID: "gc-session",
|
||||
ThreadID: "gc-thread",
|
||||
TurnID: turnID,
|
||||
RunID: "gc-run",
|
||||
StartedAt: now,
|
||||
ChatParams: map[string]any{
|
||||
"workingDirectory": workspace,
|
||||
},
|
||||
}
|
||||
sess.mu.Unlock()
|
||||
|
||||
server.orchestrator.failOpenClawTask(sess, "TEST_FAILED", "terminal")
|
||||
|
||||
if _, err := os.Stat(attachmentDirectory); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected terminal task to remove attachment directory, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -92,7 +92,7 @@ func handleGatewayConnect(
|
||||
server.gateway = gatewayruntime.NewManager()
|
||||
}
|
||||
|
||||
result := connectOpenClawGateway(server.gateway, request, notify, usesBridgeIdentity)
|
||||
result := server.gateway.Connect(request, notify)
|
||||
if result.OK && usesBridgeIdentity {
|
||||
saveBridgeGatewayDeviceToken(result.ReturnedDeviceToken)
|
||||
}
|
||||
@ -284,7 +284,7 @@ func ensureProductionGatewayConnected(
|
||||
request.Auth.DeviceToken = deviceToken
|
||||
request.HasDeviceToken = deviceToken != ""
|
||||
request.ReportedRemoteAddress = resolveGatewayReportedRemoteAddress(server, request)
|
||||
result := connectOpenClawGateway(server.gateway, request, notify, true)
|
||||
result := server.gateway.Connect(request, notify)
|
||||
if result.OK {
|
||||
saveBridgeGatewayDeviceToken(result.ReturnedDeviceToken)
|
||||
return nil
|
||||
@ -297,43 +297,6 @@ func ensureProductionGatewayConnected(
|
||||
return &shared.RPCError{Code: -32002, Message: "GATEWAY_CONNECT_FAILED: " + message}
|
||||
}
|
||||
|
||||
func connectOpenClawGateway(
|
||||
manager *gatewayruntime.Manager,
|
||||
request gatewayruntime.ConnectRequest,
|
||||
notify func(map[string]any),
|
||||
usesBridgeIdentity bool,
|
||||
) gatewayruntime.ConnectResult {
|
||||
result := manager.Connect(request, notify)
|
||||
if !usesBridgeIdentity || !shouldRetryOpenClawGatewayWithSharedToken(result) {
|
||||
return result
|
||||
}
|
||||
|
||||
clearBridgeGatewayDeviceToken()
|
||||
request.Auth.DeviceToken = ""
|
||||
request.HasDeviceToken = false
|
||||
request.Auth.Token = bridgeSharedAuthToken()
|
||||
request.HasSharedAuth = true
|
||||
request.ConnectAuthMode = "shared-token"
|
||||
request.ConnectAuthFields = []string{"token"}
|
||||
request.ConnectAuthSources = []string{"bridge:device-token-reissue"}
|
||||
return manager.Connect(request, notify)
|
||||
}
|
||||
|
||||
func shouldRetryOpenClawGatewayWithSharedToken(result gatewayruntime.ConnectResult) bool {
|
||||
if result.OK || strings.TrimSpace(bridgeSharedAuthToken()) == "" {
|
||||
return false
|
||||
}
|
||||
code := strings.ToUpper(strings.TrimSpace(shared.StringArg(result.Error, "code", "")))
|
||||
message := strings.ToLower(strings.TrimSpace(shared.StringArg(result.Error, "message", "")))
|
||||
details := shared.AsMap(result.Error["details"])
|
||||
detailCode := strings.ToUpper(strings.TrimSpace(shared.StringArg(details, "code", "")))
|
||||
return detailCode == "AUTH_DEVICE_TOKEN_MISMATCH" ||
|
||||
detailCode == "PAIRING_REQUIRED" ||
|
||||
code == "NOT_PAIRED" ||
|
||||
strings.Contains(message, "device token mismatch") ||
|
||||
strings.Contains(message, "rotate/reissue device token")
|
||||
}
|
||||
|
||||
func configureProductionOpenClawGatewayRuntime(manager *gatewayruntime.Manager) {
|
||||
if manager == nil {
|
||||
return
|
||||
|
||||
@ -138,20 +138,6 @@ func saveBridgeGatewayDeviceToken(deviceToken string) {
|
||||
)
|
||||
}
|
||||
|
||||
func clearBridgeGatewayDeviceToken() {
|
||||
bridgeGatewayIdentity.Lock()
|
||||
defer bridgeGatewayIdentity.Unlock()
|
||||
if strings.TrimSpace(bridgeGatewayIdentity.value.DeviceID) == "" {
|
||||
return
|
||||
}
|
||||
bridgeGatewayIdentity.deviceToken = ""
|
||||
_ = persistBridgeGatewayIdentity(
|
||||
bridgeGatewayIdentityPath(),
|
||||
bridgeGatewayIdentity.value,
|
||||
"",
|
||||
)
|
||||
}
|
||||
|
||||
func persistBridgeGatewayIdentity(
|
||||
path string,
|
||||
identity gatewayruntime.DeviceIdentity,
|
||||
|
||||
@ -144,26 +144,6 @@ func TestBridgeGatewayIdentityPersistsReturnedDeviceToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeGatewayIdentityClearsStoredDeviceToken(t *testing.T) {
|
||||
identityPath := filepath.Join(t.TempDir(), "openclaw-device.json")
|
||||
t.Setenv("XWORKMATE_BRIDGE_OPENCLAW_IDENTITY_PATH", identityPath)
|
||||
resetBridgeGatewayIdentityForTest()
|
||||
t.Cleanup(resetBridgeGatewayIdentityForTest)
|
||||
|
||||
identity := newBridgeGatewayIdentity()
|
||||
saveBridgeGatewayDeviceToken("device-token-2")
|
||||
clearBridgeGatewayDeviceToken()
|
||||
resetBridgeGatewayIdentityForTest()
|
||||
|
||||
reloaded, token := bridgeGatewayOpenClawCredentials()
|
||||
if reloaded.DeviceID != identity.DeviceID {
|
||||
t.Fatalf("reloaded identity = %q, want %q", reloaded.DeviceID, identity.DeviceID)
|
||||
}
|
||||
if token != "" {
|
||||
t.Fatalf("device token should be cleared, got %q", token)
|
||||
}
|
||||
}
|
||||
|
||||
func resetBridgeGatewayIdentityForTest() {
|
||||
bridgeGatewayIdentity.Lock()
|
||||
defer bridgeGatewayIdentity.Unlock()
|
||||
|
||||
@ -64,3 +64,48 @@ func TestResolveGatewayReportedRemoteAddressNormalizesExplicitPublicRemoteHost(
|
||||
t.Fatalf("resolveGatewayReportedRemoteAddress() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReassociateOpenClawTaskDerivesRuntimeBudgetWithoutExplicitBudget(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
params map[string]any
|
||||
want int
|
||||
}{
|
||||
{
|
||||
name: "short task load class",
|
||||
params: map[string]any{
|
||||
"runId": "run-short",
|
||||
"artifactScope": "tasks/main/run-short",
|
||||
"taskLoadClass": "short_task",
|
||||
},
|
||||
want: openClawShortTaskMinutes,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := NewServer()
|
||||
sess := server.reassociateOpenClawTask(tc.params)
|
||||
if sess == nil {
|
||||
t.Fatal("expected reassociated session")
|
||||
} else {
|
||||
sess.mu.Lock()
|
||||
gotTaskBudget := sess.task.RuntimeBudgetMinutes
|
||||
gotRecordBudget := sess.openClaw.RuntimeBudgetMinutes
|
||||
sess.mu.Unlock()
|
||||
|
||||
if gotTaskBudget != tc.want {
|
||||
t.Fatalf("task RuntimeBudgetMinutes = %d, want %d", gotTaskBudget, tc.want)
|
||||
}
|
||||
if gotRecordBudget != tc.want {
|
||||
t.Fatalf("record RuntimeBudgetMinutes = %d, want %d", gotRecordBudget, tc.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -108,117 +108,3 @@ func TestSystemLogsConnectsProductionGatewayForStatus(t *testing.T) {
|
||||
t.Fatalf("expected connected status to reuse gateway session, got %d connect attempts", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionGatewayReconnectsWithSharedTokenAfterDeviceTokenMismatch(t *testing.T) {
|
||||
gateway := newAcpFakeOpenClawGateway(t)
|
||||
defer gateway.Close()
|
||||
gateway.rejectDeviceTokenOnce.Store(true)
|
||||
|
||||
identityPath := filepath.Join(t.TempDir(), "openclaw-device.json")
|
||||
t.Setenv("GATEWAY_RPC_URL", gateway.URL())
|
||||
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-test-token")
|
||||
t.Setenv("BRIDGE_CONFIG_PATH", filepath.Join(t.TempDir(), "missing-config.yaml"))
|
||||
t.Setenv("XWORKMATE_BRIDGE_OPENCLAW_IDENTITY_PATH", identityPath)
|
||||
resetBridgeGatewayIdentityForTest()
|
||||
t.Cleanup(resetBridgeGatewayIdentityForTest)
|
||||
|
||||
_ = newBridgeGatewayIdentity()
|
||||
saveBridgeGatewayDeviceToken("stale-device-token")
|
||||
server := NewServer()
|
||||
|
||||
result, rpcErr := server.handleRequest(
|
||||
shared.RPCRequest{
|
||||
ID: "status",
|
||||
Method: "system.logs",
|
||||
Params: map[string]any{},
|
||||
},
|
||||
func(map[string]any) {},
|
||||
)
|
||||
if rpcErr != nil {
|
||||
t.Fatalf("system.logs returned rpc error: %#v", rpcErr)
|
||||
}
|
||||
if got := result["gatewayStatus"]; got != "connected" {
|
||||
t.Fatalf("expected gatewayStatus connected after repair, got %#v", result)
|
||||
}
|
||||
if got := gateway.ConnectCount(); got != 2 {
|
||||
t.Fatalf("expected stale device token retry with shared token, got %d connects", got)
|
||||
}
|
||||
resetBridgeGatewayIdentityForTest()
|
||||
_, token := bridgeGatewayOpenClawCredentials()
|
||||
if token != "device-token-1" {
|
||||
t.Fatalf("expected repaired device token to be persisted, got %q", token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionGatewayReconnectPrefersAIWorkspaceToken(t *testing.T) {
|
||||
gateway := newAcpFakeOpenClawGateway(t)
|
||||
defer gateway.Close()
|
||||
gateway.rejectDeviceTokenOnce.Store(true)
|
||||
|
||||
t.Setenv("GATEWAY_RPC_URL", gateway.URL())
|
||||
t.Setenv("AI_WORKSPACE_AUTH_TOKEN", "ai-workspace-token")
|
||||
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-test-token")
|
||||
t.Setenv("BRIDGE_CONFIG_PATH", filepath.Join(t.TempDir(), "missing-config.yaml"))
|
||||
t.Setenv("XWORKMATE_BRIDGE_OPENCLAW_IDENTITY_PATH", filepath.Join(t.TempDir(), "openclaw-device.json"))
|
||||
resetBridgeGatewayIdentityForTest()
|
||||
t.Cleanup(resetBridgeGatewayIdentityForTest)
|
||||
|
||||
_ = newBridgeGatewayIdentity()
|
||||
saveBridgeGatewayDeviceToken("stale-device-token")
|
||||
server := NewServer()
|
||||
|
||||
result, rpcErr := server.handleRequest(
|
||||
shared.RPCRequest{
|
||||
ID: "status",
|
||||
Method: "system.logs",
|
||||
Params: map[string]any{},
|
||||
},
|
||||
func(map[string]any) {},
|
||||
)
|
||||
if rpcErr != nil {
|
||||
t.Fatalf("system.logs returned rpc error: %#v", rpcErr)
|
||||
}
|
||||
if got := result["gatewayStatus"]; got != "connected" {
|
||||
t.Fatalf("expected gatewayStatus connected after AI workspace token repair, got %#v", result)
|
||||
}
|
||||
if got := gateway.ConnectCount(); got != 2 {
|
||||
t.Fatalf("expected stale device token retry with AI workspace token, got %d connects", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionGatewayDoesNotUseInternalServiceTokenFallback(t *testing.T) {
|
||||
gateway := newAcpFakeOpenClawGateway(t)
|
||||
defer gateway.Close()
|
||||
gateway.rejectDeviceTokenOnce.Store(true)
|
||||
|
||||
t.Setenv("GATEWAY_RPC_URL", gateway.URL())
|
||||
t.Setenv("AI_WORKSPACE_AUTH_TOKEN", "")
|
||||
t.Setenv("BRIDGE_AUTH_TOKEN", "")
|
||||
t.Setenv("INTERNAL_SERVICE_TOKEN", "bridge-test-token")
|
||||
t.Setenv("BRIDGE_CONFIG_PATH", filepath.Join(t.TempDir(), "missing-config.yaml"))
|
||||
t.Setenv("XWORKMATE_BRIDGE_OPENCLAW_IDENTITY_PATH", filepath.Join(t.TempDir(), "openclaw-device.json"))
|
||||
resetBridgeGatewayIdentityForTest()
|
||||
t.Cleanup(resetBridgeGatewayIdentityForTest)
|
||||
|
||||
_ = newBridgeGatewayIdentity()
|
||||
saveBridgeGatewayDeviceToken("stale-device-token")
|
||||
server := NewServer()
|
||||
|
||||
result, rpcErr := server.handleRequest(
|
||||
shared.RPCRequest{
|
||||
ID: "status",
|
||||
Method: "system.logs",
|
||||
Params: map[string]any{},
|
||||
},
|
||||
func(map[string]any) {},
|
||||
)
|
||||
if rpcErr != nil {
|
||||
t.Fatalf("system.logs returned rpc error: %#v", rpcErr)
|
||||
}
|
||||
if got := result["gatewayStatus"]; got != "disconnected" {
|
||||
t.Fatalf("expected gatewayStatus disconnected without AI workspace token, got %#v", result)
|
||||
}
|
||||
if got := gateway.ConnectCount(); got != 1 {
|
||||
t.Fatalf("expected no retry with internal token, got %d connects", got)
|
||||
}
|
||||
}
|
||||
|
||||
@ -584,6 +584,17 @@ func cloneMap(source map[string]any) map[string]any {
|
||||
return result
|
||||
}
|
||||
|
||||
func cloneMapSlice(source []map[string]any) []map[string]any {
|
||||
if source == nil {
|
||||
return nil
|
||||
}
|
||||
result := make([]map[string]any, 0, len(source))
|
||||
for _, item := range source {
|
||||
result = append(result, cloneMap(item))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parseSkillsCandidates(raw []any) []skills.Candidate {
|
||||
result := make([]skills.Candidate, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
|
||||
@ -37,7 +37,6 @@ func (s *Server) Handler() http.Handler {
|
||||
"commit": info.Commit,
|
||||
"version": info.Version,
|
||||
"buildDate": info.BuildDate,
|
||||
"metrics": bridgeStabilityMetricsSnapshot(), // T12
|
||||
}
|
||||
body, _ := json.Marshal(resp)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
@ -1,29 +0,0 @@
|
||||
package acp
|
||||
|
||||
import "sync/atomic"
|
||||
|
||||
// 关键稳定性指标(T12,docs/cases/06 §5)。
|
||||
//
|
||||
// 进程内累计计数,经 /api/ping 暴露,用于把「网关抖动 / run 超时」从靠用户截图
|
||||
// 变为可监控。三个计数对应三类已知的不稳定来源:
|
||||
// - gatewaySocketClosed : gatewayRPCError 命中 OPENCLAW_GATEWAY_SOCKET_CLOSED(连接断)
|
||||
// - taskGetUnconfirmedFallback: tasks.get 走持久 run 仓兜底(gateway 无法确认 run,T7)
|
||||
// - runDeadlineInterrupt : run 超过 DeadlineAt 且 gateway 无法确认,回 interrupted(T9)
|
||||
var bridgeStabilityMetrics struct {
|
||||
gatewaySocketClosed atomic.Int64
|
||||
taskGetUnconfirmedFallback atomic.Int64
|
||||
runDeadlineInterrupt atomic.Int64
|
||||
}
|
||||
|
||||
func metricGatewaySocketClosedInc() { bridgeStabilityMetrics.gatewaySocketClosed.Add(1) }
|
||||
func metricTaskGetUnconfirmedFallbackInc() { bridgeStabilityMetrics.taskGetUnconfirmedFallback.Add(1) }
|
||||
func metricRunDeadlineInterruptInc() { bridgeStabilityMetrics.runDeadlineInterrupt.Add(1) }
|
||||
|
||||
// bridgeStabilityMetricsSnapshot 返回当前计数快照,供 /api/ping 输出。
|
||||
func bridgeStabilityMetricsSnapshot() map[string]any {
|
||||
return map[string]any{
|
||||
"gatewaySocketClosed": bridgeStabilityMetrics.gatewaySocketClosed.Load(),
|
||||
"taskGetUnconfirmedFallback": bridgeStabilityMetrics.taskGetUnconfirmedFallback.Load(),
|
||||
"runDeadlineInterrupt": bridgeStabilityMetrics.runDeadlineInterrupt.Load(),
|
||||
}
|
||||
}
|
||||
@ -46,10 +46,7 @@ func (s *Server) HandleOpenClawArtifactDownload(w http.ResponseWriter, r *http.R
|
||||
}
|
||||
|
||||
query := r.URL.Query()
|
||||
sessionKey := strings.TrimSpace(query.Get("openclawSessionKey"))
|
||||
if sessionKey == "" {
|
||||
sessionKey = strings.TrimSpace(query.Get("sessionKey"))
|
||||
}
|
||||
sessionKey := strings.TrimSpace(query.Get("sessionKey"))
|
||||
runID := strings.TrimSpace(query.Get("runId"))
|
||||
rawArtifactScope := strings.TrimSpace(query.Get("artifactScope"))
|
||||
artifactScope := safeOpenClawArtifactDownloadArtifactScope(rawArtifactScope)
|
||||
@ -83,11 +80,10 @@ func (s *Server) HandleOpenClawArtifactDownload(w http.ResponseWriter, r *http.R
|
||||
return
|
||||
}
|
||||
readParams := map[string]any{
|
||||
"openclawSessionKey": sessionKey,
|
||||
"sessionKey": sessionKey,
|
||||
"runId": runID,
|
||||
"relativePath": relativePath,
|
||||
"maxInlineBytes": openClawArtifactDownloadMaxBytes,
|
||||
"sessionKey": sessionKey,
|
||||
"runId": runID,
|
||||
"relativePath": relativePath,
|
||||
"maxInlineBytes": openClawArtifactDownloadMaxBytes,
|
||||
}
|
||||
if artifactScope != "" {
|
||||
readParams["artifactScope"] = artifactScope
|
||||
@ -327,7 +323,6 @@ func (s *Server) openClawArtifactDownloadURL(
|
||||
parsed.RawQuery = ""
|
||||
expires := strconv.FormatInt(now.Add(openClawArtifactDownloadTTL).Unix(), 10)
|
||||
query := parsed.Query()
|
||||
query.Set("openclawSessionKey", sessionKey)
|
||||
query.Set("sessionKey", sessionKey)
|
||||
query.Set("runId", runID)
|
||||
query.Set("artifactScope", artifactScope)
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -9,30 +12,40 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
openClawShortTaskMinutes = 10
|
||||
openClawLongTaskMinutes = 30
|
||||
openClawComplexTaskMinutes = 60
|
||||
openClawTaskProbeTimeout = 2 * time.Second
|
||||
openClawTaskProbeTimeoutMs = 1000
|
||||
openClawTaskMonitorInterval = time.Second
|
||||
openClawShortTaskMinutes = 10
|
||||
openClawLongTaskMinutes = 30
|
||||
openClawComplexTaskMinutes = 60
|
||||
openClawDefaultMaxAllowedSilentDuration = 10 * time.Minute
|
||||
)
|
||||
|
||||
type OpenClawTaskRecord struct {
|
||||
SessionID string
|
||||
ThreadID string
|
||||
TurnID string
|
||||
RunID string
|
||||
SessionKey string
|
||||
GatewayProviderID string
|
||||
TaskLoadClass string
|
||||
RuntimeBudgetMinutes int
|
||||
StartedAt time.Time
|
||||
DeadlineAt time.Time
|
||||
ProgressStage string
|
||||
ProgressMessage string
|
||||
PreparedArtifact *openClawPreparedArtifactScope
|
||||
RequiresArtifactExport bool
|
||||
ExpectedArtifactDirs []string
|
||||
RequiredArtifactExts []string
|
||||
ResolvedModel string
|
||||
ResolvedSkills []string
|
||||
SessionID string
|
||||
ThreadID string
|
||||
TurnID string
|
||||
RunID string
|
||||
SessionKey string
|
||||
GatewayProviderID string
|
||||
TaskLoadClass string
|
||||
ArtifactSinceUnixMs int64
|
||||
RuntimeBudgetMinutes int
|
||||
StartedAt time.Time
|
||||
DeadlineAt time.Time
|
||||
LastProbeAt time.Time
|
||||
ProgressStage string
|
||||
ProgressMessage string
|
||||
ProgressTerminal bool
|
||||
FirstSilentFailureAt time.Time
|
||||
ChatParams map[string]any
|
||||
PreparedArtifact *openClawPreparedArtifactScope
|
||||
ArtifactContract openClawArtifactContract
|
||||
ResolvedModel string
|
||||
ResolvedSkills []string
|
||||
MonitorStarted bool
|
||||
ProbeInFlight bool
|
||||
AdmissionRelease func()
|
||||
}
|
||||
|
||||
func openClawTaskRuntimePolicy(params map[string]any, chatParams map[string]any, contract openClawArtifactContract) (string, int) {
|
||||
@ -87,7 +100,6 @@ func openClawRunningTaskResult(record *OpenClawTaskRecord) map[string]any {
|
||||
"resolvedGatewayProviderId": record.GatewayProviderID,
|
||||
"taskLoadClass": record.TaskLoadClass,
|
||||
"runtimeBudgetMinutes": record.RuntimeBudgetMinutes,
|
||||
"requiresArtifactExport": record.RequiresArtifactExport,
|
||||
"startedAt": record.StartedAt.UTC().Format(time.RFC3339Nano),
|
||||
"deadlineAt": record.DeadlineAt.UTC().Format(time.RFC3339Nano),
|
||||
"progress": openClawTaskProgress(record),
|
||||
@ -95,12 +107,6 @@ func openClawRunningTaskResult(record *OpenClawTaskRecord) map[string]any {
|
||||
if record.PreparedArtifact != nil {
|
||||
applyOpenClawPreparedArtifactToResult(result, record.PreparedArtifact)
|
||||
}
|
||||
if len(record.ExpectedArtifactDirs) > 0 {
|
||||
result["expectedArtifactDirs"] = append([]string(nil), record.ExpectedArtifactDirs...)
|
||||
}
|
||||
if len(record.RequiredArtifactExts) > 0 {
|
||||
result["requiredArtifactExtensions"] = append([]string(nil), record.RequiredArtifactExts...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@ -115,11 +121,12 @@ func openClawTaskProgress(record *OpenClawTaskRecord) map[string]any {
|
||||
message = "OpenClaw task is running"
|
||||
}
|
||||
return map[string]any{
|
||||
"stage": stage,
|
||||
"message": message,
|
||||
"elapsedMs": maxInt64(0, now.Sub(record.StartedAt).Milliseconds()),
|
||||
"budgetMs": (time.Duration(record.RuntimeBudgetMinutes) * time.Minute).Milliseconds(),
|
||||
"terminal": false,
|
||||
"stage": stage,
|
||||
"message": message,
|
||||
"elapsedMs": maxInt64(0, now.Sub(record.StartedAt).Milliseconds()),
|
||||
"budgetMs": (time.Duration(record.RuntimeBudgetMinutes) * time.Minute).Milliseconds(),
|
||||
"lastProbeAtMs": record.LastProbeAt.UnixMilli(),
|
||||
"terminal": record.ProgressTerminal,
|
||||
}
|
||||
}
|
||||
|
||||
@ -129,3 +136,530 @@ func maxInt64(a int64, b int64) int64 {
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (o *SessionOrchestrator) startOpenClawTaskMonitor(sess *session) {
|
||||
if sess == nil {
|
||||
return
|
||||
}
|
||||
sess.mu.Lock()
|
||||
record := sess.openClaw
|
||||
if record == nil || record.MonitorStarted || isTerminalTaskState(sess.task.State) {
|
||||
sess.mu.Unlock()
|
||||
return
|
||||
}
|
||||
record.MonitorStarted = true
|
||||
sess.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
defer o.releaseOpenClawAdmission(sess)
|
||||
time.Sleep(openClawTaskMonitorInterval)
|
||||
for {
|
||||
sess.mu.Lock()
|
||||
state := sess.task.State
|
||||
deadline := sess.task.DeadlineAt
|
||||
sess.mu.Unlock()
|
||||
if isTerminalTaskState(state) {
|
||||
return
|
||||
}
|
||||
if !deadline.IsZero() && time.Now().After(deadline) {
|
||||
o.failOpenClawTask(sess, "TASK_SLA_EXPIRED", "OpenClaw task exceeded its runtime SLA")
|
||||
return
|
||||
}
|
||||
o.probeOpenClawTask(context.Background(), sess, nil, false)
|
||||
sess.mu.Lock()
|
||||
state = sess.task.State
|
||||
sess.mu.Unlock()
|
||||
if isTerminalTaskState(state) {
|
||||
return
|
||||
}
|
||||
time.Sleep(openClawTaskMonitorInterval)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func isTerminalTaskState(state TaskState) bool {
|
||||
return state == TaskStateCompleted || state == TaskStateFailed || state == TaskStateCancelled
|
||||
}
|
||||
|
||||
func (o *SessionOrchestrator) releaseOpenClawAdmission(sess *session) {
|
||||
if sess == nil {
|
||||
return
|
||||
}
|
||||
var release func()
|
||||
sess.mu.Lock()
|
||||
if sess.openClaw != nil {
|
||||
release = sess.openClaw.AdmissionRelease
|
||||
sess.openClaw.AdmissionRelease = nil
|
||||
}
|
||||
sess.mu.Unlock()
|
||||
if release != nil {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
func (o *SessionOrchestrator) failOpenClawTask(sess *session, code string, message string) map[string]any {
|
||||
if sess == nil {
|
||||
return map[string]any{"success": false, "status": string(TaskStateFailed), "code": code, "message": message}
|
||||
}
|
||||
if strings.TrimSpace(message) == "" {
|
||||
message = code
|
||||
}
|
||||
sess.mu.Lock()
|
||||
turnID := sess.task.TurnID
|
||||
runID := sess.task.RunID
|
||||
gatewayProviderID := sess.task.GatewayProviderID
|
||||
record := sess.openClaw
|
||||
sess.task.State = TaskStateFailed
|
||||
sess.task.UpdatedAt = time.Now()
|
||||
sess.task.ProgressStage = "failed"
|
||||
sess.task.ProgressMessage = message
|
||||
sess.task.ProgressTerminal = true
|
||||
if sess.openClaw != nil {
|
||||
sess.openClaw.ProgressStage = "failed"
|
||||
sess.openClaw.ProgressMessage = message
|
||||
sess.openClaw.ProgressTerminal = true
|
||||
sess.openClaw.ProbeInFlight = false
|
||||
}
|
||||
result := map[string]any{
|
||||
"success": false,
|
||||
"status": string(TaskStateFailed),
|
||||
"code": code,
|
||||
"error": message,
|
||||
"message": message,
|
||||
"summary": message,
|
||||
"output": message,
|
||||
"turnId": turnID,
|
||||
"runId": runID,
|
||||
"mode": router.ExecutionTargetGatewayChat,
|
||||
"resolvedGatewayProviderId": gatewayProviderID,
|
||||
}
|
||||
sess.lastResult = cloneMap(result)
|
||||
sess.mu.Unlock()
|
||||
o.releaseOpenClawAdmission(sess)
|
||||
cleanupOpenClawTurnAttachments(record)
|
||||
return result
|
||||
}
|
||||
|
||||
func (o *SessionOrchestrator) probeOpenClawTask(ctx context.Context, sess *session, notify func(map[string]any), waitForArtifacts bool) map[string]any {
|
||||
if sess == nil {
|
||||
return map[string]any{"status": "not_found"}
|
||||
}
|
||||
sess.mu.Lock()
|
||||
record := sess.openClaw
|
||||
if record == nil {
|
||||
snapshot := openClawSessionSnapshotLocked(sess)
|
||||
sess.mu.Unlock()
|
||||
return snapshot
|
||||
}
|
||||
if isTerminalTaskState(sess.task.State) {
|
||||
snapshot := openClawSessionSnapshotLocked(sess)
|
||||
sess.mu.Unlock()
|
||||
return snapshot
|
||||
}
|
||||
if !record.DeadlineAt.IsZero() && time.Now().After(record.DeadlineAt) {
|
||||
sess.mu.Unlock()
|
||||
return o.failOpenClawTask(sess, "TASK_SLA_EXPIRED", "OpenClaw task exceeded its runtime SLA")
|
||||
}
|
||||
if record.ProbeInFlight {
|
||||
result := openClawRunningTaskResult(record)
|
||||
sess.lastResult = cloneMap(result)
|
||||
sess.mu.Unlock()
|
||||
return result
|
||||
}
|
||||
record.ProbeInFlight = true
|
||||
record.LastProbeAt = time.Now()
|
||||
record.ProgressStage = "probing"
|
||||
record.ProgressMessage = "Checking OpenClaw task status"
|
||||
sess.task.LastProbeAt = record.LastProbeAt
|
||||
sess.task.ProgressStage = record.ProgressStage
|
||||
sess.task.ProgressMessage = record.ProgressMessage
|
||||
gatewayProvider := record.GatewayProviderID
|
||||
runID := record.RunID
|
||||
sessionID := record.SessionID
|
||||
threadID := record.ThreadID
|
||||
turnID := record.TurnID
|
||||
sess.mu.Unlock()
|
||||
|
||||
collector := newOpenClawChatCollector()
|
||||
notifyWithCollection := func(message map[string]any) {
|
||||
collector.observe(message)
|
||||
if notify == nil {
|
||||
return
|
||||
}
|
||||
if update := openClawGatewaySessionUpdate(message, sessionID, threadID, turnID); update != nil {
|
||||
notify(update)
|
||||
}
|
||||
}
|
||||
waitStarted := time.Now()
|
||||
waitParams := map[string]any{
|
||||
"runId": runID,
|
||||
"timeoutMs": openClawTaskProbeTimeoutMs,
|
||||
}
|
||||
if waitForArtifacts {
|
||||
waitParams["waitForArtifacts"] = true
|
||||
}
|
||||
waitResult := o.openClawGatewayRequestWithRetry(
|
||||
gatewayProvider,
|
||||
"agent.wait",
|
||||
waitParams,
|
||||
openClawTaskProbeTimeout,
|
||||
notifyWithCollection,
|
||||
)
|
||||
logOpenClawGatewayTiming(
|
||||
gatewayProvider,
|
||||
"agent.wait.probe",
|
||||
sessionID,
|
||||
runID,
|
||||
time.Since(waitStarted),
|
||||
waitResult.OK,
|
||||
)
|
||||
if !waitResult.OK {
|
||||
if openClawProbeStillRunning(waitResult.Error) {
|
||||
now := time.Now()
|
||||
sess.mu.Lock()
|
||||
if sess.openClaw != nil {
|
||||
if sess.openClaw.FirstSilentFailureAt.IsZero() {
|
||||
sess.openClaw.FirstSilentFailureAt = now
|
||||
}
|
||||
if openClawSilentFailureExceeded(o.server.config, sess.openClaw.FirstSilentFailureAt, now) {
|
||||
sess.openClaw.ProbeInFlight = false
|
||||
sess.mu.Unlock()
|
||||
return o.failOpenClawTask(sess, "OPENCLAW_GATEWAY_LOST", "OpenClaw gateway stayed unreachable beyond the allowed silent duration")
|
||||
}
|
||||
sess.openClaw.ProgressStage = "running"
|
||||
sess.openClaw.ProgressMessage = "OpenClaw task is still running"
|
||||
sess.openClaw.ProbeInFlight = false
|
||||
}
|
||||
sess.task.ProgressStage = "running"
|
||||
sess.task.ProgressMessage = "OpenClaw task is still running"
|
||||
sess.task.UpdatedAt = time.Now()
|
||||
result := openClawRunningTaskResult(sess.openClaw)
|
||||
sess.lastResult = cloneMap(result)
|
||||
sess.mu.Unlock()
|
||||
return result
|
||||
}
|
||||
code := strings.TrimSpace(shared.StringArg(waitResult.Error, "code", "OPENCLAW_WAIT_FAILED"))
|
||||
message := strings.TrimSpace(shared.StringArg(waitResult.Error, "message", "openclaw wait failed"))
|
||||
return o.failOpenClawTask(sess, code, message)
|
||||
}
|
||||
sess.mu.Lock()
|
||||
if sess.openClaw != nil {
|
||||
sess.openClaw.FirstSilentFailureAt = time.Time{}
|
||||
}
|
||||
sess.mu.Unlock()
|
||||
waitPayload := shared.AsMap(waitResult.Payload)
|
||||
if !openClawWaitPayloadTerminal(waitPayload) && !collector.isTerminal() {
|
||||
return openClawMarkProbeRunning(sess)
|
||||
}
|
||||
return o.completeOpenClawTask(sess, waitPayload, collector, notify)
|
||||
}
|
||||
|
||||
func openClawMarkProbeRunning(sess *session) map[string]any {
|
||||
sess.mu.Lock()
|
||||
if sess.openClaw != nil {
|
||||
sess.openClaw.ProgressStage = "running"
|
||||
sess.openClaw.ProgressMessage = "OpenClaw task is still running"
|
||||
sess.openClaw.ProbeInFlight = false
|
||||
}
|
||||
sess.task.ProgressStage = "running"
|
||||
sess.task.ProgressMessage = "OpenClaw task is still running"
|
||||
sess.task.UpdatedAt = time.Now()
|
||||
result := openClawRunningTaskResult(sess.openClaw)
|
||||
sess.lastResult = cloneMap(result)
|
||||
sess.mu.Unlock()
|
||||
return result
|
||||
}
|
||||
|
||||
func openClawWaitPayloadTerminal(payload map[string]any) bool {
|
||||
if payload == nil {
|
||||
return false
|
||||
}
|
||||
if value, ok := payload["terminal"].(bool); ok {
|
||||
return value
|
||||
}
|
||||
for _, key := range []string{"status", "state", "phase"} {
|
||||
switch strings.TrimSpace(strings.ToLower(shared.StringArg(payload, key, ""))) {
|
||||
case "complete", "completed", "done", "final", "success", "succeeded", "failed", "failure", "error", "timeout", "timed_out", "cancelled", "canceled":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func openClawSilentFailureExceeded(config *BridgeConfig, firstFailureAt time.Time, now time.Time) bool {
|
||||
if firstFailureAt.IsZero() {
|
||||
return false
|
||||
}
|
||||
return now.Sub(firstFailureAt) >= openClawMaxAllowedSilentDuration(config)
|
||||
}
|
||||
|
||||
func openClawMaxAllowedSilentDuration(config *BridgeConfig) time.Duration {
|
||||
raw := strings.TrimSpace(shared.EnvOrDefault("XWORKMATE_BRIDGE_OPENCLAW_GATEWAY_MAX_SILENT_DURATION", ""))
|
||||
if raw == "" && config != nil {
|
||||
raw = strings.TrimSpace(config.OpenClawGateway.MaxAllowedSilentDuration)
|
||||
}
|
||||
if raw != "" {
|
||||
if parsed, err := time.ParseDuration(raw); err == nil && parsed > 0 {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return openClawDefaultMaxAllowedSilentDuration
|
||||
}
|
||||
|
||||
func openClawProbeStillRunning(errorPayload map[string]any) bool {
|
||||
code := strings.TrimSpace(strings.ToUpper(shared.StringArg(errorPayload, "code", "")))
|
||||
if code == "TIMEOUT" || code == "RPC_TIMEOUT" || code == "REQUEST_TIMEOUT" ||
|
||||
code == "OFFLINE" || code == "SOCKET_FAILURE" || code == "SOCKET_CLOSED" {
|
||||
return true
|
||||
}
|
||||
message := strings.TrimSpace(strings.ToLower(shared.StringArg(errorPayload, "message", "")))
|
||||
return strings.Contains(message, "timeout") || strings.Contains(message, "timed out")
|
||||
}
|
||||
|
||||
func (o *SessionOrchestrator) completeOpenClawTask(
|
||||
sess *session,
|
||||
waitPayload map[string]any,
|
||||
collector *openClawChatCollector,
|
||||
notify func(map[string]any),
|
||||
) map[string]any {
|
||||
if sess == nil {
|
||||
return map[string]any{"status": "not_found"}
|
||||
}
|
||||
sess.mu.Lock()
|
||||
record := sess.openClaw
|
||||
if record == nil {
|
||||
snapshot := openClawSessionSnapshotLocked(sess)
|
||||
sess.mu.Unlock()
|
||||
return snapshot
|
||||
}
|
||||
if isTerminalTaskState(sess.task.State) {
|
||||
record.ProbeInFlight = false
|
||||
snapshot := openClawSessionSnapshotLocked(sess)
|
||||
sess.mu.Unlock()
|
||||
return snapshot
|
||||
}
|
||||
sess.mu.Unlock()
|
||||
|
||||
output := ""
|
||||
if collector != nil {
|
||||
output = collector.output()
|
||||
}
|
||||
if output == "" {
|
||||
output = firstNonEmptyString(waitPayload, "output", "message", "summary", "assistantText", "text")
|
||||
if output == "" {
|
||||
output = firstNonEmptyString(shared.AsMap(waitPayload["result"]), "output", "message", "summary", "assistantText", "text")
|
||||
}
|
||||
}
|
||||
noDisplayableOutput := strings.TrimSpace(output) == ""
|
||||
if output == "" {
|
||||
output = openClawNoDisplayableText
|
||||
}
|
||||
result := map[string]any{
|
||||
"success": true,
|
||||
"status": string(TaskStateCompleted),
|
||||
"output": output,
|
||||
"message": output,
|
||||
"summary": output,
|
||||
"turnId": record.TurnID,
|
||||
"runId": record.RunID,
|
||||
"sessionId": record.SessionID,
|
||||
"threadId": record.ThreadID,
|
||||
"appThreadKey": record.ThreadID,
|
||||
"openclawSessionKey": record.SessionKey,
|
||||
"mode": router.ExecutionTargetGatewayChat,
|
||||
"resolvedExecutionTarget": router.ExecutionTargetGatewayChat,
|
||||
"resolvedProviderId": record.GatewayProviderID,
|
||||
"resolvedGatewayProviderId": record.GatewayProviderID,
|
||||
"resolvedModel": record.ResolvedModel,
|
||||
"resolvedSkills": append([]string(nil), record.ResolvedSkills...),
|
||||
"taskLoadClass": record.TaskLoadClass,
|
||||
"runtimeBudgetMinutes": record.RuntimeBudgetMinutes,
|
||||
}
|
||||
mergeOpenClawArtifactPayload(result, waitPayload)
|
||||
if nestedResult := shared.AsMap(waitPayload["result"]); len(nestedResult) > 0 {
|
||||
mergeOpenClawArtifactPayload(result, nestedResult)
|
||||
}
|
||||
if collector != nil {
|
||||
mergeOpenClawArtifactPayload(result, collector.artifactPayload())
|
||||
}
|
||||
applyOpenClawPreparedArtifactToResult(result, record.PreparedArtifact)
|
||||
artifactPayload := o.openClawArtifactExport(
|
||||
record.GatewayProviderID,
|
||||
record.ChatParams,
|
||||
record.ArtifactContract,
|
||||
record.RunID,
|
||||
record.ArtifactSinceUnixMs,
|
||||
record.PreparedArtifact,
|
||||
notify,
|
||||
)
|
||||
mergeOpenClawArtifactPayload(result, artifactPayload)
|
||||
snapshotPayload := o.openClawArtifactCollectAndSnapshot(
|
||||
record.GatewayProviderID,
|
||||
record.ChatParams,
|
||||
record.ArtifactContract,
|
||||
record.RunID,
|
||||
record.ArtifactSinceUnixMs,
|
||||
record.PreparedArtifact,
|
||||
notify,
|
||||
)
|
||||
mergeOpenClawArtifactPayload(result, snapshotPayload)
|
||||
result[openClawArtifactExportAttemptedField] = true
|
||||
exportedCount := openClawArtifactPayloadCount(result)
|
||||
logOpenClawArtifactSync(record.GatewayProviderID, record.SessionKey, record.RunID, "export", record.PreparedArtifact != nil, exportedCount > 0, exportedCount == 0)
|
||||
o.server.decorateOpenClawArtifactDownloadURLs(result, record.SessionKey, record.RunID)
|
||||
stripOpenClawArtifactInlineContent(result)
|
||||
applyOpenClawArtifactContractResult(result, record.ArtifactContract)
|
||||
guardOpenClawAgentFailedBeforeReplyResult(result)
|
||||
guardOpenClawNoDisplayableResult(result, noDisplayableOutput)
|
||||
delete(result, openClawArtifactExportAttemptedField)
|
||||
|
||||
success := parseBool(result["success"])
|
||||
state := TaskStateCompleted
|
||||
stage := "completed"
|
||||
if !success {
|
||||
state = TaskStateFailed
|
||||
stage = "failed"
|
||||
}
|
||||
artifactRecord := buildArtifactRecord(sess, result, output)
|
||||
if len(artifactRecord.Artifacts) > 0 {
|
||||
result["artifacts"] = artifactRecord.Artifacts
|
||||
}
|
||||
if artifactRecord.RemoteWorkingDirectory != "" {
|
||||
result["remoteWorkingDirectory"] = artifactRecord.RemoteWorkingDirectory
|
||||
}
|
||||
if artifactRecord.RemoteWorkspaceRefKind != "" {
|
||||
result["remoteWorkspaceRefKind"] = artifactRecord.RemoteWorkspaceRefKind
|
||||
}
|
||||
if artifactRecord.ResultSummary != "" && strings.TrimSpace(shared.StringArg(result, "resultSummary", "")) == "" {
|
||||
result["resultSummary"] = artifactRecord.ResultSummary
|
||||
}
|
||||
|
||||
sess.mu.Lock()
|
||||
sess.task.State = state
|
||||
sess.task.UpdatedAt = time.Now()
|
||||
sess.task.ProgressStage = stage
|
||||
sess.task.ProgressMessage = output
|
||||
sess.task.ProgressTerminal = true
|
||||
if sess.openClaw != nil {
|
||||
sess.openClaw.ProgressStage = stage
|
||||
sess.openClaw.ProgressMessage = output
|
||||
sess.openClaw.ProgressTerminal = true
|
||||
sess.openClaw.ProbeInFlight = false
|
||||
}
|
||||
if output != "" {
|
||||
sess.history = append(sess.history, "ASSISTANT: "+output)
|
||||
}
|
||||
sess.artifacts = artifactRecord
|
||||
sess.lastResult = cloneMap(result)
|
||||
sess.mu.Unlock()
|
||||
o.releaseOpenClawAdmission(sess)
|
||||
cleanupOpenClawTurnAttachments(record)
|
||||
if notify != nil {
|
||||
notify(shared.NotificationEnvelope("session.update", openClawGatewayCompletedResultUpdate(record.SessionID, record.ThreadID, record.TurnID, result)))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func cleanupOpenClawTurnAttachments(record *OpenClawTaskRecord) {
|
||||
if record == nil {
|
||||
return
|
||||
}
|
||||
workingDirectory := strings.TrimSpace(shared.StringArg(record.ChatParams, "workingDirectory", ""))
|
||||
if workingDirectory == "" {
|
||||
return
|
||||
}
|
||||
attachmentDirectory := filepath.Join(
|
||||
workingDirectory,
|
||||
".xworkmate",
|
||||
"attachments",
|
||||
safeOpenClawAttachmentPathSegment(record.TurnID, "turn"),
|
||||
)
|
||||
if !openClawSafeAttachmentCleanupPath(workingDirectory, attachmentDirectory) {
|
||||
return
|
||||
}
|
||||
_ = os.RemoveAll(attachmentDirectory)
|
||||
}
|
||||
|
||||
func openClawSafeAttachmentCleanupPath(workingDirectory string, attachmentDirectory string) bool {
|
||||
workingRoot, err := filepath.Abs(strings.TrimSpace(workingDirectory))
|
||||
if err != nil || workingRoot == "" {
|
||||
return false
|
||||
}
|
||||
attachmentRoot := filepath.Join(workingRoot, ".xworkmate", "attachments")
|
||||
target, err := filepath.Abs(strings.TrimSpace(attachmentDirectory))
|
||||
if err != nil || target == "" {
|
||||
return false
|
||||
}
|
||||
rel, err := filepath.Rel(attachmentRoot, target)
|
||||
if err != nil || rel == "." || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func openClawSessionSnapshotLocked(sess *session) map[string]any {
|
||||
payload := map[string]any{
|
||||
"status": string(sess.task.State),
|
||||
"sessionId": sess.sessionID,
|
||||
"threadId": sess.threadID,
|
||||
"task": openClawTaskMapLocked(sess),
|
||||
}
|
||||
if len(sess.lastResult) > 0 {
|
||||
payload["result"] = cloneMap(sess.lastResult)
|
||||
}
|
||||
if len(sess.artifacts.Artifacts) > 0 ||
|
||||
sess.artifacts.RemoteWorkingDirectory != "" ||
|
||||
sess.artifacts.RemoteWorkspaceRefKind != "" ||
|
||||
sess.artifacts.ResultSummary != "" {
|
||||
payload["artifacts"] = map[string]any{
|
||||
"items": cloneMapSlice(sess.artifacts.Artifacts),
|
||||
"remoteWorkingDirectory": sess.artifacts.RemoteWorkingDirectory,
|
||||
"remoteWorkspaceRefKind": sess.artifacts.RemoteWorkspaceRefKind,
|
||||
"resultSummary": sess.artifacts.ResultSummary,
|
||||
"updatedAt": sess.artifacts.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
}
|
||||
if sess.openClaw != nil {
|
||||
payload["progress"] = openClawTaskProgress(sess.openClaw)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func openClawTaskMapLocked(sess *session) map[string]any {
|
||||
task := sess.task
|
||||
payload := map[string]any{
|
||||
"sessionId": task.SessionID,
|
||||
"threadId": task.ThreadID,
|
||||
"turnId": task.TurnID,
|
||||
"runId": task.RunID,
|
||||
"appThreadKey": task.ThreadID,
|
||||
"openclawSessionKey": task.SessionKey,
|
||||
"provider": task.Provider,
|
||||
"target": task.Target,
|
||||
"gatewayProviderId": task.GatewayProviderID,
|
||||
"state": string(task.State),
|
||||
"kind": string(task.Kind),
|
||||
"taskLoadClass": task.TaskLoadClass,
|
||||
"artifactScope": task.ArtifactScope,
|
||||
"artifactDirectory": task.ArtifactDirectory,
|
||||
"runtimeBudgetMinutes": task.RuntimeBudgetMinutes,
|
||||
"updatedAt": task.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
if !task.StartedAt.IsZero() {
|
||||
payload["startedAt"] = task.StartedAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
if !task.DeadlineAt.IsZero() {
|
||||
payload["deadlineAt"] = task.DeadlineAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
if !task.LastProbeAt.IsZero() {
|
||||
payload["lastProbeAt"] = task.LastProbeAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
if task.ProgressStage != "" || task.ProgressMessage != "" {
|
||||
payload["progress"] = map[string]any{
|
||||
"stage": task.ProgressStage,
|
||||
"message": task.ProgressMessage,
|
||||
"terminal": task.ProgressTerminal,
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
@ -1,197 +0,0 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"xworkmate-bridge/internal/shared"
|
||||
)
|
||||
|
||||
// 持久 run 仓 / run 关联与 WS 解耦(T7/T8/T9)。
|
||||
//
|
||||
// 背景:OpenClaw gateway turn 采用异步模型——chat.send 快速返回 runId,bridge 把
|
||||
// run 记录(sess.openClaw)、预算(sess.task.DeadlineAt)、运行句柄(sess.lastResult)
|
||||
// 落在「按 sessionID 维度」的 per-session store 里(s.sessions),其生命周期独立于
|
||||
// bridge↔gateway 的 WebSocket 连接。客户端随后轮询 tasks.get。
|
||||
//
|
||||
// 此前 tasks.get 每次都强依赖 gateway 应答:一旦 WS 抖动 / 重连后 run 内存态丢失,
|
||||
// tasks.get 回 not_found 或 socket_closed,已完成的结果就此丢失,客户端要么硬失败、
|
||||
// 要么(修复前)无限轮询。下列辅助把 tasks.get 改造为「优先用持久 run 仓兜底」:
|
||||
//
|
||||
// T8 已观察到的终态结果缓存进 sess.lastResult,gateway 之后查不到也不丢;
|
||||
// T7 gateway 暂时无法确认(unavailable / socket closed / not_found)但 run 仍在预算内时,
|
||||
// 合成一个 running 句柄让客户端继续轮询,跨越瞬时抖动(与 WS 生命周期解耦);
|
||||
// T9 run 超过 DeadlineAt 且 gateway 仍无法确认时,回确定性的 interrupted 终态。
|
||||
|
||||
// openClawTaskGetResultIsTerminal 判断一个 tasks.get 结果是否表示 run 已结束。
|
||||
// 注意:仍在 artifact 同步中的结果会被 normalizeOpenClawTaskGetResult 重写为 status=running,
|
||||
// 因此这里只认显式终态,不会把「同步中」误判为终态。
|
||||
func openClawTaskGetResultIsTerminal(payload map[string]any) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(shared.StringArg(payload, "status", ""))) {
|
||||
case string(TaskStateCompleted), string(TaskStateFailed), string(TaskStateCancelled),
|
||||
"interrupted", "partially_delivered":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// cacheOpenClawTaskGetResultIfTerminal 把一次 gateway 确认的终态结果落进 per-session 持久 run 仓(T8)。
|
||||
func (s *Server) cacheOpenClawTaskGetResultIfTerminal(params map[string]any, payload map[string]any) {
|
||||
if len(payload) == 0 || !openClawTaskGetResultIsTerminal(payload) {
|
||||
return
|
||||
}
|
||||
sess := s.findTaskSession(params)
|
||||
if sess == nil {
|
||||
return
|
||||
}
|
||||
sess.mu.Lock()
|
||||
defer sess.mu.Unlock()
|
||||
switch strings.ToLower(strings.TrimSpace(shared.StringArg(payload, "status", ""))) {
|
||||
case string(TaskStateFailed):
|
||||
sess.task.State = TaskStateFailed
|
||||
case string(TaskStateCancelled):
|
||||
sess.task.State = TaskStateCancelled
|
||||
default:
|
||||
sess.task.State = TaskStateCompleted
|
||||
}
|
||||
sess.task.ProgressTerminal = true
|
||||
sess.task.ProgressStage = strings.ToLower(strings.TrimSpace(shared.StringArg(payload, "status", "")))
|
||||
sess.task.UpdatedAt = time.Now()
|
||||
sess.lastResult = cloneMap(payload)
|
||||
}
|
||||
|
||||
// cachedTerminalOpenClawResult 返回某 run 此前已观察到的终态结果(若有)(T7/T8)。
|
||||
func (s *Server) cachedTerminalOpenClawResult(params map[string]any) (map[string]any, bool) {
|
||||
sess := s.findTaskSession(params)
|
||||
if sess == nil {
|
||||
return nil, false
|
||||
}
|
||||
sess.mu.Lock()
|
||||
defer sess.mu.Unlock()
|
||||
return cachedTerminalForRunLocked(sess, params)
|
||||
}
|
||||
|
||||
// cachedTerminalForRunLocked 仅当缓存终态确实属于「本次请求的 runId」时才命中,
|
||||
// 防止同一 session 复用后把旧 run 的终态错配给新 run。调用方须持有 sess.mu。
|
||||
func cachedTerminalForRunLocked(sess *session, params map[string]any) (map[string]any, bool) {
|
||||
if !sess.task.ProgressTerminal || len(sess.lastResult) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
if !openClawTaskGetResultIsTerminal(sess.lastResult) {
|
||||
return nil, false
|
||||
}
|
||||
requestedRun := strings.TrimSpace(shared.StringArg(params, "runId", ""))
|
||||
if requestedRun == "" {
|
||||
requestedRun = strings.TrimSpace(shared.StringArg(params, "taskId", ""))
|
||||
}
|
||||
if requestedRun != "" {
|
||||
cachedRun := firstNonEmptyString(sess.lastResult, "runId", "taskId")
|
||||
if cachedRun != "" && !strings.EqualFold(cachedRun, requestedRun) {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return cloneMap(sess.lastResult), true
|
||||
}
|
||||
|
||||
// openClawTaskGetGatewayUnconfirmedFallback 在 gateway 无法确认 run 时,用持久 run 仓兜底(T7/T9):
|
||||
// - 已有缓存终态 -> 直接返回;
|
||||
// - run 仍在预算内 -> 合成 running 句柄,客户端继续轮询,跨越瞬时抖动;
|
||||
// - run 超过 deadline -> 回确定性 interrupted 终态。
|
||||
//
|
||||
// 没有任何 per-session 记录时退回旧行为(not_found),不改变无状态查询的语义。
|
||||
func (s *Server) openClawTaskGetGatewayUnconfirmedFallback(params map[string]any, code string, message string) map[string]any {
|
||||
notFound := func() map[string]any {
|
||||
return map[string]any{
|
||||
"ok": false,
|
||||
"status": "not_found",
|
||||
"code": fallbackString(code, "TASK_LOOKUP_FAILED"),
|
||||
"message": fallbackString(message, "openclaw native task lookup failed"),
|
||||
}
|
||||
}
|
||||
sess := s.findTaskSession(params)
|
||||
if sess == nil {
|
||||
return notFound()
|
||||
}
|
||||
sess.mu.Lock()
|
||||
defer sess.mu.Unlock()
|
||||
if cached, ok := cachedTerminalForRunLocked(sess, params); ok {
|
||||
return cached
|
||||
}
|
||||
if sess.openClaw == nil {
|
||||
return notFound()
|
||||
}
|
||||
now := time.Now()
|
||||
if !sess.task.DeadlineAt.IsZero() && now.After(sess.task.DeadlineAt) {
|
||||
return s.markOpenClawRunDeadlineInterruptedLocked(sess, code, message)
|
||||
}
|
||||
// 仍在预算内:合成 running 句柄让客户端继续轮询,不因一次瞬时抖动硬失败。
|
||||
metricTaskGetUnconfirmedFallbackInc() // T12
|
||||
running := openClawRunningTaskResult(sess.openClaw)
|
||||
running["transportDegraded"] = true
|
||||
if strings.TrimSpace(code) != "" {
|
||||
running["transportDegradedCode"] = strings.TrimSpace(code)
|
||||
}
|
||||
// T11:带 runId 的日志,便于与 App / 插件 / 网关四层按 runId 串联。
|
||||
log.Printf("level=warn component=openclaw_run_registry event=tasks_get_unconfirmed_fallback runId=%q openclawSessionKey=%q code=%q",
|
||||
sess.openClaw.RunID, sess.openClaw.SessionKey, strings.TrimSpace(code))
|
||||
sess.lastResult = cloneMap(running)
|
||||
return running
|
||||
}
|
||||
|
||||
// markOpenClawRunDeadlineInterruptedLocked 为「超过预算且 gateway 无法确认」的 run 生成确定性
|
||||
// interrupted 终态(T9)。调用方须持有 sess.mu。
|
||||
func (s *Server) markOpenClawRunDeadlineInterruptedLocked(sess *session, code string, message string) map[string]any {
|
||||
now := time.Now()
|
||||
sess.task.State = TaskStateFailed
|
||||
sess.task.ProgressTerminal = true
|
||||
sess.task.ProgressStage = "interrupted"
|
||||
sess.task.ProgressMessage = "OpenClaw run exceeded its budget and could not be confirmed"
|
||||
sess.task.UpdatedAt = now
|
||||
metricRunDeadlineInterruptInc() // T12
|
||||
// T11:带 runId 的终态日志。
|
||||
if sess.openClaw != nil {
|
||||
log.Printf("level=warn component=openclaw_run_registry event=run_deadline_interrupt runId=%q openclawSessionKey=%q deadlineAt=%q code=%q",
|
||||
sess.openClaw.RunID, sess.openClaw.SessionKey,
|
||||
sess.openClaw.DeadlineAt.UTC().Format(time.RFC3339Nano), strings.TrimSpace(code))
|
||||
}
|
||||
|
||||
result := map[string]any{
|
||||
"ok": true,
|
||||
"success": false,
|
||||
"status": "interrupted",
|
||||
"event": "interrupted",
|
||||
"pending": false,
|
||||
"code": "OPENCLAW_RUN_DEADLINE_EXCEEDED",
|
||||
"artifactSyncStatus": "interrupted",
|
||||
"message": "OpenClaw 任务超过预算上限且网关无法确认结果,已结束本轮等待。任务可能已在后台完成,请重新发送请求以拿回结果。",
|
||||
"artifacts": []any{},
|
||||
}
|
||||
if strings.TrimSpace(code) != "" {
|
||||
result["gatewayUnconfirmedCode"] = strings.TrimSpace(code)
|
||||
}
|
||||
if strings.TrimSpace(message) != "" {
|
||||
result["gatewayUnconfirmedMessage"] = strings.TrimSpace(message)
|
||||
}
|
||||
if record := sess.openClaw; record != nil {
|
||||
result["runId"] = record.RunID
|
||||
result["taskId"] = record.RunID
|
||||
result["turnId"] = record.TurnID
|
||||
result["sessionId"] = record.SessionID
|
||||
result["threadId"] = record.ThreadID
|
||||
result["appThreadKey"] = record.ThreadID
|
||||
result["openclawSessionKey"] = record.SessionKey
|
||||
result["resolvedGatewayProviderId"] = record.GatewayProviderID
|
||||
result["startedAt"] = record.StartedAt.UTC().Format(time.RFC3339Nano)
|
||||
result["deadlineAt"] = record.DeadlineAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
sess.lastResult = cloneMap(result)
|
||||
return result
|
||||
}
|
||||
|
||||
func fallbackString(value string, fallback string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fallback
|
||||
}
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
@ -1,154 +0,0 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"xworkmate-bridge/internal/shared"
|
||||
)
|
||||
|
||||
func newRunRegistryTestServer(deadline time.Time) (*Server, map[string]any) {
|
||||
sess := &session{sessionID: "s1", threadID: "t1"}
|
||||
sess.task.RunID = "run-1"
|
||||
sess.task.SessionKey = "sk"
|
||||
sess.task.GatewayProviderID = "openclaw"
|
||||
sess.task.DeadlineAt = deadline
|
||||
sess.openClaw = &OpenClawTaskRecord{
|
||||
SessionID: "s1",
|
||||
ThreadID: "t1",
|
||||
TurnID: "turn-1",
|
||||
RunID: "run-1",
|
||||
SessionKey: "sk",
|
||||
GatewayProviderID: "openclaw",
|
||||
StartedAt: time.Now().Add(-time.Minute),
|
||||
DeadlineAt: deadline,
|
||||
}
|
||||
srv := &Server{sessions: map[string]*session{"s1": sess}}
|
||||
params := map[string]any{"sessionId": "s1", "runId": "run-1"}
|
||||
return srv, params
|
||||
}
|
||||
|
||||
func TestOpenClawTaskGetResultIsTerminal(t *testing.T) {
|
||||
cases := []struct {
|
||||
status string
|
||||
want bool
|
||||
}{
|
||||
{"completed", true},
|
||||
{"failed", true},
|
||||
{"cancelled", true},
|
||||
{"interrupted", true},
|
||||
{"partially_delivered", true},
|
||||
{"running", false},
|
||||
{"syncing-artifacts", false},
|
||||
{"queued", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := openClawTaskGetResultIsTerminal(map[string]any{"status": tc.status}); got != tc.want {
|
||||
t.Errorf("status=%q: got %v, want %v", tc.status, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// T7: gateway 无法确认但 run 仍在预算内 -> 合成 running 句柄续轮询。
|
||||
func TestGatewayUnconfirmedFallbackWithinBudgetKeepsPolling(t *testing.T) {
|
||||
srv, params := newRunRegistryTestServer(time.Now().Add(30 * time.Minute))
|
||||
got := srv.openClawTaskGetGatewayUnconfirmedFallback(params, "SOCKET_CLOSED", "socket closed")
|
||||
if status := shared.StringArg(got, "status", ""); status != string(TaskStateRunning) {
|
||||
t.Fatalf("status = %q, want running", status)
|
||||
}
|
||||
if !parseBool(got["transportDegraded"]) {
|
||||
t.Fatalf("transportDegraded not set: %v", got)
|
||||
}
|
||||
if shared.StringArg(got, "runId", "") != "run-1" {
|
||||
t.Fatalf("runId mismatch: %v", got["runId"])
|
||||
}
|
||||
}
|
||||
|
||||
// T9: run 超过 deadline 且 gateway 无法确认 -> 确定性 interrupted 终态。
|
||||
func TestGatewayUnconfirmedFallbackPastDeadlineInterrupts(t *testing.T) {
|
||||
srv, params := newRunRegistryTestServer(time.Now().Add(-time.Minute))
|
||||
got := srv.openClawTaskGetGatewayUnconfirmedFallback(params, "SOCKET_CLOSED", "socket closed")
|
||||
if status := shared.StringArg(got, "status", ""); status != "interrupted" {
|
||||
t.Fatalf("status = %q, want interrupted", status)
|
||||
}
|
||||
if code := shared.StringArg(got, "code", ""); code != "OPENCLAW_RUN_DEADLINE_EXCEEDED" {
|
||||
t.Fatalf("code = %q, want OPENCLAW_RUN_DEADLINE_EXCEEDED", code)
|
||||
}
|
||||
if parseBool(got["success"]) {
|
||||
t.Fatalf("interrupted result must not be success")
|
||||
}
|
||||
sess := srv.findTaskSession(params)
|
||||
if sess == nil || !sess.task.ProgressTerminal || sess.task.State != TaskStateFailed {
|
||||
t.Fatalf("session terminal state not recorded: %+v", sess)
|
||||
}
|
||||
}
|
||||
|
||||
// T8: 已观察到的终态被缓存,且即使之后 gateway 不可达也优先返回缓存终态。
|
||||
func TestTerminalResultCachedAndServedAfterGatewayLoss(t *testing.T) {
|
||||
srv, params := newRunRegistryTestServer(time.Now().Add(30 * time.Minute))
|
||||
terminal := map[string]any{
|
||||
"ok": true,
|
||||
"success": true,
|
||||
"status": "completed",
|
||||
"runId": "run-1",
|
||||
"message": "done",
|
||||
}
|
||||
srv.cacheOpenClawTaskGetResultIfTerminal(params, terminal)
|
||||
|
||||
cached, ok := srv.cachedTerminalOpenClawResult(params)
|
||||
if !ok {
|
||||
t.Fatalf("expected cached terminal result")
|
||||
}
|
||||
if shared.StringArg(cached, "status", "") != "completed" {
|
||||
t.Fatalf("cached status = %q, want completed", cached["status"])
|
||||
}
|
||||
|
||||
// 即使 run 已过 deadline + gateway 丢失,也应优先返回缓存终态而非 interrupted。
|
||||
sess := srv.findTaskSession(params)
|
||||
sess.mu.Lock()
|
||||
sess.task.DeadlineAt = time.Now().Add(-time.Hour)
|
||||
sess.mu.Unlock()
|
||||
got := srv.openClawTaskGetGatewayUnconfirmedFallback(params, "SOCKET_CLOSED", "socket closed")
|
||||
if shared.StringArg(got, "status", "") != "completed" {
|
||||
t.Fatalf("expected cached completed to win over deadline interrupt, got %v", got["status"])
|
||||
}
|
||||
}
|
||||
|
||||
// 同一 session 复用后,旧 run 的终态不得错配给新 runId 的查询。
|
||||
func TestCachedTerminalNotServedForDifferentRunId(t *testing.T) {
|
||||
srv, params := newRunRegistryTestServer(time.Now().Add(30 * time.Minute))
|
||||
srv.cacheOpenClawTaskGetResultIfTerminal(params, map[string]any{
|
||||
"status": "completed", "success": true, "runId": "run-1",
|
||||
})
|
||||
// 新一轮查询带不同 runId -> 不应命中旧缓存。
|
||||
newParams := map[string]any{"sessionId": "s1", "runId": "run-2"}
|
||||
if _, ok := srv.cachedTerminalOpenClawResult(newParams); ok {
|
||||
t.Fatalf("stale terminal for run-1 must not be served for run-2")
|
||||
}
|
||||
// 原 runId 仍应命中。
|
||||
if _, ok := srv.cachedTerminalOpenClawResult(params); !ok {
|
||||
t.Fatalf("terminal for run-1 should still be served for run-1")
|
||||
}
|
||||
}
|
||||
|
||||
// running 结果不应被当作终态缓存。
|
||||
func TestRunningResultNotCachedAsTerminal(t *testing.T) {
|
||||
srv, params := newRunRegistryTestServer(time.Now().Add(30 * time.Minute))
|
||||
srv.cacheOpenClawTaskGetResultIfTerminal(params, map[string]any{"status": "running", "runId": "run-1"})
|
||||
if _, ok := srv.cachedTerminalOpenClawResult(params); ok {
|
||||
t.Fatalf("running result must not be cached as terminal")
|
||||
}
|
||||
}
|
||||
|
||||
// 无 per-session 记录时退回旧的 not_found 行为。
|
||||
func TestGatewayUnconfirmedFallbackWithoutSessionReturnsNotFound(t *testing.T) {
|
||||
srv := &Server{sessions: map[string]*session{}}
|
||||
got := srv.openClawTaskGetGatewayUnconfirmedFallback(map[string]any{"sessionId": "missing"}, "X", "y")
|
||||
if parseBool(got["ok"]) {
|
||||
t.Fatalf("expected ok=false not_found, got %v", got)
|
||||
}
|
||||
if shared.StringArg(got, "status", "") != "not_found" {
|
||||
t.Fatalf("status = %q, want not_found", got["status"])
|
||||
}
|
||||
}
|
||||
@ -308,12 +308,14 @@ func (o *SessionOrchestrator) startOpenClawGatewayTask(
|
||||
releaseAdmission func(),
|
||||
notify func(map[string]any),
|
||||
) (map[string]any, *shared.RPCError) {
|
||||
collector := newOpenClawChatCollector()
|
||||
sessionID := strings.TrimSpace(shared.StringArg(params, "sessionId", ""))
|
||||
threadID := strings.TrimSpace(shared.StringArg(params, "threadId", sessionID))
|
||||
if sessionID == "" {
|
||||
sessionID = threadID
|
||||
}
|
||||
notifyWithCollection := func(message map[string]any) {
|
||||
collector.observe(message)
|
||||
if notify == nil {
|
||||
return
|
||||
}
|
||||
@ -323,7 +325,12 @@ func (o *SessionOrchestrator) startOpenClawGatewayTask(
|
||||
}
|
||||
sessionKey := o.openClawSessionKey(params, turnID)
|
||||
params = withOpenClawWritableWorkspace(params, openClawAppThreadKey(params))
|
||||
artifactContract := openClawArtifactContractForParams(params, nil)
|
||||
chatParams, rpcErr := openClawChatSendParamsWithSessionKey(params, turnID, sessionKey)
|
||||
if rpcErr != nil {
|
||||
return nil, rpcErr
|
||||
}
|
||||
artifactContract := openClawArtifactContractForParams(params, chatParams)
|
||||
artifactSinceUnixMs := time.Now().Add(-1 * time.Second).UnixMilli()
|
||||
preparedArtifact, prepareErr := o.openClawArtifactPrepare(
|
||||
gatewayProvider,
|
||||
params,
|
||||
@ -336,19 +343,13 @@ func (o *SessionOrchestrator) startOpenClawGatewayTask(
|
||||
return nil, prepareErr
|
||||
}
|
||||
logOpenClawArtifactSync(gatewayProvider, sessionKey, turnID, "prepare", true, false, false)
|
||||
params = withOpenClawPreparedArtifactWorkspace(params, preparedArtifact)
|
||||
chatParams, rpcErr := openClawChatSendParamsWithSessionKey(params, turnID, sessionKey)
|
||||
if rpcErr != nil {
|
||||
return nil, rpcErr
|
||||
}
|
||||
applyOpenClawPreparedArtifactToChatParams(chatParams, preparedArtifact, sessionKey, turnID, artifactContract)
|
||||
chatSendTimeout := openClawAgentWaitTimeout(params, chatParams)
|
||||
sendStarted := time.Now()
|
||||
sendResult := o.openClawGatewayRequestWithRetry(
|
||||
gatewayProvider,
|
||||
"chat.send",
|
||||
chatParams,
|
||||
chatSendTimeout,
|
||||
2*time.Minute,
|
||||
notifyWithCollection,
|
||||
)
|
||||
logOpenClawGatewayTiming(
|
||||
@ -385,24 +386,25 @@ func (o *SessionOrchestrator) startOpenClawGatewayTask(
|
||||
taskLoadClass, runtimeBudgetMinutes := openClawTaskRuntimePolicy(params, chatParams, artifactContract)
|
||||
startedAt := time.Now()
|
||||
record := &OpenClawTaskRecord{
|
||||
SessionID: sessionID,
|
||||
ThreadID: threadID,
|
||||
TurnID: turnID,
|
||||
RunID: runID,
|
||||
SessionKey: sessionKey,
|
||||
GatewayProviderID: gatewayProvider,
|
||||
TaskLoadClass: taskLoadClass,
|
||||
RuntimeBudgetMinutes: runtimeBudgetMinutes,
|
||||
StartedAt: startedAt,
|
||||
DeadlineAt: startedAt.Add(time.Duration(runtimeBudgetMinutes) * time.Minute),
|
||||
ProgressStage: "running",
|
||||
ProgressMessage: "OpenClaw task accepted",
|
||||
PreparedArtifact: preparedArtifact,
|
||||
RequiresArtifactExport: artifactContract.RequiresArtifactExport,
|
||||
ExpectedArtifactDirs: append([]string(nil), artifactContract.ExpectedArtifactDirs...),
|
||||
RequiredArtifactExts: append([]string(nil), artifactContract.RequiredArtifactExts...),
|
||||
ResolvedModel: routing.Model,
|
||||
ResolvedSkills: append([]string(nil), routing.Skills...),
|
||||
SessionID: sessionID,
|
||||
ThreadID: threadID,
|
||||
TurnID: turnID,
|
||||
RunID: runID,
|
||||
SessionKey: sessionKey,
|
||||
GatewayProviderID: gatewayProvider,
|
||||
TaskLoadClass: taskLoadClass,
|
||||
ArtifactSinceUnixMs: artifactSinceUnixMs,
|
||||
RuntimeBudgetMinutes: runtimeBudgetMinutes,
|
||||
StartedAt: startedAt,
|
||||
DeadlineAt: startedAt.Add(time.Duration(runtimeBudgetMinutes) * time.Minute),
|
||||
ProgressStage: "running",
|
||||
ProgressMessage: "OpenClaw task accepted",
|
||||
ChatParams: cloneMap(chatParams),
|
||||
PreparedArtifact: preparedArtifact,
|
||||
ArtifactContract: artifactContract,
|
||||
ResolvedModel: routing.Model,
|
||||
ResolvedSkills: append([]string(nil), routing.Skills...),
|
||||
AdmissionRelease: releaseAdmission,
|
||||
}
|
||||
sess := o.server.getOrCreateSession(sessionID, threadID)
|
||||
sess.mu.Lock()
|
||||
@ -417,17 +419,11 @@ func (o *SessionOrchestrator) startOpenClawGatewayTask(
|
||||
sess.task.DeadlineAt = record.DeadlineAt
|
||||
sess.task.ProgressStage = "running"
|
||||
sess.task.ProgressMessage = "OpenClaw task accepted"
|
||||
// 新一轮 turn 复用同一 session 时,必须重置上一轮可能留下的终态标记,
|
||||
// 否则持久 run 仓(T8)会把旧 runId 的终态错配给新 run。
|
||||
sess.task.State = TaskStateRunning
|
||||
sess.task.ProgressTerminal = false
|
||||
sess.openClaw = record
|
||||
running := openClawRunningTaskResult(record)
|
||||
sess.lastResult = cloneMap(running)
|
||||
sess.mu.Unlock()
|
||||
if releaseAdmission != nil {
|
||||
releaseAdmission()
|
||||
}
|
||||
o.startOpenClawTaskMonitor(sess)
|
||||
if notify != nil {
|
||||
notify(shared.NotificationEnvelope("session.update", map[string]any{
|
||||
"sessionId": sessionID,
|
||||
@ -444,6 +440,7 @@ func (o *SessionOrchestrator) startOpenClawGatewayTask(
|
||||
"progress": running["progress"],
|
||||
}))
|
||||
}
|
||||
_ = collector
|
||||
return running, nil
|
||||
}
|
||||
|
||||
@ -527,6 +524,14 @@ func logOpenClawArtifactSync(
|
||||
)
|
||||
}
|
||||
|
||||
func openClawArtifactPayloadCount(payload map[string]any) int {
|
||||
if payload == nil {
|
||||
return 0
|
||||
}
|
||||
remoteWorkingDirectory := strings.TrimSpace(shared.StringArg(payload, "remoteWorkingDirectory", ""))
|
||||
return len(extractArtifactPayloads(payload, remoteWorkingDirectory))
|
||||
}
|
||||
|
||||
func isSessionTaskMethod(method string) bool {
|
||||
switch strings.TrimSpace(method) {
|
||||
case "session.start", "session.message":
|
||||
@ -577,24 +582,9 @@ func (o *SessionOrchestrator) openClawArtifactPrepare(
|
||||
sessionKey = strings.TrimSpace(sessionKey)
|
||||
runID = strings.TrimSpace(runID)
|
||||
if sessionKey == "" || runID == "" {
|
||||
log.Printf(
|
||||
"level=warn component=openclaw_gateway event=artifact_prepare_missing_context provider=%q hasOpenClawSessionKey=%t hasRunId=%t appThreadKey=%q",
|
||||
gatewayProvider,
|
||||
sessionKey != "",
|
||||
runID != "",
|
||||
openClawAppThreadKey(params),
|
||||
)
|
||||
return nil, &shared.RPCError{Code: -32602, Message: "openclaw artifact prepare requires openclawSessionKey and runId"}
|
||||
}
|
||||
prepareParams := openClawSessionPrepareParams(params, sessionKey, runID, artifactContract)
|
||||
log.Printf(
|
||||
"level=info component=openclaw_gateway event=artifact_prepare_context provider=%q hasOpenClawSessionKey=%t hasRunId=%t hasWorkspaceDir=%t expectedArtifactDirs=%d",
|
||||
gatewayProvider,
|
||||
strings.TrimSpace(shared.StringArg(prepareParams, "openclawSessionKey", "")) != "",
|
||||
strings.TrimSpace(shared.StringArg(prepareParams, "runId", "")) != "",
|
||||
strings.TrimSpace(shared.StringArg(prepareParams, "workspaceDir", "")) != "",
|
||||
len(shared.ListArg(prepareParams, "expectedArtifactDirs")),
|
||||
)
|
||||
prepareResult := o.openClawGatewayRequestWithRetry(
|
||||
gatewayProvider,
|
||||
"xworkmate.session.prepare",
|
||||
@ -603,8 +593,16 @@ func (o *SessionOrchestrator) openClawArtifactPrepare(
|
||||
notify,
|
||||
)
|
||||
if !prepareResult.OK {
|
||||
if isOpenClawUnknownMethodError(prepareResult.Error, "xworkmate.session.prepare") {
|
||||
return openClawPreparedArtifactScopeFromPayload(openClawFallbackSessionPreparePayload(prepareParams)), nil
|
||||
if openClawPrepareUnsupported(prepareResult.Error) {
|
||||
prepared := openClawLegacyPreparedArtifactScope(params, sessionKey, runID)
|
||||
log.Printf(
|
||||
"level=warn component=openclaw_gateway event=session_prepare_legacy_fallback provider=%q sessionId=%q runId=%q artifactScope=%q",
|
||||
gatewayProvider,
|
||||
sessionKey,
|
||||
runID,
|
||||
prepared.ArtifactScope,
|
||||
)
|
||||
return prepared, nil
|
||||
}
|
||||
return nil, gatewayRPCError(prepareResult.Error, "openclaw artifact prepare failed")
|
||||
}
|
||||
@ -615,58 +613,46 @@ func (o *SessionOrchestrator) openClawArtifactPrepare(
|
||||
return prepared, nil
|
||||
}
|
||||
|
||||
func isOpenClawUnknownMethodError(errorPayload map[string]any, method string) bool {
|
||||
func openClawPrepareUnsupported(errorPayload map[string]any) bool {
|
||||
code := strings.ToUpper(strings.TrimSpace(shared.StringArg(errorPayload, "code", "")))
|
||||
message := strings.ToLower(strings.TrimSpace(shared.StringArg(errorPayload, "message", "")))
|
||||
if message == "" {
|
||||
if !strings.Contains(message, "xworkmate.session.prepare") {
|
||||
return false
|
||||
}
|
||||
// 消息形如「unknown method: <method>」已明确指向「网关不认识该方法」,足以判定,
|
||||
// 据此走 graceful fallback(如 openClawFallbackSessionPreparePayload)。
|
||||
//
|
||||
// 注意:不能再用严格的 code 白名单来 gate。真实网关常以数字 JSON-RPC code
|
||||
// (-32601 method not found / -32600 invalid request / -32002 等) 回传,
|
||||
// 经 shared.StringArg(fmt.Sprint) 会被字符串化为 "-32601"/"-32002",
|
||||
// 旧实现只接受 {"", INVALID_REQUEST, METHOD_NOT_FOUND},导致 fallback 失效、
|
||||
// session.prepare 直接以 -32002 硬失败整轮任务。
|
||||
return strings.Contains(message, "unknown method") &&
|
||||
strings.Contains(message, strings.ToLower(strings.TrimSpace(method)))
|
||||
return code == "INVALID_REQUEST" ||
|
||||
code == "METHOD_NOT_FOUND" ||
|
||||
code == "UNKNOWN_METHOD" ||
|
||||
strings.Contains(message, "unknown method") ||
|
||||
strings.Contains(message, "method not found")
|
||||
}
|
||||
|
||||
func openClawFallbackSessionPreparePayload(params map[string]any) map[string]any {
|
||||
sessionKey := strings.TrimSpace(shared.StringArg(params, "openclawSessionKey", ""))
|
||||
if sessionKey == "" {
|
||||
sessionKey = strings.TrimSpace(shared.StringArg(params, "sessionKey", ""))
|
||||
func openClawLegacyPreparedArtifactScope(params map[string]any, sessionKey string, runID string) *openClawPreparedArtifactScope {
|
||||
sessionKey = strings.TrimSpace(sessionKey)
|
||||
runID = strings.TrimSpace(runID)
|
||||
artifactScope := "tasks/" + sessionKey + "/" + runID
|
||||
workspaceRoot := openClawLegacyArtifactWorkspaceRoot(params)
|
||||
return &openClawPreparedArtifactScope{
|
||||
RemoteWorkingDirectory: workspaceRoot,
|
||||
RemoteWorkspaceRefKind: "remotePath",
|
||||
ArtifactScope: artifactScope,
|
||||
ArtifactDirectory: filepath.Join(workspaceRoot, filepath.FromSlash(artifactScope)),
|
||||
RelativeArtifactDirectory: artifactScope,
|
||||
ScopeKind: "task",
|
||||
}
|
||||
if sessionKey == "" {
|
||||
sessionKey = "main"
|
||||
}
|
||||
runID := strings.TrimSpace(shared.StringArg(params, "runId", ""))
|
||||
if runID == "" {
|
||||
runID = strings.TrimSpace(shared.StringArg(params, "taskId", ""))
|
||||
}
|
||||
if runID == "" {
|
||||
runID = strings.TrimSpace(shared.StringArg(params, "requestId", ""))
|
||||
}
|
||||
if runID == "" {
|
||||
runID = "default"
|
||||
}
|
||||
relativeArtifactDirectory := filepath.Join("tasks", sessionKey, runID)
|
||||
workspaceDir := openClawArtifactWorkspaceDir(params)
|
||||
artifactDirectory := filepath.Join(workspaceDir, relativeArtifactDirectory)
|
||||
return map[string]any{
|
||||
"ok": true,
|
||||
"fallback": true,
|
||||
"compatibilityMode": "local-session-prepare",
|
||||
"runId": runID,
|
||||
"sessionKey": sessionKey,
|
||||
"openclawSessionKey": sessionKey,
|
||||
"remoteWorkingDirectory": workspaceDir,
|
||||
"remoteWorkspaceRefKind": "path",
|
||||
"artifactScope": relativeArtifactDirectory,
|
||||
"artifactDirectory": artifactDirectory,
|
||||
"relativeArtifactDirectory": relativeArtifactDirectory,
|
||||
"scopeKind": "task",
|
||||
}
|
||||
|
||||
func openClawLegacyArtifactWorkspaceRoot(params map[string]any) string {
|
||||
for _, key := range []string{"remoteWorkingDirectoryHint", "remoteWorkingDirectory"} {
|
||||
value := strings.TrimSpace(shared.StringArg(params, key, ""))
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
cleaned := filepath.Clean(value)
|
||||
if strings.HasPrefix(cleaned, "/home/ubuntu/.openclaw/workspace") {
|
||||
return strings.TrimRight(cleaned, string(os.PathSeparator))
|
||||
}
|
||||
}
|
||||
return "/home/ubuntu/.openclaw/workspace"
|
||||
}
|
||||
|
||||
func openClawSessionPrepareParams(params map[string]any, openClawSessionKey string, runID string, artifactContract openClawArtifactContract) map[string]any {
|
||||
@ -682,45 +668,15 @@ func openClawSessionPrepareParams(params map[string]any, openClawSessionKey stri
|
||||
if len(artifactContract.ExpectedArtifactDirs) > 0 {
|
||||
result["expectedArtifactDirs"] = append([]string(nil), artifactContract.ExpectedArtifactDirs...)
|
||||
}
|
||||
if artifactContract.RequiresArtifactExport {
|
||||
result["requiresArtifactExport"] = true
|
||||
if sessionID := strings.TrimSpace(shared.StringArg(params, "sessionId", "")); sessionID != "" {
|
||||
result["sessionId"] = sessionID
|
||||
}
|
||||
if len(artifactContract.RequiredArtifactExts) > 0 {
|
||||
result["requiredArtifactExtensions"] = append([]string(nil), artifactContract.RequiredArtifactExts...)
|
||||
}
|
||||
if workspaceDir := openClawArtifactWorkspaceDir(params); workspaceDir != "" {
|
||||
result["workspaceDir"] = workspaceDir
|
||||
if threadID := strings.TrimSpace(shared.StringArg(params, "threadId", "")); threadID != "" {
|
||||
result["threadId"] = threadID
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func openClawArtifactWorkspaceDir(params map[string]any) string {
|
||||
if value := strings.TrimSpace(shared.StringArg(params, "workspaceDir", "")); value != "" {
|
||||
return value
|
||||
}
|
||||
for _, key := range []string{"remoteWorkingDirectoryHint", "remoteWorkingDirectory", "workingDirectory"} {
|
||||
if value := strings.TrimSpace(shared.StringArg(params, key, "")); isOpenClawWorkspacePath(value) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
if configured := strings.TrimSpace(os.Getenv("OPENCLAW_WORKSPACE_DIR")); configured != "" {
|
||||
return configured
|
||||
}
|
||||
return "~/.openclaw/workspace"
|
||||
}
|
||||
|
||||
func isOpenClawWorkspacePath(path string) bool {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
normalized := filepath.ToSlash(filepath.Clean(path))
|
||||
return strings.HasPrefix(normalized, "/home/ubuntu/.openclaw/workspace") ||
|
||||
strings.HasPrefix(normalized, "/Users/") && strings.Contains(normalized, "/.openclaw/workspace") ||
|
||||
strings.HasPrefix(normalized, "~/.openclaw/workspace") ||
|
||||
strings.HasPrefix(normalized, "$HOME/.openclaw/workspace")
|
||||
}
|
||||
|
||||
func openClawAppThreadKey(params map[string]any) string {
|
||||
if value := strings.TrimSpace(shared.StringArg(params, "appThreadKey", "")); value != "" {
|
||||
return value
|
||||
@ -841,13 +797,10 @@ func shellSingleQuote(value string) string {
|
||||
}
|
||||
|
||||
type openClawArtifactContract struct {
|
||||
TaskLoadClass string
|
||||
ComplexLongChain bool
|
||||
RequiresArtifactExport bool
|
||||
ExpectedArtifactDirs []string
|
||||
RequiredArtifactExts []string
|
||||
ExpectedFileCounts map[string]int
|
||||
SourceMessage string
|
||||
TaskLoadClass string
|
||||
ComplexLongChain bool
|
||||
ExpectedArtifactDirs []string
|
||||
SourceMessage string
|
||||
}
|
||||
|
||||
func openClawArtifactContractForParams(params map[string]any, chatParams map[string]any) openClawArtifactContract {
|
||||
@ -860,75 +813,15 @@ func openClawArtifactContractForParams(params map[string]any, chatParams map[str
|
||||
lowerMessage := strings.ToLower(message)
|
||||
contract := shared.AsMap(metadata["xworkmateTaskArtifactContract"])
|
||||
expectedDirs := normalizeOpenClawDirList(shared.ListArg(contract, "expectedArtifactDirs"))
|
||||
requiresExport := parseBool(contract["requiresExportBeforeFinalResponse"]) || len(expectedDirs) > 0
|
||||
complex := taskLoadClass == "complex_long_chain_task" || isOpenClawLongArtifactTask(lowerMessage)
|
||||
requiredExts := normalizeOpenClawArtifactExtList(shared.ListArg(metadata, "requiredArtifactExtensions"))
|
||||
if len(requiredExts) == 0 {
|
||||
requiredExts = normalizeOpenClawArtifactExtList(shared.ListArg(metadata, "expectedArtifactExtensions"))
|
||||
}
|
||||
if len(requiredExts) == 0 {
|
||||
requiredExts = inferOpenClawRequiredArtifactExts(lowerMessage)
|
||||
}
|
||||
expectedFileCounts := normalizeOpenClawArtifactExtCountMap(shared.AsMap(contract["expectedFileCountByExtension"]))
|
||||
if len(expectedFileCounts) == 0 {
|
||||
expectedFileCounts = normalizeOpenClawArtifactExtCountMap(shared.AsMap(metadata["expectedFileCountByExtension"]))
|
||||
}
|
||||
if len(expectedFileCounts) == 0 {
|
||||
expectedFileCounts = normalizeOpenClawArtifactExtCountMap(shared.AsMap(shared.AsMap(metadata["xworkmateArtifactConstraints"])["expectedFileCountByExtension"]))
|
||||
}
|
||||
return openClawArtifactContract{
|
||||
TaskLoadClass: taskLoadClass,
|
||||
ComplexLongChain: complex,
|
||||
RequiresArtifactExport: requiresExport,
|
||||
ExpectedArtifactDirs: expectedDirs,
|
||||
RequiredArtifactExts: requiredExts,
|
||||
ExpectedFileCounts: expectedFileCounts,
|
||||
SourceMessage: message,
|
||||
TaskLoadClass: taskLoadClass,
|
||||
ComplexLongChain: complex,
|
||||
ExpectedArtifactDirs: expectedDirs,
|
||||
SourceMessage: message,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeOpenClawArtifactExtCountMap(values map[string]any) map[string]int {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := map[string]int{}
|
||||
for key, raw := range values {
|
||||
ext := strings.ToLower(strings.TrimSpace(key))
|
||||
ext = strings.TrimPrefix(ext, ".")
|
||||
if ext == "" || strings.Contains(ext, "/") || strings.Contains(ext, "\\") {
|
||||
continue
|
||||
}
|
||||
count := openClawPositiveInt(raw)
|
||||
if count <= 0 {
|
||||
continue
|
||||
}
|
||||
result[ext] = count
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func openClawPositiveInt(value any) int {
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
return v
|
||||
case int64:
|
||||
return int(v)
|
||||
case float64:
|
||||
return int(v)
|
||||
case float32:
|
||||
return int(v)
|
||||
case string:
|
||||
var parsed int
|
||||
if _, err := fmt.Sscanf(strings.TrimSpace(v), "%d", &parsed); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func normalizeOpenClawDirList(values []any) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
@ -946,44 +839,11 @@ func normalizeOpenClawDirList(values []any) []string {
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeOpenClawArtifactExtList(values []any) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]string, 0, len(values))
|
||||
seen := map[string]bool{}
|
||||
for _, value := range values {
|
||||
ext := strings.ToLower(strings.TrimSpace(fmt.Sprint(value)))
|
||||
ext = strings.TrimPrefix(ext, ".")
|
||||
if ext == "" || strings.Contains(ext, "/") || strings.Contains(ext, "\\") || seen[ext] {
|
||||
continue
|
||||
}
|
||||
seen[ext] = true
|
||||
result = append(result, ext)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func inferOpenClawRequiredArtifactExts(lowerMessage string) []string {
|
||||
switch {
|
||||
case openClawMessageContainsAny(lowerMessage, []string{"pdf", "输出 pdf", "生成 pdf"}):
|
||||
return []string{"pdf"}
|
||||
case openClawMessageContainsAny(lowerMessage, []string{"视频", "video", "mp4", "渲染"}):
|
||||
return []string{"mp4"}
|
||||
case openClawMessageContainsAny(lowerMessage, []string{"图片", "图像", "png", "jpg", "jpeg", "webp", "生成图"}):
|
||||
return []string{"png", "jpg", "jpeg", "webp"}
|
||||
case openClawMessageContainsAny(lowerMessage, []string{"markdown", "md文件", ".md", "文案", "资讯"}):
|
||||
return []string{"md"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func openClawChatSendParams(
|
||||
params map[string]any,
|
||||
turnID string,
|
||||
) (map[string]any, *shared.RPCError) {
|
||||
return openClawChatSendParamsWithSessionKey(params, turnID, openClawAgentMainSessionKey(openClawAppThreadKey(params)))
|
||||
return openClawChatSendParamsWithSessionKey(params, turnID, fallbackOpenClawSessionKey(params, turnID))
|
||||
}
|
||||
|
||||
func openClawChatSendParamsWithSessionKey(
|
||||
@ -1007,6 +867,7 @@ func openClawChatSendParamsWithSessionKey(
|
||||
}
|
||||
attachments = append(attachments, inlineAttachments...)
|
||||
if len(attachments) > 0 {
|
||||
chatParams["attachments"] = attachments
|
||||
chatParams["message"] = shared.AugmentPromptWithAttachments(
|
||||
message,
|
||||
map[string]any{"attachments": attachments},
|
||||
@ -1047,66 +908,6 @@ func withOpenClawWritableWorkspace(params map[string]any, appThreadKey string) m
|
||||
return next
|
||||
}
|
||||
|
||||
func withOpenClawPreparedArtifactWorkspace(params map[string]any, prepared *openClawPreparedArtifactScope) map[string]any {
|
||||
if prepared == nil {
|
||||
return params
|
||||
}
|
||||
artifactDirectory := strings.TrimSpace(prepared.ArtifactDirectory)
|
||||
if artifactDirectory == "" {
|
||||
return params
|
||||
}
|
||||
replacements := openClawWorkspacePromptReplacementValues(params)
|
||||
next := make(map[string]any, len(params)+2)
|
||||
for key, value := range params {
|
||||
next[key] = value
|
||||
}
|
||||
next["workingDirectory"] = artifactDirectory
|
||||
next["remoteWorkingDirectoryHint"] = artifactDirectory
|
||||
for _, key := range []string{"taskPrompt", "prompt", "message"} {
|
||||
value, ok := next[key].(string)
|
||||
if !ok || strings.TrimSpace(value) == "" {
|
||||
continue
|
||||
}
|
||||
next[key] = rewriteOpenClawWorkspaceReferences(value, artifactDirectory, replacements)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func openClawWorkspacePromptReplacementValues(params map[string]any) []string {
|
||||
values := []string{
|
||||
shared.StringArg(params, "workingDirectory", ""),
|
||||
shared.StringArg(params, "remoteWorkingDirectoryHint", ""),
|
||||
shared.StringArg(params, "remoteWorkingDirectory", ""),
|
||||
}
|
||||
metadata := shared.AsMap(params["metadata"])
|
||||
contract := shared.AsMap(metadata["xworkmateTaskArtifactContract"])
|
||||
values = append(values,
|
||||
shared.StringArg(contract, "currentTaskWorkspace", ""),
|
||||
shared.StringArg(contract, "remoteWorkspaceHint", ""),
|
||||
)
|
||||
result := make([]string, 0, len(values))
|
||||
seen := map[string]bool{}
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" || seen[trimmed] {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = true
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func rewriteOpenClawWorkspaceReferences(message string, artifactDirectory string, replacements []string) string {
|
||||
result := message
|
||||
for _, value := range replacements {
|
||||
if value != artifactDirectory {
|
||||
result = strings.ReplaceAll(result, value, artifactDirectory)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func firstOwnerScopedWorkspace(values ...string) string {
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
@ -1120,7 +921,7 @@ func firstOwnerScopedWorkspace(values ...string) string {
|
||||
func openClawWritableWorkspaceForOwnerPath(ownerPath string, sessionKey string) string {
|
||||
root := strings.TrimSpace(os.Getenv("OPENCLAW_WRITABLE_WORKSPACE_ROOT"))
|
||||
if root == "" {
|
||||
return ""
|
||||
root = "/home/ubuntu/.openclaw/workspace/task_artifacts"
|
||||
}
|
||||
root = strings.TrimRight(filepath.Clean(root), string(os.PathSeparator))
|
||||
if root == "" || root == "." || root == string(os.PathSeparator) {
|
||||
@ -1138,28 +939,17 @@ func openClawNonEmptyPathAttachments(params map[string]any) []any {
|
||||
if len(rawAttachments) == 0 {
|
||||
return nil
|
||||
}
|
||||
inlineAttachmentNames := map[string]bool{}
|
||||
for _, raw := range shared.ListArg(params, "inlineAttachments") {
|
||||
name := strings.TrimSpace(shared.StringArg(shared.AsMap(raw), "name", ""))
|
||||
if name != "" {
|
||||
inlineAttachmentNames[name] = true
|
||||
}
|
||||
}
|
||||
attachments := make([]any, 0, len(rawAttachments))
|
||||
for _, raw := range rawAttachments {
|
||||
attachment := shared.AsMap(raw)
|
||||
if len(attachment) == 0 {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(shared.StringArg(attachment, "name", "attachment"))
|
||||
if inlineAttachmentNames[name] {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(shared.StringArg(attachment, "path", "")) == "" {
|
||||
continue
|
||||
}
|
||||
attachments = append(attachments, map[string]any{
|
||||
"name": name,
|
||||
"name": strings.TrimSpace(shared.StringArg(attachment, "name", "attachment")),
|
||||
"description": strings.TrimSpace(shared.StringArg(attachment, "description", "")),
|
||||
"path": strings.TrimSpace(shared.StringArg(attachment, "path", "")),
|
||||
})
|
||||
@ -1478,15 +1268,18 @@ func (o *SessionOrchestrator) openClawSessionKey(params map[string]any, turnID s
|
||||
if explicit := strings.TrimSpace(shared.StringArg(params, "openclawSessionKey", "")); explicit != "" {
|
||||
return explicit
|
||||
}
|
||||
return openClawAgentMainSessionKey(openClawAppThreadKey(params))
|
||||
if appThreadKey := openClawAppThreadKey(params); appThreadKey != "" {
|
||||
return openClawAgentMainSessionKey(appThreadKey)
|
||||
}
|
||||
return fallbackOpenClawSessionKey(params, turnID)
|
||||
}
|
||||
|
||||
func openClawAgentMainSessionKey(appThreadKey string) string {
|
||||
appThreadKey = strings.TrimSpace(appThreadKey)
|
||||
if appThreadKey == "" {
|
||||
appThreadKey = "main"
|
||||
return "main"
|
||||
}
|
||||
return "agent:main:" + appThreadKey
|
||||
return appThreadKey
|
||||
}
|
||||
|
||||
func validateOpenClawAcceptedSessionKey(payload map[string]any, expectedSessionKey string) *shared.RPCError {
|
||||
@ -1512,6 +1305,18 @@ func validateOpenClawAcceptedSessionKey(payload map[string]any, expectedSessionK
|
||||
}
|
||||
}
|
||||
|
||||
func fallbackOpenClawSessionKey(params map[string]any, turnID string) string {
|
||||
for _, key := range []string{"threadId", "sessionId"} {
|
||||
if value := strings.TrimSpace(shared.StringArg(params, key, "")); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
if trimmed := strings.TrimSpace(turnID); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
return "main"
|
||||
}
|
||||
|
||||
func (o *SessionOrchestrator) openClawArtifactExport(
|
||||
gatewayProvider string,
|
||||
chatParams map[string]any,
|
||||
@ -1539,20 +1344,54 @@ func (o *SessionOrchestrator) openClawArtifactExport(
|
||||
if len(artifactContract.ExpectedArtifactDirs) > 0 {
|
||||
exportParams["expectedArtifactDirs"] = append([]string(nil), artifactContract.ExpectedArtifactDirs...)
|
||||
}
|
||||
if len(artifactContract.RequiredArtifactExts) > 0 {
|
||||
exportParams["requiredArtifactExtensions"] = append([]string(nil), artifactContract.RequiredArtifactExts...)
|
||||
}
|
||||
if len(artifactContract.ExpectedFileCounts) > 0 {
|
||||
counts := map[string]int{}
|
||||
for ext, count := range artifactContract.ExpectedFileCounts {
|
||||
counts[ext] = count
|
||||
}
|
||||
exportParams["expectedFileCountByExtension"] = counts
|
||||
}
|
||||
payload := o.openClawArtifactExportRequest(gatewayProvider, exportParams, notify)
|
||||
return payload
|
||||
}
|
||||
|
||||
func (o *SessionOrchestrator) openClawArtifactCollectAndSnapshot(
|
||||
gatewayProvider string,
|
||||
chatParams map[string]any,
|
||||
artifactContract openClawArtifactContract,
|
||||
runID string,
|
||||
sinceUnixMs int64,
|
||||
preparedArtifact *openClawPreparedArtifactScope,
|
||||
notify func(map[string]any),
|
||||
) map[string]any {
|
||||
sessionKey := strings.TrimSpace(shared.StringArg(chatParams, "sessionKey", ""))
|
||||
if sessionKey == "" || strings.TrimSpace(runID) == "" || preparedArtifact == nil {
|
||||
return nil
|
||||
}
|
||||
snapshotParams := map[string]any{
|
||||
"openclawSessionKey": sessionKey,
|
||||
"runId": strings.TrimSpace(runID),
|
||||
"sinceUnixMs": sinceUnixMs,
|
||||
"maxFiles": 64,
|
||||
}
|
||||
if strings.TrimSpace(preparedArtifact.ArtifactScope) != "" {
|
||||
snapshotParams["artifactScope"] = strings.TrimSpace(preparedArtifact.ArtifactScope)
|
||||
}
|
||||
if len(artifactContract.ExpectedArtifactDirs) > 0 {
|
||||
snapshotParams["expectedArtifactDirs"] = append([]string(nil), artifactContract.ExpectedArtifactDirs...)
|
||||
}
|
||||
snapshotResult := o.openClawGatewayRequestWithRetry(
|
||||
gatewayProvider,
|
||||
"xworkmate.artifacts.collect-and-snapshot",
|
||||
snapshotParams,
|
||||
30*time.Second,
|
||||
notify,
|
||||
)
|
||||
if snapshotResult.OK {
|
||||
return shared.AsMap(snapshotResult.Payload)
|
||||
}
|
||||
message := strings.TrimSpace(shared.StringArg(snapshotResult.Error, "message", ""))
|
||||
if message == "" {
|
||||
message = "openclaw artifact snapshot unavailable"
|
||||
}
|
||||
return map[string]any{
|
||||
"artifactWarnings": []any{message},
|
||||
}
|
||||
}
|
||||
|
||||
func (o *SessionOrchestrator) openClawArtifactExportRequest(
|
||||
gatewayProvider string,
|
||||
exportParams map[string]any,
|
||||
@ -1577,6 +1416,61 @@ func (o *SessionOrchestrator) openClawArtifactExportRequest(
|
||||
}
|
||||
}
|
||||
|
||||
func openClawSessionKeyFromArtifactScope(scope string) string {
|
||||
parts := strings.Split(strings.TrimSpace(scope), "/")
|
||||
if len(parts) != 3 || parts[0] != "tasks" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(parts[1])
|
||||
}
|
||||
|
||||
func guardOpenClawNoDisplayableResult(result map[string]any, noDisplayableOutput bool) {
|
||||
if !noDisplayableOutput || result == nil || !parseBool(result["success"]) {
|
||||
return
|
||||
}
|
||||
remoteWorkingDirectory := strings.TrimSpace(shared.StringArg(result, "remoteWorkingDirectory", ""))
|
||||
if len(extractArtifactPayloads(result, remoteWorkingDirectory)) > 0 {
|
||||
return
|
||||
}
|
||||
result["success"] = false
|
||||
result["status"] = "failed"
|
||||
result["code"] = "OPENCLAW_NO_DISPLAYABLE_OUTPUT"
|
||||
result["error"] = "openclaw returned no displayable output"
|
||||
result["message"] = openClawNoDisplayableText
|
||||
result["output"] = openClawNoDisplayableText
|
||||
result["summary"] = openClawNoDisplayableText
|
||||
}
|
||||
|
||||
func guardOpenClawAgentFailedBeforeReplyResult(result map[string]any) {
|
||||
if result == nil || !parseBool(result["success"]) {
|
||||
return
|
||||
}
|
||||
remoteWorkingDirectory := strings.TrimSpace(shared.StringArg(result, "remoteWorkingDirectory", ""))
|
||||
if len(extractArtifactPayloads(result, remoteWorkingDirectory)) > 0 {
|
||||
return
|
||||
}
|
||||
output := firstNonEmptyString(result, "output", "message", "summary")
|
||||
if !strings.Contains(strings.ToLower(output), "agent failed before reply") {
|
||||
return
|
||||
}
|
||||
result["success"] = false
|
||||
result["status"] = "failed"
|
||||
result["code"] = "OPENCLAW_AGENT_FAILED_BEFORE_REPLY"
|
||||
result["error"] = output
|
||||
result["message"] = output
|
||||
result["output"] = output
|
||||
result["summary"] = output
|
||||
}
|
||||
|
||||
func applyOpenClawArtifactContractResult(result map[string]any, contract openClawArtifactContract) {
|
||||
if result == nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(contract.TaskLoadClass) != "" {
|
||||
result["taskLoadClass"] = contract.TaskLoadClass
|
||||
}
|
||||
}
|
||||
|
||||
func mergeOpenClawArtifactPayload(result map[string]any, source map[string]any) {
|
||||
if result == nil || len(source) == 0 {
|
||||
return
|
||||
@ -1611,41 +1505,6 @@ func mergeOpenClawArtifactPayload(result map[string]any, source map[string]any)
|
||||
result[key] = merged
|
||||
}
|
||||
}
|
||||
if value, ok := source["constraintSatisfied"]; ok {
|
||||
result["constraintSatisfied"] = parseBool(value)
|
||||
}
|
||||
if _, ok := source["missingRequiredExtensions"]; ok {
|
||||
result["missingRequiredExtensions"] = appendStringList(result["missingRequiredExtensions"], source["missingRequiredExtensions"])
|
||||
}
|
||||
if value, ok := source["missingRequiredFileCounts"]; ok {
|
||||
result["missingRequiredFileCounts"] = value
|
||||
}
|
||||
}
|
||||
|
||||
func appendStringList(existing any, incoming any) []any {
|
||||
seen := map[string]bool{}
|
||||
merged := make([]any, 0)
|
||||
add := func(value any) {
|
||||
item := strings.TrimSpace(fmt.Sprint(value))
|
||||
if item == "" || seen[item] {
|
||||
return
|
||||
}
|
||||
seen[item] = true
|
||||
merged = append(merged, item)
|
||||
}
|
||||
for _, values := range []any{existing, incoming} {
|
||||
switch typed := values.(type) {
|
||||
case []any:
|
||||
for _, item := range typed {
|
||||
add(item)
|
||||
}
|
||||
case []string:
|
||||
for _, item := range typed {
|
||||
add(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func appendArtifactList(existing any, incoming any) []any {
|
||||
@ -1669,34 +1528,13 @@ func appendArtifactList(existing any, incoming any) []any {
|
||||
return merged
|
||||
}
|
||||
|
||||
func applyOpenClawConstraintDeliveryStatus(result map[string]any) {
|
||||
if result == nil || !parseBool(result["success"]) {
|
||||
return
|
||||
}
|
||||
if value, ok := result["constraintSatisfied"]; !ok || parseBool(value) {
|
||||
return
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(shared.StringArg(result, "status", ""))) {
|
||||
case string(TaskStateRunning), string(TaskStateFailed), string(TaskStateCancelled):
|
||||
return
|
||||
default:
|
||||
result["status"] = "partially_delivered"
|
||||
result["artifactSyncStatus"] = "partial"
|
||||
}
|
||||
}
|
||||
|
||||
func gatewayRPCError(errorPayload map[string]any, fallback string) *shared.RPCError {
|
||||
if isOpenClawRetryableGatewayError(errorPayload) {
|
||||
metricGatewaySocketClosedInc() // T12
|
||||
// T10:连接断属「可重试 / run 可能仍在后台、可续轮询」语义,而非 run 确实失败。
|
||||
// 带 retryable/poll 提示,客户端据此降级为「后台续跑·重连中」(T5) 续轮询 tasks.get,而非硬失败。
|
||||
return &shared.RPCError{
|
||||
Code: -32002,
|
||||
Message: "OPENCLAW_GATEWAY_SOCKET_CLOSED: OpenClaw gateway connection closed during task execution",
|
||||
Data: map[string]any{
|
||||
"code": "OPENCLAW_GATEWAY_SOCKET_CLOSED",
|
||||
"retryable": true,
|
||||
"poll": true,
|
||||
"originalCode": strings.TrimSpace(shared.StringArg(errorPayload, "code", "")),
|
||||
"originalError": strings.TrimSpace(shared.StringArg(errorPayload, "message", "")),
|
||||
},
|
||||
@ -1779,6 +1617,71 @@ func firstNonEmptyString(values map[string]any, keys ...string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
type openClawChatCollector struct {
|
||||
parts []string
|
||||
final string
|
||||
terminal bool
|
||||
artifactPayloads []map[string]any
|
||||
}
|
||||
|
||||
func newOpenClawChatCollector() *openClawChatCollector {
|
||||
return &openClawChatCollector{}
|
||||
}
|
||||
|
||||
func (c *openClawChatCollector) observe(notification map[string]any) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
event := shared.AsMap(shared.AsMap(notification["params"])["event"])
|
||||
if len(event) == 0 {
|
||||
return
|
||||
}
|
||||
payload := shared.AsMap(event["payload"])
|
||||
if hasArtifactPayload(payload) {
|
||||
c.artifactPayloads = append(c.artifactPayloads, payload)
|
||||
}
|
||||
if strings.TrimSpace(shared.StringArg(event, "event", "")) != "chat.run" {
|
||||
return
|
||||
}
|
||||
if isTerminalGatewayPayload(payload) {
|
||||
c.terminal = true
|
||||
}
|
||||
text := firstNonEmptyString(payload, "assistantText", "text", "message", "output", "summary")
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
if isTerminalGatewayPayload(payload) {
|
||||
c.final = text
|
||||
return
|
||||
}
|
||||
c.parts = append(c.parts, text)
|
||||
}
|
||||
|
||||
func (c *openClawChatCollector) output() string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
if strings.TrimSpace(c.final) != "" {
|
||||
return strings.TrimSpace(c.final)
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(c.parts, ""))
|
||||
}
|
||||
|
||||
func (c *openClawChatCollector) isTerminal() bool {
|
||||
return c != nil && c.terminal
|
||||
}
|
||||
|
||||
func (c *openClawChatCollector) artifactPayload() map[string]any {
|
||||
if c == nil || len(c.artifactPayloads) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := map[string]any{}
|
||||
for _, payload := range c.artifactPayloads {
|
||||
mergeOpenClawArtifactPayload(result, payload)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func openClawGatewaySessionUpdate(notification map[string]any, sessionID string, threadID string, turnID string) map[string]any {
|
||||
params := shared.AsMap(notification["params"])
|
||||
event := shared.AsMap(params["event"])
|
||||
@ -1813,6 +1716,18 @@ func openClawGatewaySessionUpdate(notification map[string]any, sessionID string,
|
||||
return shared.NotificationEnvelope("session.update", update)
|
||||
}
|
||||
|
||||
func hasArtifactPayload(payload map[string]any) bool {
|
||||
if len(payload) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, key := range []string{"artifacts", "files", "attachments", "remoteWorkingDirectory", "remoteWorkspaceRefKind"} {
|
||||
if _, ok := payload[key]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isTerminalGatewayPayload(payload map[string]any) bool {
|
||||
if payload == nil {
|
||||
return false
|
||||
@ -1901,12 +1816,7 @@ func (o *SessionOrchestrator) normalizeResult(sess *session, result map[string]a
|
||||
delete(result, openClawArtifactExportAttemptedField)
|
||||
|
||||
successValue, hasSuccess := result["success"]
|
||||
successSource := "explicit"
|
||||
success := parseBool(successValue)
|
||||
if !hasSuccess {
|
||||
successSource = "absent"
|
||||
success = true
|
||||
}
|
||||
success := !hasSuccess || parseBool(successValue)
|
||||
|
||||
output := strings.TrimSpace(shared.StringArg(result, "output", ""))
|
||||
if output == "" {
|
||||
@ -1915,19 +1825,6 @@ func (o *SessionOrchestrator) normalizeResult(sess *session, result map[string]a
|
||||
if output == "" && success {
|
||||
output = strings.TrimSpace(shared.StringArg(result, "message", ""))
|
||||
}
|
||||
if routing.TargetID == "gateway" && successSource == "absent" {
|
||||
remoteWorkingDirectory := strings.TrimSpace(shared.StringArg(result, "remoteWorkingDirectory", ""))
|
||||
if output == "" && len(extractArtifactPayloads(result, remoteWorkingDirectory)) == 0 {
|
||||
result["success"] = false
|
||||
result["status"] = string(TaskStateFailed)
|
||||
result["code"] = "OPENCLAW_TERMINAL_WITHOUT_EVIDENCE"
|
||||
result["error"] = "OPENCLAW_TERMINAL_WITHOUT_EVIDENCE"
|
||||
result["message"] = "OPENCLAW_TERMINAL_WITHOUT_EVIDENCE"
|
||||
} else {
|
||||
result["success"] = true
|
||||
result["successSource"] = "inferred"
|
||||
}
|
||||
}
|
||||
|
||||
sess.mu.Lock()
|
||||
if output != "" {
|
||||
@ -1942,12 +1839,7 @@ func (o *SessionOrchestrator) normalizeResult(sess *session, result map[string]a
|
||||
result["status"] = "completed"
|
||||
}
|
||||
if !hasSuccess {
|
||||
if _, ok := result["success"]; !ok {
|
||||
result["success"] = true
|
||||
}
|
||||
}
|
||||
if !parseBool(result["success"]) && strings.TrimSpace(shared.StringArg(result, "status", "")) == string(TaskStateCompleted) {
|
||||
result["status"] = string(TaskStateFailed)
|
||||
result["success"] = true
|
||||
}
|
||||
result["resolvedExecutionTarget"] = routing.TargetID
|
||||
result["resolvedProviderId"] = routing.ProviderID
|
||||
@ -1974,7 +1866,6 @@ func (o *SessionOrchestrator) normalizeResult(sess *session, result map[string]a
|
||||
sess.task.UpdatedAt = time.Now()
|
||||
sess.mu.Unlock()
|
||||
}
|
||||
applyOpenClawConstraintDeliveryStatus(result)
|
||||
|
||||
artifactRecord := buildArtifactRecord(sess, result, output)
|
||||
if artifactRecord.RemoteWorkingDirectory != "" {
|
||||
|
||||
@ -1,374 +0,0 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"xworkmate-bridge/internal/shared"
|
||||
)
|
||||
|
||||
func TestNormalizeResultGatewaySuccessEvidenceAdjudication(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
routingTarget string
|
||||
result map[string]any
|
||||
wantSuccess bool
|
||||
wantStatus string
|
||||
wantCode string
|
||||
wantSuccessSource string
|
||||
}{
|
||||
{
|
||||
name: "gateway absent success without output or artifacts fails",
|
||||
routingTarget: "gateway",
|
||||
result: map[string]any{},
|
||||
wantSuccess: false,
|
||||
wantStatus: string(TaskStateFailed),
|
||||
wantCode: "OPENCLAW_TERMINAL_WITHOUT_EVIDENCE",
|
||||
},
|
||||
{
|
||||
name: "gateway absent success with output is inferred",
|
||||
routingTarget: "gateway",
|
||||
result: map[string]any{"output": "done"},
|
||||
wantSuccess: true,
|
||||
wantStatus: string(TaskStateCompleted),
|
||||
wantSuccessSource: "inferred",
|
||||
},
|
||||
{
|
||||
name: "gateway absent success with artifacts is inferred",
|
||||
routingTarget: "gateway",
|
||||
result: map[string]any{
|
||||
"artifacts": []any{map[string]any{"relativePath": "reports/final.md"}},
|
||||
},
|
||||
wantSuccess: true,
|
||||
wantStatus: string(TaskStateCompleted),
|
||||
wantSuccessSource: "inferred",
|
||||
},
|
||||
{
|
||||
name: "gateway explicit false remains failed",
|
||||
routingTarget: "gateway",
|
||||
result: map[string]any{"success": false, "output": "failed"},
|
||||
wantSuccess: false,
|
||||
wantStatus: string(TaskStateFailed),
|
||||
},
|
||||
{
|
||||
name: "gateway explicit true remains completed",
|
||||
routingTarget: "gateway",
|
||||
result: map[string]any{"success": true},
|
||||
wantSuccess: true,
|
||||
wantStatus: string(TaskStateCompleted),
|
||||
},
|
||||
{
|
||||
name: "non gateway absent success keeps legacy inference",
|
||||
routingTarget: "single-agent",
|
||||
result: map[string]any{"output": "done"},
|
||||
wantSuccess: true,
|
||||
wantStatus: string(TaskStateCompleted),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
server := NewServer()
|
||||
orchestrator := NewSessionOrchestrator(server)
|
||||
sess := server.getOrCreateSession("session-"+strings.ReplaceAll(tc.name, " ", "-"), "thread")
|
||||
|
||||
got := orchestrator.normalizeResult(
|
||||
sess,
|
||||
tc.result,
|
||||
RoutingResult{TargetID: tc.routingTarget, ProviderID: "provider", GatewayProviderID: "openclaw"},
|
||||
"turn-1",
|
||||
map[string]any{},
|
||||
)
|
||||
|
||||
if parseBool(got["success"]) != tc.wantSuccess {
|
||||
t.Fatalf("success = %#v, want %v in %#v", got["success"], tc.wantSuccess, got)
|
||||
}
|
||||
if status := shared.StringArg(got, "status", ""); status != tc.wantStatus {
|
||||
t.Fatalf("status = %q, want %q in %#v", status, tc.wantStatus, got)
|
||||
}
|
||||
if code := shared.StringArg(got, "code", ""); code != tc.wantCode {
|
||||
t.Fatalf("code = %q, want %q in %#v", code, tc.wantCode, got)
|
||||
}
|
||||
if source := shared.StringArg(got, "successSource", ""); source != tc.wantSuccessSource {
|
||||
t.Fatalf("successSource = %q, want %q in %#v", source, tc.wantSuccessSource, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOpenClawTaskGetUnknownArtifactEvidenceKeepsActiveRecordRunning(t *testing.T) {
|
||||
payload := map[string]any{
|
||||
"success": false,
|
||||
"status": "unknown",
|
||||
"taskStatus": "unknown",
|
||||
"evidence": "artifacts_present",
|
||||
"artifactCount": 1,
|
||||
"artifactScope": "tasks/session/run",
|
||||
"artifactDirectory": "/remote/openclaw/workspace/tasks/session/run",
|
||||
"artifacts": []any{map[string]any{"relativePath": "series.config.json"}},
|
||||
}
|
||||
record := &OpenClawTaskRecord{
|
||||
RunID: "run",
|
||||
SessionKey: "session",
|
||||
GatewayProviderID: "openclaw",
|
||||
RequiresArtifactExport: true,
|
||||
DeadlineAt: time.Now().Add(time.Minute),
|
||||
}
|
||||
|
||||
got := normalizeOpenClawTaskGetResult(
|
||||
map[string]any{"requiredArtifactExtensions": []any{"pdf"}},
|
||||
payload,
|
||||
"openclaw",
|
||||
record,
|
||||
)
|
||||
|
||||
if status := shared.StringArg(got, "status", ""); status != string(TaskStateRunning) {
|
||||
t.Fatalf("expected active unknown artifact evidence to remain running, got %#v", got)
|
||||
}
|
||||
if evidence := shared.StringArg(got, "artifactEvidence", ""); evidence != "artifacts_present" {
|
||||
t.Fatalf("expected artifact evidence audit field, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpectedArtifactDirectoriesDoNotBlockTerminalTaskState(t *testing.T) {
|
||||
params := map[string]any{"expectedArtifactDirs": []any{"reports/", "artifacts/"}}
|
||||
payload := map[string]any{
|
||||
"success": true,
|
||||
"status": string(TaskStateCompleted),
|
||||
"artifactScope": "tasks/session/run",
|
||||
"artifactDirectory": "/remote/openclaw/workspace/tasks/session/run",
|
||||
"expectedArtifactDirs": []any{
|
||||
"reports/",
|
||||
"artifacts/",
|
||||
},
|
||||
}
|
||||
|
||||
if openClawTaskGetRequiresArtifactExport(params, payload) {
|
||||
t.Fatal("expectedArtifactDirs must remain non-blocking scan hints")
|
||||
}
|
||||
got := normalizeOpenClawTaskGetResult(params, payload, "openclaw", nil)
|
||||
if status := shared.StringArg(got, "status", ""); status != string(TaskStateCompleted) {
|
||||
t.Fatalf("expected terminal status to remain completed, got %#v", got)
|
||||
}
|
||||
if parseBool(got["pending"]) {
|
||||
t.Fatalf("expected terminal payload not to become pending, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequiredArtifactExtensionsStillBlockUntilVerified(t *testing.T) {
|
||||
params := map[string]any{"requiredArtifactExtensions": []any{"md"}}
|
||||
payload := map[string]any{
|
||||
"success": true,
|
||||
"status": string(TaskStateCompleted),
|
||||
"artifactScope": "tasks/session/run",
|
||||
"artifactDirectory": "/remote/openclaw/workspace/tasks/session/run",
|
||||
}
|
||||
|
||||
if !openClawTaskGetRequiresArtifactExport(params, payload) {
|
||||
t.Fatal("requiredArtifactExtensions must remain a blocking delivery contract")
|
||||
}
|
||||
got := normalizeOpenClawTaskGetResult(params, payload, "openclaw", nil)
|
||||
if status := shared.StringArg(got, "status", ""); status != string(TaskStateRunning) {
|
||||
t.Fatalf("expected missing required artifact to remain syncing, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOpenClawTaskGetUnknownArtifactEvidenceFailsAfterDeadlineWithoutRequiredArtifacts(t *testing.T) {
|
||||
payload := map[string]any{
|
||||
"success": false,
|
||||
"status": "unknown",
|
||||
"taskStatus": "unknown",
|
||||
"evidence": "artifacts_present",
|
||||
"artifactCount": 1,
|
||||
"runId": "run",
|
||||
"openclawSessionKey": "session",
|
||||
"artifactScope": "tasks/session/run",
|
||||
"artifactDirectory": "/remote/openclaw/workspace/tasks/session/run",
|
||||
"artifacts": []any{map[string]any{"relativePath": "series.config.json"}},
|
||||
}
|
||||
record := &OpenClawTaskRecord{DeadlineAt: time.Now().Add(-time.Minute)}
|
||||
|
||||
got := normalizeOpenClawTaskGetResult(
|
||||
map[string]any{"requiredArtifactExtensions": []any{"pdf"}},
|
||||
payload,
|
||||
"openclaw",
|
||||
record,
|
||||
)
|
||||
|
||||
if status := shared.StringArg(got, "status", ""); status != string(TaskStateFailed) {
|
||||
t.Fatalf("expected expired unknown artifact evidence to fail, got %#v", got)
|
||||
}
|
||||
if code := shared.StringArg(got, "code", ""); code != "OPENCLAW_TERMINAL_WITHOUT_EVIDENCE" {
|
||||
t.Fatalf("expected evidence failure code, got %#v", got)
|
||||
}
|
||||
if missing := shared.ListArg(got, "missingRequiredExtensions"); !slices.ContainsFunc(missing, func(value any) bool {
|
||||
return strings.TrimSpace(shared.StringArg(map[string]any{"value": value}, "value", "")) == "pdf"
|
||||
}) {
|
||||
t.Fatalf("expected missing pdf extension, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenClawArtifactConstraintFieldsArePropagatedAndMarkPartialDelivery(t *testing.T) {
|
||||
result := map[string]any{}
|
||||
mergeOpenClawArtifactPayload(result, map[string]any{
|
||||
"constraintSatisfied": false,
|
||||
"missingRequiredExtensions": []any{"pdf"},
|
||||
})
|
||||
if got := result["constraintSatisfied"]; got != false {
|
||||
t.Fatalf("expected constraintSatisfied=false to propagate, got %#v", result)
|
||||
}
|
||||
if missing := shared.ListArg(result, "missingRequiredExtensions"); len(missing) != 1 || missing[0] != "pdf" {
|
||||
t.Fatalf("expected missingRequiredExtensions to propagate, got %#v", result)
|
||||
}
|
||||
|
||||
server := NewServer()
|
||||
orchestrator := NewSessionOrchestrator(server)
|
||||
sess := server.getOrCreateSession("session-partial-delivery", "thread-partial-delivery")
|
||||
got := orchestrator.normalizeResult(
|
||||
sess,
|
||||
map[string]any{
|
||||
"success": true,
|
||||
"output": "created some files",
|
||||
"constraintSatisfied": false,
|
||||
"missingRequiredExtensions": []any{"pdf"},
|
||||
},
|
||||
RoutingResult{TargetID: "gateway", ProviderID: "gateway", GatewayProviderID: "openclaw"},
|
||||
"turn-partial-delivery",
|
||||
map[string]any{},
|
||||
)
|
||||
|
||||
if status := shared.StringArg(got, "status", ""); status != "partially_delivered" {
|
||||
t.Fatalf("expected partially_delivered status, got %#v", got)
|
||||
}
|
||||
if !parseBool(got["success"]) {
|
||||
t.Fatalf("partial delivery should preserve success=true, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenClawArtifactsSatisfyEveryRequiredExtension(t *testing.T) {
|
||||
artifacts := []map[string]any{
|
||||
{"relativePath": "exports/final.pdf"},
|
||||
}
|
||||
if openClawArtifactsSatisfyRequiredExtensions(artifacts, []string{"pdf", "mp4"}) {
|
||||
t.Fatalf("expected only pdf artifact to miss mp4 requirement")
|
||||
}
|
||||
if !openClawArtifactsSatisfyRequiredExtensions(
|
||||
append(artifacts, map[string]any{"relativePath": "exports/final.MP4"}),
|
||||
[]string{"pdf", "mp4"},
|
||||
) {
|
||||
t.Fatalf("expected pdf and mp4 artifacts to satisfy both requirements")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskGetArtifactExportReceivesRequiredArtifactExtensions(t *testing.T) {
|
||||
gateway := newAcpFakeOpenClawGateway(t)
|
||||
defer gateway.Close()
|
||||
|
||||
t.Setenv("GATEWAY_RPC_URL", gateway.URL())
|
||||
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-token")
|
||||
|
||||
server := NewServer()
|
||||
start, rpcErr := server.handleRequest(shared.RPCRequest{
|
||||
Method: "session.start",
|
||||
Params: map[string]any{
|
||||
"sessionId": "session-export-required-exts",
|
||||
"threadId": "thread-export-required-exts",
|
||||
"taskPrompt": "say pong",
|
||||
"workingDirectory": t.TempDir(),
|
||||
"metadata": map[string]any{
|
||||
"xworkmateTaskArtifactContract": map[string]any{
|
||||
"requiresExportBeforeFinalResponse": true,
|
||||
},
|
||||
},
|
||||
"routing": map[string]any{
|
||||
"routingMode": "explicit",
|
||||
"explicitExecutionTarget": "gateway",
|
||||
"preferredGatewayProviderId": "openclaw",
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
if rpcErr != nil {
|
||||
t.Fatalf("expected running task handle, got rpc error: %#v", rpcErr)
|
||||
}
|
||||
response, rpcErr := server.handleRequest(shared.RPCRequest{
|
||||
Method: "xworkmate.tasks.get",
|
||||
Params: map[string]any{
|
||||
"sessionId": shared.StringArg(start, "sessionId", ""),
|
||||
"threadId": shared.StringArg(start, "threadId", ""),
|
||||
"turnId": shared.StringArg(start, "turnId", ""),
|
||||
"runId": shared.StringArg(start, "runId", ""),
|
||||
"appThreadKey": shared.StringArg(start, "appThreadKey", ""),
|
||||
"openclawSessionKey": shared.StringArg(start, "openclawSessionKey", ""),
|
||||
"artifactScope": shared.StringArg(start, "artifactScope", ""),
|
||||
"artifactDirectory": shared.StringArg(start, "artifactDirectory", ""),
|
||||
"gatewayProviderId": shared.StringArg(start, "resolvedGatewayProviderId", ""),
|
||||
"requiresArtifactExport": true,
|
||||
"requiredArtifactExtensions": []any{"pdf"},
|
||||
"expectedFileCountByExtension": map[string]any{
|
||||
"pdf": 1,
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
if rpcErr != nil {
|
||||
t.Fatalf("expected task lookup response, got rpc error: %#v", rpcErr)
|
||||
}
|
||||
if status := shared.StringArg(response, "status", ""); status != string(TaskStateRunning) {
|
||||
t.Fatalf("expected missing required artifact to keep syncing, got %#v", response)
|
||||
}
|
||||
exportParams := gateway.LastArtifactExportParams()
|
||||
if got := shared.ListArg(exportParams, "requiredArtifactExtensions"); len(got) != 1 || got[0] != "pdf" {
|
||||
t.Fatalf("expected requiredArtifactExtensions to reach export, got %#v", exportParams)
|
||||
}
|
||||
if got := shared.AsMap(exportParams["expectedFileCountByExtension"]); openClawPositiveInt(got["pdf"]) != 1 {
|
||||
t.Fatalf("expected expectedFileCountByExtension to reach export, got %#v", exportParams)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsOpenClawUnknownMethodErrorAcceptsNumericGatewayCodes(t *testing.T) {
|
||||
const method = "xworkmate.session.prepare"
|
||||
cases := []struct {
|
||||
name string
|
||||
payload map[string]any
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "string invalid_request code",
|
||||
payload: map[string]any{"code": "INVALID_REQUEST", "message": "unknown method: xworkmate.session.prepare"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "numeric -32002 (real gateway shape that previously hard-failed)",
|
||||
payload: map[string]any{"code": float64(-32002), "message": "unknown method: xworkmate.session.prepare"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "numeric -32601 method not found",
|
||||
payload: map[string]any{"code": float64(-32601), "message": "Unknown method: xworkmate.session.prepare"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "empty code",
|
||||
payload: map[string]any{"message": "unknown method: xworkmate.session.prepare"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "unrelated error must not be swallowed",
|
||||
payload: map[string]any{"code": float64(-32002), "message": "gateway socket closed"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "unknown method for a different method name",
|
||||
payload: map[string]any{"code": float64(-32601), "message": "unknown method: chat.send"},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := isOpenClawUnknownMethodError(tc.payload, method); got != tc.want {
|
||||
t.Fatalf("isOpenClawUnknownMethodError(%v) = %v, want %v", tc.payload, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -95,8 +95,8 @@ func TestCapabilitiesExposeBuiltInProductionProviderCatalog(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestProductionProviderCatalogFallsBackToBridgeAuthToken(t *testing.T) {
|
||||
t.Setenv("AI_WORKSPACE_AUTH_TOKEN", "")
|
||||
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-token")
|
||||
t.Setenv("INTERNAL_SERVICE_TOKEN", "")
|
||||
|
||||
_, catalog, _ := newProductionProviderCatalog()
|
||||
p, ok := catalog["codex"]
|
||||
@ -109,24 +109,9 @@ func TestProductionProviderCatalogFallsBackToBridgeAuthToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionProviderCatalogPrefersAIWorkspaceAuthToken(t *testing.T) {
|
||||
t.Setenv("AI_WORKSPACE_AUTH_TOKEN", "ai-workspace-token")
|
||||
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-token")
|
||||
|
||||
_, catalog, _ := newProductionProviderCatalog()
|
||||
p, ok := catalog["codex"]
|
||||
if !ok {
|
||||
t.Fatal("missing codex")
|
||||
}
|
||||
|
||||
if got := p.AuthorizationHeader; got != "Bearer ai-workspace-token" {
|
||||
t.Fatalf("expected AI workspace bearer header, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionProviderCatalogPrefersDedicatedBridgeAuthToken(t *testing.T) {
|
||||
t.Setenv("AI_WORKSPACE_AUTH_TOKEN", "")
|
||||
t.Setenv("BRIDGE_AUTH_TOKEN", "dedicated-token")
|
||||
t.Setenv("INTERNAL_SERVICE_TOKEN", "legacy-token")
|
||||
|
||||
_, catalog, _ := newProductionProviderCatalog()
|
||||
p, ok := catalog["codex"]
|
||||
@ -140,7 +125,6 @@ func TestProductionProviderCatalogPrefersDedicatedBridgeAuthToken(t *testing.T)
|
||||
}
|
||||
|
||||
func TestProductionProviderCatalogIgnoresInternalServiceToken(t *testing.T) {
|
||||
t.Setenv("AI_WORKSPACE_AUTH_TOKEN", "")
|
||||
t.Setenv("BRIDGE_AUTH_TOKEN", "")
|
||||
t.Setenv("INTERNAL_SERVICE_TOKEN", "legacy-token")
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -76,9 +76,6 @@ func (s *Server) handleRequest(request shared.RPCRequest, notify func(map[string
|
||||
case "xworkmate.tasks.get":
|
||||
return s.handleTaskGet(ctx, request.Params, notify), nil
|
||||
|
||||
case "xworkmate.session.prepare":
|
||||
return s.handleSessionPrepare(ctx, request.Params, notify)
|
||||
|
||||
case "xworkmate.tasks.cancel":
|
||||
return s.handleTaskCancel(ctx, request.Params, notify), nil
|
||||
|
||||
@ -93,533 +90,46 @@ func (s *Server) handleRequest(request shared.RPCRequest, notify func(map[string
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleSessionPrepare(ctx context.Context, params map[string]any, notify func(map[string]any)) (map[string]any, *shared.RPCError) {
|
||||
gatewayProvider := strings.TrimSpace(shared.StringArg(params, "gatewayProviderId", ""))
|
||||
if gatewayProvider == "" {
|
||||
gatewayProvider = strings.TrimSpace(shared.StringArg(params, "resolvedGatewayProviderId", ""))
|
||||
}
|
||||
if gatewayProvider == "" {
|
||||
gatewayProvider = "openclaw"
|
||||
}
|
||||
if rpcErr := ensureProductionGatewayConnected(s, gatewayProvider, notify); rpcErr != nil {
|
||||
return openClawFallbackSessionPreparePayload(params), nil
|
||||
}
|
||||
result := s.gateway.RequestByMode(
|
||||
gatewayProvider,
|
||||
"xworkmate.session.prepare",
|
||||
params,
|
||||
30*time.Second,
|
||||
notify,
|
||||
)
|
||||
if result.OK {
|
||||
payload := shared.AsMap(result.Payload)
|
||||
if openClawPreparedArtifactScopeFromPayload(payload) != nil {
|
||||
return payload, nil
|
||||
}
|
||||
}
|
||||
if !result.OK && !isOpenClawUnknownMethodError(result.Error, "xworkmate.session.prepare") {
|
||||
return nil, gatewayRPCError(result.Error, "openclaw artifact prepare failed")
|
||||
}
|
||||
return openClawFallbackSessionPreparePayload(params), nil
|
||||
}
|
||||
|
||||
func (s *Server) handleTaskGet(ctx context.Context, params map[string]any, notify func(map[string]any)) map[string]any {
|
||||
params = s.taskGetParamsWithSessionScope(params)
|
||||
gatewayProvider := strings.TrimSpace(shared.StringArg(params, "gatewayProviderId", ""))
|
||||
if gatewayProvider == "" {
|
||||
gatewayProvider = strings.TrimSpace(shared.StringArg(params, "resolvedGatewayProviderId", ""))
|
||||
}
|
||||
if gatewayProvider == "" {
|
||||
gatewayProvider = "openclaw"
|
||||
}
|
||||
// T7/T8: 一旦观察到终态就从持久 run 仓返回,避免之后 gateway 查不到导致结果丢失。
|
||||
if cached, ok := s.cachedTerminalOpenClawResult(params); ok {
|
||||
return cached
|
||||
}
|
||||
if rpcErr := ensureProductionGatewayConnected(s, gatewayProvider, notify); rpcErr != nil {
|
||||
// T7/T9: gateway 不可达时按持久 run 仓兜底(续轮询 / deadline 终态),而非裸 not_found。
|
||||
return s.openClawTaskGetGatewayUnconfirmedFallback(params, "GATEWAY_UNAVAILABLE", rpcErr.Message)
|
||||
}
|
||||
result := s.gateway.RequestByMode(
|
||||
gatewayProvider,
|
||||
"xworkmate.tasks.get",
|
||||
openClawTaskLookupParams(params),
|
||||
30*time.Second,
|
||||
notify,
|
||||
)
|
||||
if result.OK {
|
||||
payload := shared.AsMap(result.Payload)
|
||||
activeOpenClawTask := s.activeOpenClawTaskRecord(params)
|
||||
s.mergeOpenClawTaskGetArtifactExport(payload, params, gatewayProvider, notify)
|
||||
payload = normalizeOpenClawTaskGetResult(params, payload, gatewayProvider, activeOpenClawTask)
|
||||
sessionKey := firstNonEmptyString(payload, "openclawSessionKey", "sessionKey")
|
||||
if sessionKey == "" {
|
||||
sessionKey = strings.TrimSpace(shared.StringArg(params, "openclawSessionKey", ""))
|
||||
}
|
||||
runID := firstNonEmptyString(payload, "runId", "taskId")
|
||||
if runID == "" {
|
||||
runID = strings.TrimSpace(shared.StringArg(params, "runId", ""))
|
||||
}
|
||||
s.decorateOpenClawArtifactDownloadURLs(payload, sessionKey, runID)
|
||||
stripOpenClawArtifactInlineContent(payload)
|
||||
// T8: 缓存「最终客户端可见形态」(已 decorate 下载 URL + strip 内联内容),
|
||||
// 这样从缓存回放时与正常路径完全一致。
|
||||
s.cacheOpenClawTaskGetResultIfTerminal(params, payload)
|
||||
return payload
|
||||
}
|
||||
// T7/T9: gateway 返回错误(socket closed / not_found / lookup failed)时同样走持久 run 仓兜底。
|
||||
message := strings.TrimSpace(shared.StringArg(result.Error, "message", "openclaw native task lookup failed"))
|
||||
code := strings.TrimSpace(shared.StringArg(result.Error, "code", "TASK_LOOKUP_FAILED"))
|
||||
return s.openClawTaskGetGatewayUnconfirmedFallback(params, code, message)
|
||||
}
|
||||
|
||||
func (s *Server) taskGetParamsWithSessionScope(params map[string]any) map[string]any {
|
||||
next := make(map[string]any, len(params)+8)
|
||||
for key, value := range params {
|
||||
next[key] = value
|
||||
}
|
||||
sess := s.findTaskSession(params)
|
||||
if sess == nil {
|
||||
return next
|
||||
sess = s.reassociateOpenClawTask(params)
|
||||
}
|
||||
sess.mu.Lock()
|
||||
defer sess.mu.Unlock()
|
||||
if strings.TrimSpace(shared.StringArg(next, "runId", "")) == "" {
|
||||
next["runId"] = sess.task.RunID
|
||||
}
|
||||
if strings.TrimSpace(shared.StringArg(next, "taskId", "")) == "" {
|
||||
next["taskId"] = sess.task.RunID
|
||||
}
|
||||
if strings.TrimSpace(shared.StringArg(next, "gatewayProviderId", "")) == "" {
|
||||
next["gatewayProviderId"] = sess.task.GatewayProviderID
|
||||
}
|
||||
if strings.TrimSpace(shared.StringArg(next, "artifactScope", "")) == "" {
|
||||
next["artifactScope"] = sess.task.ArtifactScope
|
||||
}
|
||||
if strings.TrimSpace(shared.StringArg(next, "artifactDirectory", "")) == "" {
|
||||
next["artifactDirectory"] = sess.task.ArtifactDirectory
|
||||
}
|
||||
if _, ok := next["requiresArtifactExport"]; !ok && sess.openClaw != nil && sess.openClaw.RequiresArtifactExport {
|
||||
next["requiresArtifactExport"] = true
|
||||
}
|
||||
if _, ok := next["expectedArtifactDirs"]; !ok && sess.openClaw != nil && len(sess.openClaw.ExpectedArtifactDirs) > 0 {
|
||||
next["expectedArtifactDirs"] = append([]string(nil), sess.openClaw.ExpectedArtifactDirs...)
|
||||
}
|
||||
if _, ok := next["requiredArtifactExtensions"]; !ok && sess.openClaw != nil && len(sess.openClaw.RequiredArtifactExts) > 0 {
|
||||
next["requiredArtifactExtensions"] = append([]string(nil), sess.openClaw.RequiredArtifactExts...)
|
||||
}
|
||||
if strings.TrimSpace(shared.StringArg(next, "openclawSessionKey", "")) == "" {
|
||||
next["openclawSessionKey"] = sess.task.SessionKey
|
||||
}
|
||||
if strings.TrimSpace(shared.StringArg(next, "appThreadKey", "")) == "" {
|
||||
next["appThreadKey"] = sess.threadID
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func (s *Server) mergeOpenClawTaskGetArtifactExport(payload map[string]any, params map[string]any, gatewayProvider string, notify func(map[string]any)) {
|
||||
if len(payload) == 0 || s == nil || s.orchestrator == nil {
|
||||
return
|
||||
}
|
||||
status := strings.ToLower(strings.TrimSpace(shared.StringArg(payload, "status", "")))
|
||||
if status == string(TaskStateRunning) || status == string(TaskStateFailed) || status == string(TaskStateCancelled) {
|
||||
return
|
||||
}
|
||||
if !openClawTaskGetRequiresArtifactExport(params, payload) {
|
||||
return
|
||||
}
|
||||
success := true
|
||||
if value, ok := payload["success"]; ok {
|
||||
success = parseBool(value)
|
||||
}
|
||||
if !success {
|
||||
return
|
||||
}
|
||||
remoteWorkingDirectory := strings.TrimSpace(shared.StringArg(payload, "remoteWorkingDirectory", ""))
|
||||
sessionKey := firstNonEmptyString(payload, "openclawSessionKey", "sessionKey")
|
||||
if sessionKey == "" {
|
||||
sessionKey = strings.TrimSpace(shared.StringArg(params, "openclawSessionKey", ""))
|
||||
}
|
||||
runID := firstNonEmptyString(payload, "runId", "taskId")
|
||||
if runID == "" {
|
||||
runID = strings.TrimSpace(shared.StringArg(params, "runId", ""))
|
||||
}
|
||||
artifactScope := firstNonEmptyString(payload, "artifactScope")
|
||||
if artifactScope == "" {
|
||||
artifactScope = strings.TrimSpace(shared.StringArg(params, "artifactScope", ""))
|
||||
}
|
||||
artifactDirectory := firstNonEmptyString(payload, "artifactDirectory")
|
||||
if artifactDirectory == "" {
|
||||
artifactDirectory = strings.TrimSpace(shared.StringArg(params, "artifactDirectory", ""))
|
||||
}
|
||||
if sessionKey == "" || runID == "" || artifactScope == "" || artifactDirectory == "" {
|
||||
return
|
||||
}
|
||||
prepared := &openClawPreparedArtifactScope{
|
||||
RemoteWorkingDirectory: remoteWorkingDirectory,
|
||||
RemoteWorkspaceRefKind: strings.TrimSpace(shared.StringArg(payload, "remoteWorkspaceRefKind", "")),
|
||||
ArtifactScope: artifactScope,
|
||||
ArtifactDirectory: artifactDirectory,
|
||||
ScopeKind: "task",
|
||||
}
|
||||
exportParams := map[string]any{
|
||||
"openclawSessionKey": sessionKey,
|
||||
"runId": runID,
|
||||
"artifactScope": artifactScope,
|
||||
"sinceUnixMs": 0,
|
||||
"maxFiles": 64,
|
||||
"maxInlineBytes": 0,
|
||||
"includeContent": false,
|
||||
}
|
||||
if expectedDirs := openClawTaskGetExpectedArtifactDirs(params, payload); len(expectedDirs) > 0 {
|
||||
exportParams["expectedArtifactDirs"] = expectedDirs
|
||||
}
|
||||
if requiredExts := openClawTaskGetRequiredArtifactExtensions(params, payload); len(requiredExts) > 0 {
|
||||
exportParams["requiredArtifactExtensions"] = append([]string(nil), requiredExts...)
|
||||
}
|
||||
if expectedCounts := openClawTaskGetExpectedFileCounts(params, payload); len(expectedCounts) > 0 {
|
||||
exportParams["expectedFileCountByExtension"] = expectedCounts
|
||||
}
|
||||
exportPayload := s.orchestrator.openClawArtifactExportRequest(gatewayProvider, exportParams, notify)
|
||||
if openClawArtifactExportPayloadAuthoritative(exportPayload) {
|
||||
replaceOpenClawArtifactPayload(payload, exportPayload)
|
||||
} else {
|
||||
mergeOpenClawArtifactPayload(payload, exportPayload)
|
||||
}
|
||||
applyOpenClawPreparedArtifactToResult(payload, prepared)
|
||||
s.decorateOpenClawArtifactDownloadURLs(payload, sessionKey, runID)
|
||||
stripOpenClawArtifactInlineContent(payload)
|
||||
}
|
||||
|
||||
func (s *Server) activeOpenClawTaskRecord(params map[string]any) *OpenClawTaskRecord {
|
||||
sess := s.findTaskSession(params)
|
||||
if sess == nil {
|
||||
return nil
|
||||
return map[string]any{"status": "not_found"}
|
||||
}
|
||||
sess.mu.Lock()
|
||||
defer sess.mu.Unlock()
|
||||
if sess.task.State != TaskStateRunning {
|
||||
return nil
|
||||
waitForArtifacts := shared.BoolArg(shared.StringArg(params, "waitForArtifacts", ""), false)
|
||||
if val, ok := params["waitForArtifacts"].(bool); ok {
|
||||
waitForArtifacts = val
|
||||
}
|
||||
if sess.openClaw == nil {
|
||||
return nil
|
||||
}
|
||||
record := *sess.openClaw
|
||||
return &record
|
||||
}
|
||||
|
||||
func normalizeOpenClawTaskGetResult(params map[string]any, payload map[string]any, gatewayProvider string, activeRecord *OpenClawTaskRecord) map[string]any {
|
||||
if len(payload) == 0 {
|
||||
return payload
|
||||
}
|
||||
artifactScope := firstNonEmptyString(payload, "artifactScope")
|
||||
if artifactScope == "" {
|
||||
artifactScope = strings.TrimSpace(shared.StringArg(params, "artifactScope", ""))
|
||||
}
|
||||
artifactDirectory := firstNonEmptyString(payload, "artifactDirectory")
|
||||
if artifactDirectory == "" {
|
||||
artifactDirectory = strings.TrimSpace(shared.StringArg(params, "artifactDirectory", ""))
|
||||
}
|
||||
if artifactScope == "" && artifactDirectory == "" {
|
||||
return payload
|
||||
}
|
||||
remoteWorkingDirectory := strings.TrimSpace(shared.StringArg(payload, "remoteWorkingDirectory", ""))
|
||||
artifacts := extractArtifactPayloads(payload, remoteWorkingDirectory)
|
||||
requiredExts := openClawTaskGetRequiredArtifactExtensions(params, payload)
|
||||
if openClawUnknownArtifactEvidence(payload, artifacts) {
|
||||
return adjudicateOpenClawUnknownArtifactEvidence(params, payload, gatewayProvider, activeRecord, artifacts, requiredExts, artifactScope, artifactDirectory)
|
||||
}
|
||||
applyOpenClawConstraintDeliveryStatus(payload)
|
||||
if strings.TrimSpace(shared.StringArg(payload, "status", "")) == "partially_delivered" {
|
||||
return payload
|
||||
}
|
||||
if len(artifacts) > 0 && openClawArtifactsSatisfyRequiredExtensions(artifacts, requiredExts) {
|
||||
return payload
|
||||
}
|
||||
status := strings.ToLower(strings.TrimSpace(shared.StringArg(payload, "status", "")))
|
||||
success := true
|
||||
if value, ok := payload["success"]; ok {
|
||||
success = parseBool(value)
|
||||
}
|
||||
if !success || status == string(TaskStateRunning) || status == string(TaskStateFailed) || status == string(TaskStateCancelled) {
|
||||
return payload
|
||||
}
|
||||
if !openClawTaskGetRequiresArtifactExport(params, payload) {
|
||||
return payload
|
||||
}
|
||||
runID := firstNonEmptyString(payload, "runId", "taskId")
|
||||
if runID == "" {
|
||||
runID = strings.TrimSpace(shared.StringArg(params, "runId", ""))
|
||||
}
|
||||
sessionKey := firstNonEmptyString(payload, "openclawSessionKey", "sessionKey")
|
||||
if sessionKey == "" {
|
||||
sessionKey = strings.TrimSpace(shared.StringArg(params, "openclawSessionKey", ""))
|
||||
}
|
||||
payload["success"] = true
|
||||
payload["status"] = string(TaskStateRunning)
|
||||
payload["event"] = string(TaskStateRunning)
|
||||
payload["pending"] = true
|
||||
payload["artifactSyncStatus"] = "syncing"
|
||||
payload["message"] = "OpenClaw task completed; waiting for artifact export."
|
||||
payload["runId"] = runID
|
||||
payload["taskId"] = runID
|
||||
payload["openclawSessionKey"] = sessionKey
|
||||
if strings.TrimSpace(shared.StringArg(payload, "appThreadKey", "")) == "" {
|
||||
payload["appThreadKey"] = strings.TrimSpace(shared.StringArg(params, "appThreadKey", ""))
|
||||
}
|
||||
payload["artifactScope"] = artifactScope
|
||||
payload["artifactDirectory"] = artifactDirectory
|
||||
if len(requiredExts) > 0 {
|
||||
payload["requiredArtifactExtensions"] = append([]string(nil), requiredExts...)
|
||||
}
|
||||
if strings.TrimSpace(shared.StringArg(payload, "resolvedGatewayProviderId", "")) == "" {
|
||||
payload["resolvedGatewayProviderId"] = gatewayProvider
|
||||
}
|
||||
payload["progress"] = map[string]any{
|
||||
"stage": "syncing-artifacts",
|
||||
"message": "Waiting for OpenClaw artifact export.",
|
||||
"terminal": false,
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func openClawUnknownArtifactEvidence(payload map[string]any, artifacts []map[string]any) bool {
|
||||
if strings.ToLower(strings.TrimSpace(shared.StringArg(payload, "status", ""))) != "unknown" {
|
||||
return false
|
||||
}
|
||||
evidence := strings.ToLower(strings.TrimSpace(shared.StringArg(payload, "evidence", "")))
|
||||
if evidence == "artifacts_present" {
|
||||
return true
|
||||
}
|
||||
return parseBool(payload["artifactsPresent"]) ||
|
||||
shared.IntArg(shared.StringArg(payload, "artifactCount", ""), 0) > 0 ||
|
||||
len(artifacts) > 0
|
||||
}
|
||||
|
||||
func adjudicateOpenClawUnknownArtifactEvidence(
|
||||
params map[string]any,
|
||||
payload map[string]any,
|
||||
gatewayProvider string,
|
||||
activeRecord *OpenClawTaskRecord,
|
||||
artifacts []map[string]any,
|
||||
requiredExts []string,
|
||||
artifactScope string,
|
||||
artifactDirectory string,
|
||||
) map[string]any {
|
||||
if openClawTaskRecordStillActive(activeRecord) {
|
||||
running := openClawRunningTaskResult(activeRecord)
|
||||
running["artifactEvidence"] = "artifacts_present"
|
||||
if strings.TrimSpace(shared.StringArg(running, "resolvedGatewayProviderId", "")) == "" {
|
||||
running["resolvedGatewayProviderId"] = gatewayProvider
|
||||
}
|
||||
return running
|
||||
}
|
||||
runID := firstNonEmptyString(payload, "runId", "taskId")
|
||||
if runID == "" {
|
||||
runID = strings.TrimSpace(shared.StringArg(params, "runId", ""))
|
||||
}
|
||||
sessionKey := firstNonEmptyString(payload, "openclawSessionKey", "sessionKey")
|
||||
if sessionKey == "" {
|
||||
sessionKey = strings.TrimSpace(shared.StringArg(params, "openclawSessionKey", ""))
|
||||
}
|
||||
if len(requiredExts) > 0 && openClawArtifactsSatisfyRequiredExtensions(artifacts, requiredExts) {
|
||||
payload["success"] = true
|
||||
payload["successSource"] = "inferred"
|
||||
payload["status"] = string(TaskStateCompleted)
|
||||
payload["event"] = string(TaskStateCompleted)
|
||||
payload["pending"] = false
|
||||
} else {
|
||||
payload["success"] = false
|
||||
payload["status"] = string(TaskStateFailed)
|
||||
payload["event"] = string(TaskStateFailed)
|
||||
payload["pending"] = false
|
||||
payload["code"] = "OPENCLAW_TERMINAL_WITHOUT_EVIDENCE"
|
||||
payload["error"] = "OPENCLAW_TERMINAL_WITHOUT_EVIDENCE"
|
||||
payload["message"] = "OPENCLAW_TERMINAL_WITHOUT_EVIDENCE"
|
||||
if len(requiredExts) > 0 {
|
||||
payload["missingRequiredExtensions"] = openClawMissingRequiredExtensions(artifacts, requiredExts)
|
||||
}
|
||||
}
|
||||
payload["runId"] = runID
|
||||
payload["taskId"] = runID
|
||||
payload["openclawSessionKey"] = sessionKey
|
||||
if strings.TrimSpace(shared.StringArg(payload, "appThreadKey", "")) == "" {
|
||||
payload["appThreadKey"] = strings.TrimSpace(shared.StringArg(params, "appThreadKey", ""))
|
||||
}
|
||||
payload["artifactScope"] = artifactScope
|
||||
payload["artifactDirectory"] = artifactDirectory
|
||||
if len(requiredExts) > 0 {
|
||||
payload["requiredArtifactExtensions"] = append([]string(nil), requiredExts...)
|
||||
}
|
||||
if strings.TrimSpace(shared.StringArg(payload, "resolvedGatewayProviderId", "")) == "" {
|
||||
payload["resolvedGatewayProviderId"] = gatewayProvider
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func openClawTaskRecordStillActive(record *OpenClawTaskRecord) bool {
|
||||
if record == nil {
|
||||
return false
|
||||
}
|
||||
return record.DeadlineAt.IsZero() || time.Now().Before(record.DeadlineAt)
|
||||
}
|
||||
|
||||
func openClawTaskGetRequiresArtifactExport(params map[string]any, payload map[string]any) bool {
|
||||
if parseBool(params["requiresArtifactExport"]) || parseBool(payload["requiresArtifactExport"]) {
|
||||
return true
|
||||
}
|
||||
if parseBool(params["requiresExportBeforeFinalResponse"]) || parseBool(payload["requiresExportBeforeFinalResponse"]) {
|
||||
return true
|
||||
}
|
||||
// expectedArtifactDirs are discovery hints for the plugin's workspace-root
|
||||
// scan. They do not prove that the caller requires a file before the run can
|
||||
// reach a terminal state. Treating them as a blocking contract turns a
|
||||
// failed/no-output agent run into an endless "syncing-artifacts" loop.
|
||||
return len(shared.ListArg(params, "requiredArtifactExtensions")) > 0 ||
|
||||
len(shared.ListArg(payload, "requiredArtifactExtensions")) > 0
|
||||
}
|
||||
|
||||
func openClawTaskGetExpectedArtifactDirs(params map[string]any, payload map[string]any) []any {
|
||||
seen := map[string]bool{}
|
||||
result := []any{}
|
||||
for _, values := range [][]any{
|
||||
shared.ListArg(params, "expectedArtifactDirs"),
|
||||
shared.ListArg(payload, "expectedArtifactDirs"),
|
||||
} {
|
||||
for _, value := range values {
|
||||
item := strings.TrimSpace(fmt.Sprint(value))
|
||||
if item == "" || seen[item] {
|
||||
continue
|
||||
}
|
||||
seen[item] = true
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func openClawArtifactExportPayloadAuthoritative(payload map[string]any) bool {
|
||||
if len(payload) == 0 {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(shared.StringArg(payload, "remoteWorkingDirectory", "")) != "" {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(shared.StringArg(payload, "artifactScope", "")) != "" {
|
||||
return true
|
||||
}
|
||||
_, hasArtifacts := payload["artifacts"]
|
||||
_, hasFiles := payload["files"]
|
||||
_, hasAttachments := payload["attachments"]
|
||||
return hasArtifacts || hasFiles || hasAttachments
|
||||
}
|
||||
|
||||
func replaceOpenClawArtifactPayload(result map[string]any, source map[string]any) {
|
||||
if result == nil {
|
||||
return
|
||||
}
|
||||
for _, key := range []string{"artifacts", "files", "attachments"} {
|
||||
delete(result, key)
|
||||
}
|
||||
mergeOpenClawArtifactPayload(result, source)
|
||||
}
|
||||
|
||||
func openClawTaskGetRequiredArtifactExtensions(params map[string]any, payload map[string]any) []string {
|
||||
return normalizeOpenClawArtifactExtList(openClawTaskGetMergedList(params, payload, "requiredArtifactExtensions"))
|
||||
}
|
||||
|
||||
func openClawTaskGetExpectedFileCounts(params map[string]any, payload map[string]any) map[string]int {
|
||||
result := normalizeOpenClawArtifactExtCountMap(shared.AsMap(payload["expectedFileCountByExtension"]))
|
||||
for ext, count := range normalizeOpenClawArtifactExtCountMap(shared.AsMap(params["expectedFileCountByExtension"])) {
|
||||
if result == nil {
|
||||
result = map[string]int{}
|
||||
}
|
||||
result[ext] = count
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func openClawTaskGetMergedList(params map[string]any, payload map[string]any, key string) []any {
|
||||
seen := map[string]bool{}
|
||||
result := []any{}
|
||||
for _, values := range [][]any{
|
||||
shared.ListArg(params, key),
|
||||
shared.ListArg(payload, key),
|
||||
} {
|
||||
for _, value := range values {
|
||||
item := strings.TrimSpace(fmt.Sprint(value))
|
||||
if item == "" || seen[item] {
|
||||
continue
|
||||
}
|
||||
seen[item] = true
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func openClawArtifactsSatisfyRequiredExtensions(artifacts []map[string]any, requiredExts []string) bool {
|
||||
if len(requiredExts) == 0 {
|
||||
return true
|
||||
}
|
||||
return len(openClawMissingRequiredExtensions(artifacts, requiredExts)) == 0
|
||||
}
|
||||
|
||||
func openClawMissingRequiredExtensions(artifacts []map[string]any, requiredExts []string) []any {
|
||||
missing := make([]any, 0)
|
||||
for _, ext := range requiredExts {
|
||||
normalized := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(ext)), ".")
|
||||
if normalized == "" {
|
||||
continue
|
||||
}
|
||||
found := false
|
||||
for _, artifact := range artifacts {
|
||||
relativePath := strings.ToLower(strings.TrimSpace(shared.StringArg(artifact, "relativePath", "")))
|
||||
if strings.HasSuffix(relativePath, "."+normalized) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
missing = append(missing, normalized)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
return s.orchestrator.probeOpenClawTask(ctx, sess, notify, waitForArtifacts)
|
||||
}
|
||||
|
||||
func (s *Server) handleTaskCancel(ctx context.Context, params map[string]any, notify func(map[string]any)) map[string]any {
|
||||
sess := s.findTaskSession(params)
|
||||
runID := strings.TrimSpace(shared.StringArg(params, "runId", ""))
|
||||
gatewayProvider := strings.TrimSpace(shared.StringArg(params, "gatewayProviderId", ""))
|
||||
if gatewayProvider == "" {
|
||||
gatewayProvider = strings.TrimSpace(shared.StringArg(params, "resolvedGatewayProviderId", ""))
|
||||
if sess == nil {
|
||||
sess = s.reassociateOpenClawTask(params)
|
||||
}
|
||||
if sess != nil {
|
||||
sess.mu.Lock()
|
||||
if runID == "" {
|
||||
runID = sess.task.RunID
|
||||
}
|
||||
if gatewayProvider == "" {
|
||||
gatewayProvider = sess.task.GatewayProviderID
|
||||
}
|
||||
sess.task.State = TaskStateCancelled
|
||||
sess.task.UpdatedAt = time.Now()
|
||||
sess.task.ProgressStage = "cancelled"
|
||||
sess.task.ProgressMessage = "OpenClaw task cancelled"
|
||||
sess.task.ProgressTerminal = true
|
||||
if sess.openClaw != nil {
|
||||
sess.openClaw.ProgressStage = "cancelled"
|
||||
sess.openClaw.ProgressMessage = "OpenClaw task cancelled"
|
||||
}
|
||||
sess.mu.Unlock()
|
||||
if sess == nil {
|
||||
return map[string]any{"accepted": false, "status": "not_found"}
|
||||
}
|
||||
if gatewayProvider == "" {
|
||||
gatewayProvider = "openclaw"
|
||||
sess.mu.Lock()
|
||||
gatewayProvider := sess.task.GatewayProviderID
|
||||
runID := sess.task.RunID
|
||||
sess.task.State = TaskStateCancelled
|
||||
sess.task.UpdatedAt = time.Now()
|
||||
sess.task.ProgressStage = "cancelled"
|
||||
sess.task.ProgressMessage = "OpenClaw task cancelled"
|
||||
sess.task.ProgressTerminal = true
|
||||
if sess.openClaw != nil {
|
||||
sess.openClaw.ProgressStage = "cancelled"
|
||||
sess.openClaw.ProgressMessage = "OpenClaw task cancelled"
|
||||
sess.openClaw.ProgressTerminal = true
|
||||
}
|
||||
if strings.TrimSpace(runID) != "" && s.gateway != nil {
|
||||
snapshot := openClawSessionSnapshotLocked(sess)
|
||||
sess.mu.Unlock()
|
||||
s.orchestrator.releaseOpenClawAdmission(sess)
|
||||
if strings.TrimSpace(gatewayProvider) != "" && strings.TrimSpace(runID) != "" && s.gateway != nil {
|
||||
_ = s.gateway.RequestByMode(
|
||||
gatewayProvider,
|
||||
"agent.cancel",
|
||||
@ -628,7 +138,8 @@ func (s *Server) handleTaskCancel(ctx context.Context, params map[string]any, no
|
||||
notify,
|
||||
)
|
||||
}
|
||||
return map[string]any{"accepted": strings.TrimSpace(runID) != "", "runId": runID}
|
||||
snapshot["accepted"] = true
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func (s *Server) findTaskSession(params map[string]any) *session {
|
||||
@ -636,6 +147,7 @@ func (s *Server) findTaskSession(params map[string]any) *session {
|
||||
threadID := strings.TrimSpace(shared.StringArg(params, "threadId", ""))
|
||||
turnID := strings.TrimSpace(shared.StringArg(params, "turnId", ""))
|
||||
runID := strings.TrimSpace(shared.StringArg(params, "runId", ""))
|
||||
artifactScope := strings.TrimSpace(shared.StringArg(params, "artifactScope", ""))
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if sessionID != "" && s.sessions[sessionID] != nil {
|
||||
@ -648,7 +160,8 @@ func (s *Server) findTaskSession(params map[string]any) *session {
|
||||
candidate.mu.Lock()
|
||||
matches := (threadID != "" && candidate.threadID == threadID) ||
|
||||
(turnID != "" && candidate.task.TurnID == turnID) ||
|
||||
(runID != "" && candidate.task.RunID == runID)
|
||||
(runID != "" && candidate.task.RunID == runID) ||
|
||||
(artifactScope != "" && candidate.task.ArtifactScope == artifactScope)
|
||||
candidate.mu.Unlock()
|
||||
if matches {
|
||||
return candidate
|
||||
@ -657,31 +170,93 @@ func (s *Server) findTaskSession(params map[string]any) *session {
|
||||
return nil
|
||||
}
|
||||
|
||||
func openClawTaskLookupParams(params map[string]any) map[string]any {
|
||||
result := map[string]any{}
|
||||
for _, key := range []string{
|
||||
"appThreadKey",
|
||||
"openclawSessionKey",
|
||||
"runId",
|
||||
"taskId",
|
||||
"includeArtifacts",
|
||||
"includeContent",
|
||||
"expectedArtifactDirs",
|
||||
"workspaceDir",
|
||||
"artifactScope",
|
||||
"artifactDirectory",
|
||||
"requiresArtifactExport",
|
||||
} {
|
||||
if value, ok := params[key]; ok {
|
||||
result[key] = value
|
||||
}
|
||||
func (s *Server) reassociateOpenClawTask(params map[string]any) *session {
|
||||
runID := strings.TrimSpace(shared.StringArg(params, "runId", ""))
|
||||
artifactScope := strings.TrimSpace(shared.StringArg(params, "artifactScope", ""))
|
||||
if runID == "" || artifactScope == "" {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(shared.StringArg(result, "workspaceDir", "")) == "" {
|
||||
if workspaceDir := openClawArtifactWorkspaceDir(params); workspaceDir != "" {
|
||||
result["workspaceDir"] = workspaceDir
|
||||
}
|
||||
sessionID := strings.TrimSpace(shared.StringArg(params, "sessionId", ""))
|
||||
threadID := strings.TrimSpace(shared.StringArg(params, "threadId", sessionID))
|
||||
if sessionID == "" {
|
||||
sessionID = threadID
|
||||
}
|
||||
return result
|
||||
if sessionID == "" {
|
||||
sessionID = "openclaw:" + runID
|
||||
}
|
||||
if threadID == "" {
|
||||
threadID = sessionID
|
||||
}
|
||||
turnID := strings.TrimSpace(shared.StringArg(params, "turnId", runID))
|
||||
sessionKey := strings.TrimSpace(shared.StringArg(params, "openclawSessionKey", ""))
|
||||
if sessionKey == "" {
|
||||
sessionKey = openClawAgentMainSessionKey(strings.TrimSpace(shared.StringArg(params, "appThreadKey", threadID)))
|
||||
}
|
||||
gatewayProvider := strings.TrimSpace(shared.StringArg(params, "gatewayProviderId", "openclaw"))
|
||||
now := time.Now()
|
||||
prepared := &openClawPreparedArtifactScope{
|
||||
ArtifactScope: artifactScope,
|
||||
ArtifactDirectory: strings.TrimSpace(shared.StringArg(params, "artifactDirectory", "")),
|
||||
RelativeArtifactDirectory: artifactScope,
|
||||
ScopeKind: "task",
|
||||
RemoteWorkingDirectory: strings.TrimSpace(shared.StringArg(params, "remoteWorkingDirectory", "")),
|
||||
RemoteWorkspaceRefKind: strings.TrimSpace(shared.StringArg(params, "remoteWorkspaceRefKind", "")),
|
||||
}
|
||||
contract := openClawArtifactContract{
|
||||
TaskLoadClass: strings.TrimSpace(shared.StringArg(params, "taskLoadClass", "")),
|
||||
ComplexLongChain: shared.BoolArg(shared.StringArg(params, "complexLongChain", ""), false),
|
||||
}
|
||||
taskLoadClass, budget := openClawTaskRuntimePolicy(params, map[string]any{"sessionKey": sessionKey}, contract)
|
||||
if explicitBudget := shared.IntArg(shared.StringArg(params, "runtimeBudgetMinutes", ""), 0); explicitBudget > 0 {
|
||||
budget = explicitBudget
|
||||
}
|
||||
sess := s.getOrCreateSession(sessionID, threadID)
|
||||
sess.mu.Lock()
|
||||
sess.provider = gatewayProvider
|
||||
sess.target = "gateway"
|
||||
sess.mode = "gateway"
|
||||
sess.task = QueuedTask{
|
||||
SessionID: sessionID,
|
||||
ThreadID: threadID,
|
||||
TurnID: turnID,
|
||||
RunID: runID,
|
||||
SessionKey: sessionKey,
|
||||
Provider: gatewayProvider,
|
||||
Target: "gateway",
|
||||
GatewayProviderID: gatewayProvider,
|
||||
State: TaskStateRunning,
|
||||
Kind: TaskKindGateway,
|
||||
TaskLoadClass: taskLoadClass,
|
||||
ArtifactScope: artifactScope,
|
||||
ArtifactDirectory: prepared.ArtifactDirectory,
|
||||
RuntimeBudgetMinutes: budget,
|
||||
StartedAt: now,
|
||||
DeadlineAt: now.Add(time.Duration(budget) * time.Minute),
|
||||
UpdatedAt: now,
|
||||
ProgressStage: "reassociated",
|
||||
ProgressMessage: "OpenClaw task reassociated from task handle",
|
||||
}
|
||||
sess.openClaw = &OpenClawTaskRecord{
|
||||
SessionID: sessionID,
|
||||
ThreadID: threadID,
|
||||
TurnID: turnID,
|
||||
RunID: runID,
|
||||
SessionKey: sessionKey,
|
||||
GatewayProviderID: gatewayProvider,
|
||||
TaskLoadClass: taskLoadClass,
|
||||
ArtifactSinceUnixMs: 0,
|
||||
RuntimeBudgetMinutes: budget,
|
||||
StartedAt: now,
|
||||
DeadlineAt: now.Add(time.Duration(budget) * time.Minute),
|
||||
ProgressStage: "reassociated",
|
||||
ProgressMessage: "OpenClaw task reassociated from task handle",
|
||||
ChatParams: map[string]any{"sessionKey": sessionKey},
|
||||
PreparedArtifact: prepared,
|
||||
ArtifactContract: contract,
|
||||
}
|
||||
sess.lastResult = openClawRunningTaskResult(sess.openClaw)
|
||||
sess.mu.Unlock()
|
||||
return sess
|
||||
}
|
||||
|
||||
func (s *Server) cancelSession(ctx context.Context, sessionID string) {
|
||||
@ -763,10 +338,6 @@ func (s *Server) handleDesktopMethod(ctx context.Context, method string, params
|
||||
srv.StopSession(sessionID)
|
||||
return nil, &shared.RPCError{Code: -32002, Message: fmt.Sprintf("failed to process SDP offer: %v", err)}
|
||||
}
|
||||
if err := srv.StartCapture(sessionID); err != nil {
|
||||
srv.StopSession(sessionID)
|
||||
return nil, &shared.RPCError{Code: -32004, Message: fmt.Sprintf("failed to start desktop capture: %v", err)}
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"sessionId": sessionID,
|
||||
|
||||
@ -43,20 +43,13 @@ func newHTTPServer(addr string, handler http.Handler) *http.Server {
|
||||
|
||||
func NewServer() *Server {
|
||||
config := loadBridgeConfig()
|
||||
authTokens := bridgeInboundAuthTokens()
|
||||
authToken := ""
|
||||
authExtraTokens := []string(nil)
|
||||
if len(authTokens) > 0 {
|
||||
authToken = authTokens[0]
|
||||
authExtraTokens = authTokens[1:]
|
||||
}
|
||||
s := &Server{
|
||||
sessions: make(map[string]*session),
|
||||
config: config,
|
||||
allowedOrigins: shared.ParseAllowedOrigins(shared.EnvOrDefault("ACP_ALLOWED_ORIGINS", "https://xworkmate.svc.plus,http://localhost:*,http://127.0.0.1:*")),
|
||||
authService: service.NewStaticTokenAuthService(
|
||||
authToken,
|
||||
authExtraTokens...,
|
||||
shared.EnvOrDefault("BRIDGE_AUTH_TOKEN", ""),
|
||||
shared.EnvOrDefault("BRIDGE_REVIEW_AUTH_TOKEN", ""),
|
||||
),
|
||||
openClawGate: newOpenClawGatewayAdmissionGate(config),
|
||||
taskRouter: newDistributedTaskRouter(distributedTaskRouterConfig{
|
||||
|
||||
@ -1,158 +0,0 @@
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="silent-session" runId="silent-run" durationMs=0 ok=false
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701412623849000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701412623849000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw" runId="turn-1780701412623849000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701412623849000" stage="export" prepared=true exported=false empty=true
|
||||
--- FAIL: TestExecuteSessionTaskGatewayAutoConnectsLocalOpenClaw (0.00s)
|
||||
routing_test.go:532: expected readable OpenClaw session key, got map[string]interface {}{"appThreadKey":"thread-openclaw", "expectedArtifactDirs":[]interface {}{"assets/images/", "reports/"}, "externalTaskId":"turn-1780701412623849000", "openclawSessionKey":"test-session", "requestId":"turn-1780701412623849000", "runId":"turn-1780701412623849000", "schemaVersion":1, "sessionId":"session-openclaw", "threadId":"thread-openclaw"}
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701412628012000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701412628012000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-no-output" runId="turn-1780701412628012000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701412628012000" stage="export" prepared=true exported=false empty=true
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701412629576000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701412629576000" durationMs=0 ok=true
|
||||
--- FAIL: TestExecuteSessionTaskGatewayFailsClosedWhenOpenClawAcceptsDifferentSession (0.00s)
|
||||
routing_test.go:851: expected Bridge to request the app-mapped OpenClaw session, got map[string]interface {}{"idempotencyKey":"turn-1780701412629576000", "message":"say pong", "sessionKey":"test-session", "systemProvenanceReceipt":"XWorkmate task artifact context:\n- Treat artifactDirectory as the working directory for all files generated in this turn.\n- Write final artifacts directly under artifactDirectory using relative paths such as assets/images/... or prompts/....\n- Do not create a nested task_artifacts/<session> directory inside artifactDirectory.\n- Environment contract for shell commands:\n export XWORKMATE_TASK_ARTIFACT_DIR='/remote/openclaw/workspace/tasks/test-session/turn-1780701412629576000'\n export XWORKMATE_ARTIFACT_DIRECTORY='/remote/openclaw/workspace/tasks/test-session/turn-1780701412629576000'\n export XWORKMATE_ARTIFACT_SCOPE='tasks/test-session/turn-1780701412629576000'\n export XWORKMATE_SESSION_KEY='test-session'\n export XWORKMATE_RUN_ID='turn-1780701412629576000'\n cd '/remote/openclaw/workspace/tasks/test-session/turn-1780701412629576000'\nartifactDirectory: /remote/openclaw/workspace/tasks/test-session/turn-1780701412629576000\nartifactScope: tasks/test-session/turn-1780701412629576000\nrelativeArtifactDirectory: tasks/test-session/turn-1780701412629576000\nremoteWorkingDirectory: /remote/openclaw/workspace"}
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701412630899000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701412630899000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-wait-recover" runId="turn-1780701412630899000" durationMs=0 ok=false
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701412632427000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701412632427000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-running-wait" runId="turn-1780701412632427000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701412633591000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701412633591000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-agent-failed" runId="turn-1780701412633591000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701412633591000" stage="export" prepared=true exported=false empty=true
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701412634940000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701412634940000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw" runId="turn-1780701412634940000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:52 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701412634940000" stage="export" prepared=true exported=false empty=true
|
||||
2026/06/06 07:16:53 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-wait-recover" runId="turn-1780701412630899000" durationMs=0 ok=false
|
||||
2026/06/06 07:16:53 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-running-wait" runId="turn-1780701412632427000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-wait-recover" runId="turn-1780701412630899000" durationMs=0 ok=false
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-running-wait" runId="turn-1780701412632427000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701414658278000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701414658278000" durationMs=0 ok=false
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701414659932000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701414659932000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-retry" runId="turn-1780701414659932000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701414659932000" stage="export" prepared=true exported=false empty=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701414661850000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701414661850000" durationMs=0 ok=false
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701414663673000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701414663673000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-wait-fail" runId="turn-1780701414663673000" durationMs=0 ok=false
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701414664955000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701414664955000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-artifact" runId="turn-1780701414664955000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701414664955000" stage="export" prepared=true exported=true empty=false
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701414666478000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701414666478000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-latest-artifact" runId="turn-1780701414666478000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701414666478000" stage="export" prepared=true exported=false empty=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701414668574000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701414668574000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="openclaw-run-actual" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-actual-run" runId="openclaw-run-actual" durationMs=0 ok=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="openclaw-run-actual" stage="export" prepared=true exported=true empty=false
|
||||
--- FAIL: TestExecuteSessionTaskGatewayDoesNotExportArtifactScopeDeclaredInOutput (0.00s)
|
||||
routing_test.go:1725: expected gateway response, got rpc error: &shared.RPCError{Code:-32602, Message:"openclaw artifact prepare requires openclawSessionKey and runId", Data:interface {}(nil)}
|
||||
--- FAIL: TestExecuteSessionTaskGatewayDoesNotExportDraftScopeVariant (0.00s)
|
||||
routing_test.go:1780: expected gateway response, got rpc error: &shared.RPCError{Code:-32602, Message:"openclaw artifact prepare requires openclawSessionKey and runId", Data:interface {}(nil)}
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701414673111000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701414673111000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-claimed-artifact" runId="turn-1780701414673111000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701414673111000" stage="export" prepared=true exported=false empty=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701414674771000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701414674771000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-message-artifact" runId="turn-1780701414674771000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701414674771000" stage="export" prepared=true exported=true empty=false
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701414956527000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701414956527000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-event-artifact" runId="turn-1780701414956527000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701414956527000" stage="export" prepared=true exported=true empty=false
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701414958364000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701414958364000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-artifact-missing" runId="turn-1780701414958364000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:54 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701414958364000" stage="export" prepared=true exported=false empty=true
|
||||
2026/06/06 07:16:55 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="task-keepalive" sessionId="s1" threadId="t1" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.log"
|
||||
2026/06/06 07:16:55 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="task-keepalive" sessionId="s1" threadId="t1" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.snapshot"
|
||||
2026/06/06 07:16:55 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="task-keepalive" sessionId="s1" threadId="t1" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.log"
|
||||
2026/06/06 07:16:55 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="task-keepalive" sessionId="s1" threadId="t1" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.snapshot"
|
||||
2026/06/06 07:16:55 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="task-keepalive" sessionId="s1" threadId="t1" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.log"
|
||||
2026/06/06 07:16:55 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701415258804000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:55 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701415258804000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:55 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="task-1" sessionId="s1" threadId="t1" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.log"
|
||||
2026/06/06 07:16:55 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="task-1" sessionId="s1" threadId="t1" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.snapshot"
|
||||
2026/06/06 07:16:55 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="task-1" sessionId="s1" threadId="t1" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.log"
|
||||
2026/06/06 07:16:55 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="task-1" sessionId="s1" threadId="t1" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.snapshot"
|
||||
2026/06/06 07:16:55 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="task-1" sessionId="s1" threadId="t1" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.log"
|
||||
2026/06/06 07:16:55 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701415260880000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:55 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701415260880000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:55 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-running-wait" runId="turn-1780701412632427000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:55 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-wait-recover" runId="turn-1780701412630899000" durationMs=0 ok=false
|
||||
2026/06/06 07:16:56 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="s1" runId="turn-1780701415258804000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:56 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701415258804000" stage="export" prepared=true exported=false empty=true
|
||||
2026/06/06 07:16:56 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-running-wait" runId="turn-1780701412632427000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:56 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-wait-recover" runId="turn-1780701412630899000" durationMs=0 ok=false
|
||||
2026/06/06 07:16:57 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-running-wait" runId="turn-1780701412632427000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:57 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-wait-recover" runId="turn-1780701412630899000" durationMs=0 ok=false
|
||||
2026/06/06 07:16:57 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="s1" runId="turn-1780701415260880000" durationMs=1500 ok=true
|
||||
2026/06/06 07:16:57 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701415260880000" stage="export" prepared=true exported=false empty=true
|
||||
2026/06/06 07:16:57 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701415260905000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:57 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701415260905000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:57 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="e2e-0" sessionId="e2e-s0" threadId="e2e-t0" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.log"
|
||||
2026/06/06 07:16:57 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="e2e-0" sessionId="e2e-s0" threadId="e2e-t0" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.snapshot"
|
||||
2026/06/06 07:16:57 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="e2e-0" sessionId="e2e-s0" threadId="e2e-t0" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.log"
|
||||
2026/06/06 07:16:57 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="e2e-0" sessionId="e2e-s0" threadId="e2e-t0" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.snapshot"
|
||||
2026/06/06 07:16:57 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="e2e-0" sessionId="e2e-s0" threadId="e2e-t0" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.log"
|
||||
2026/06/06 07:16:57 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701417766587000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:57 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701417766525000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:57 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701417766826000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:57 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701417766815000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:57 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701417766587000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:57 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701417766525000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:57 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701417766815000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:57 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701417766451000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:57 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701417766826000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:57 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701417766451000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:57 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="e2e-s4" runId="turn-1780701417766587000" durationMs=201 ok=true
|
||||
2026/06/06 07:16:57 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701417766587000" stage="export" prepared=true exported=true empty=false
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="e2e-s3" runId="turn-1780701417766525000" durationMs=200 ok=true
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701417766525000" stage="export" prepared=true exported=true empty=false
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="e2e-s1" runId="turn-1780701417766815000" durationMs=201 ok=true
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701417766815000" stage="export" prepared=true exported=true empty=false
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="e2e-s0" runId="turn-1780701417766451000" durationMs=201 ok=true
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701417766451000" stage="export" prepared=true exported=true empty=false
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-wait-recover" runId="turn-1780701412630899000" durationMs=0 ok=false
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-openclaw-running-wait" runId="turn-1780701412632427000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="e2e-s2" runId="turn-1780701417766826000" durationMs=201 ok=true
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701417766826000" stage="export" prepared=true exported=true empty=false
|
||||
2026/06/06 07:16:58 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="task-active" sessionId="active" threadId="active" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.log"
|
||||
2026/06/06 07:16:58 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="task-active" sessionId="active" threadId="active" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.snapshot"
|
||||
2026/06/06 07:16:58 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="task-active" sessionId="active" threadId="active" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.log"
|
||||
2026/06/06 07:16:58 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="task-active" sessionId="active" threadId="active" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.snapshot"
|
||||
2026/06/06 07:16:58 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="task-active" sessionId="active" threadId="active" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.log"
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701418788112000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701418788112000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:58 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="task-filter" sessionId="session-filter" threadId="thread-filter" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.log"
|
||||
2026/06/06 07:16:58 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="task-filter" sessionId="session-filter" threadId="thread-filter" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.snapshot"
|
||||
2026/06/06 07:16:58 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="task-filter" sessionId="session-filter" threadId="thread-filter" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.log"
|
||||
2026/06/06 07:16:58 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="task-filter" sessionId="session-filter" threadId="thread-filter" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.snapshot"
|
||||
2026/06/06 07:16:58 level=warn component=acp_sse event=notification_dropped path="/acp/rpc" rpcMethod="session.start" requestId="task-filter" sessionId="session-filter" threadId="thread-filter" reason="raw_gateway_event" notificationMethod="xworkmate.gateway.log"
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701418800358000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701418800358000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="session-filter" runId="turn-1780701418800358000" durationMs=1 ok=true
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701418800358000" stage="export" prepared=true exported=true empty=false
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701418803902000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701418803902000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="s1" runId="turn-1780701418803902000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701418803902000" stage="export" prepared=true exported=false empty=true
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701418806273000" stage="prepare" prepared=true exported=false empty=false
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="chat.send" sessionId="test-session" runId="turn-1780701418806273000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=request_timing provider="openclaw" method="agent.wait.probe" sessionId="s1" runId="turn-1780701418806273000" durationMs=0 ok=true
|
||||
2026/06/06 07:16:58 level=info component=openclaw_gateway event=artifact_sync provider="openclaw" sessionId="test-session" runId="turn-1780701418806273000" stage="export" prepared=true exported=false empty=true
|
||||
2026/06/06 07:16:58 level=warn component=acp_sse event=stream_write path="" rpcMethod="" requestId="" sessionId="" threadId="" sseEvent="xworkmate.gateway.push" reason="panic" error="closed response writer"
|
||||
FAIL
|
||||
FAIL xworkmate-bridge/internal/acp 6.479s
|
||||
FAIL
|
||||
@ -52,6 +52,7 @@ type QueuedTask struct {
|
||||
StartedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
DeadlineAt time.Time
|
||||
LastProbeAt time.Time
|
||||
ProgressStage string
|
||||
ProgressMessage string
|
||||
ProgressTerminal bool
|
||||
@ -95,11 +96,11 @@ type Server struct {
|
||||
orchestrator *SessionOrchestrator
|
||||
memoryService memory.Service
|
||||
|
||||
providerOrder []string
|
||||
gateway *gatewayruntime.Manager
|
||||
openClawGate *openClawGatewayAdmissionGate
|
||||
jobs *jobManager
|
||||
taskRouter *distributedTaskRouter
|
||||
providerOrder []string
|
||||
gateway *gatewayruntime.Manager
|
||||
openClawGate *openClawGatewayAdmissionGate
|
||||
jobs *jobManager
|
||||
taskRouter *distributedTaskRouter
|
||||
|
||||
// Legacy / Common
|
||||
authService interface{} // Minimal auth dependency
|
||||
|
||||
@ -40,11 +40,20 @@ func sseFirstResultEnvelope(t *testing.T, body string) map[string]any {
|
||||
func taskGetHTTPResult(t *testing.T, handler http.Handler, handle map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
body := fmt.Sprintf(
|
||||
`{"jsonrpc":"2.0","id":"task-get","method":"xworkmate.tasks.get","params":{"runId":%q,"appThreadKey":%q,"openclawSessionKey":%q,"gatewayProviderId":%q,"includeArtifacts":true}}`,
|
||||
`{"jsonrpc":"2.0","id":"task-get","method":"xworkmate.tasks.get","params":{"sessionId":%q,"threadId":%q,"turnId":%q,"runId":%q,"appThreadKey":%q,"openclawSessionKey":%q,"artifactScope":%q,"artifactDirectory":%q,"gatewayProviderId":%q,"runtimeBudgetMinutes":%q,"taskLoadClass":%q,"expectedArtifactExtensions":%s,"requiredArtifactExtensions":%s}}`,
|
||||
shared.StringArg(handle, "sessionId", ""),
|
||||
shared.StringArg(handle, "threadId", ""),
|
||||
shared.StringArg(handle, "turnId", ""),
|
||||
shared.StringArg(handle, "runId", ""),
|
||||
shared.StringArg(handle, "appThreadKey", ""),
|
||||
shared.StringArg(handle, "openclawSessionKey", ""),
|
||||
shared.StringArg(handle, "artifactScope", ""),
|
||||
shared.StringArg(handle, "artifactDirectory", ""),
|
||||
shared.StringArg(handle, "resolvedGatewayProviderId", "openclaw"),
|
||||
shared.StringArg(handle, "runtimeBudgetMinutes", ""),
|
||||
shared.StringArg(handle, "taskLoadClass", ""),
|
||||
jsonArrayString(t, shared.ListArg(handle, "expectedArtifactExtensions")),
|
||||
jsonArrayString(t, shared.ListArg(handle, "requiredArtifactExtensions")),
|
||||
)
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "http://127.0.0.1/acp/rpc", strings.NewReader(body))
|
||||
@ -77,6 +86,18 @@ func taskGetHTTPTerminalResult(t *testing.T, handler http.Handler, handle map[st
|
||||
}
|
||||
}
|
||||
|
||||
func jsonArrayString(t *testing.T, values []any) string {
|
||||
t.Helper()
|
||||
if values == nil {
|
||||
return "[]"
|
||||
}
|
||||
encoded, err := json.Marshal(values)
|
||||
if err != nil {
|
||||
t.Fatalf("encode array: %v", err)
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func TestHTTPHandlerRootAndPingExposeRuntimeVersionInfo(t *testing.T) {
|
||||
t.Setenv("BRIDGE_AUTH_TOKEN", "")
|
||||
t.Setenv("BRIDGE_REVIEW_AUTH_TOKEN", "")
|
||||
@ -231,7 +252,7 @@ func TestHTTPHandlerGatewayOpenClawReturnsRunningEnvelopeAndDone(t *testing.T) {
|
||||
request, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
httpServer.URL+"/acp/rpc",
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":"task-keepalive","method":"session.start","params":{"sessionId":"s1","openclawSessionKey":"t1","threadId":"t1","taskPrompt":"Reply pong","workingDirectory":"`+t.TempDir()+`","routing":{"routingMode":"explicit","explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}`),
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":"task-keepalive","method":"session.start","params":{"sessionId":"s1","threadId":"t1","taskPrompt":"Reply pong","workingDirectory":"`+t.TempDir()+`","routing":{"routingMode":"explicit","explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}`),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("build request: %v", err)
|
||||
@ -296,7 +317,7 @@ func TestHTTPHandlerGatewayOpenClawReturnsRunningEnvelopeAndDone(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPHandlerGatewayOpenClawAdmissionReleasesAfterAcceptedSSE(t *testing.T) {
|
||||
func TestHTTPHandlerGatewayOpenClawAdmissionQueuesExcessConcurrentSSE(t *testing.T) {
|
||||
gateway := newAcpFakeOpenClawGateway(t)
|
||||
defer gateway.Close()
|
||||
gateway.agentWaitDelayMs.Store(1500)
|
||||
@ -326,7 +347,7 @@ func TestHTTPHandlerGatewayOpenClawAdmissionReleasesAfterAcceptedSSE(t *testing.
|
||||
request, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
httpServer.URL+"/acp/rpc",
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":"task-`+strconv.Itoa(index)+`","method":"session.start","params":{"sessionId":"s`+strconv.Itoa(index)+`","openclawSessionKey":"t`+strconv.Itoa(index)+`","threadId":"t`+strconv.Itoa(index)+`","taskPrompt":"Reply pong","workingDirectory":"`+t.TempDir()+`","routing":{"routingMode":"explicit","explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}`),
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":"task-`+strconv.Itoa(index)+`","method":"session.start","params":{"sessionId":"s`+strconv.Itoa(index)+`","threadId":"t`+strconv.Itoa(index)+`","taskPrompt":"Reply pong","workingDirectory":"`+t.TempDir()+`","routing":{"routingMode":"explicit","explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}`),
|
||||
)
|
||||
if err != nil {
|
||||
results <- result{err: err}
|
||||
@ -355,25 +376,36 @@ func TestHTTPHandlerGatewayOpenClawAdmissionReleasesAfterAcceptedSSE(t *testing.
|
||||
}
|
||||
close(start)
|
||||
waitForOpenClawGatewayCount(t, func() int { return gateway.ChatSendCount() }, 1)
|
||||
time.Sleep(75 * time.Millisecond)
|
||||
if got := gateway.ChatSendCount(); got != 1 {
|
||||
t.Fatalf("expected admission gate to hold queued chat.send while one is active, got %d", got)
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
|
||||
var sawQueued bool
|
||||
var runningHandleCount int
|
||||
for item := range results {
|
||||
if item.err != nil {
|
||||
t.Fatalf("concurrent request failed: %v", item.err)
|
||||
}
|
||||
if strings.Contains(item.body, `"event":"queued"`) {
|
||||
sawQueued = true
|
||||
}
|
||||
envelope := sseFirstResultEnvelope(t, item.body)
|
||||
result := shared.AsMap(envelope["result"])
|
||||
if result["status"] == "running" && strings.TrimSpace(shared.StringArg(result, "runId", "")) != "" {
|
||||
runningHandleCount += 1
|
||||
}
|
||||
}
|
||||
if !sawQueued {
|
||||
t.Fatalf("expected one queued session.update event")
|
||||
}
|
||||
if runningHandleCount != 2 {
|
||||
t.Fatalf("expected both requests to return running handles, got %d", runningHandleCount)
|
||||
}
|
||||
if got := gateway.ChatSendCount(); got != 2 {
|
||||
t.Fatalf("expected admission to release after accepted native chat.send, got %d chat.send calls", got)
|
||||
t.Fatalf("expected queued request to run after a slot releases, got %d chat.send calls", got)
|
||||
}
|
||||
}
|
||||
|
||||
@ -394,11 +426,11 @@ func TestHTTPHandlerGatewayOpenClawHandlesFiveConcurrentE2ECases(t *testing.T) {
|
||||
defer httpServer.Close()
|
||||
|
||||
prompts := []string{
|
||||
"采集最新AI资讯,保存在md文件",
|
||||
"围绕\n从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 右侧是当下 \n测试制作视频,附件带有图片",
|
||||
"从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 \n制作 使用codex 制作连续制作 7张的一些列图片",
|
||||
"围绕\n从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进\n输出Markdown格式文件, 微信公众号短图文 400-600字 插入关键词的软文\n输出Markdown格式文件, 小红书风格 600-800字 插入钩子话题的软文\n输出Markdown格式文件, X文案串 小于144字的英语 鲜明的观点\n输出Markdown格式文件, 微信公众号文章 800-1200字左右\n输出Markdown格式文件, 头条号长文 800-1200字左右",
|
||||
"围绕\n\n从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 \n\n拆章节 -> 每章调用 Codex -> 每章 GPT images2 生成图 -> 汇总排版 -> 输出 PDF",
|
||||
"参考附件模版制作 ,围绕\n从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 \n连续制作 7张的一些列图片",
|
||||
"拆章节 -> 每章调用 Codex -> 每章 GPT images2 生成图 -> 汇总排版 -> 输出 PDF\n\n右侧 artifact栏 显示的陈旧文件 make artifact",
|
||||
"围绕\n从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 右侧是当下 \n测试制作视频",
|
||||
"围绕\n\n从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 \n\n拆章节 -> 每章调用 Codex -> 每章 GPT images2 生成图 -> 汇总排版 -> 制作视频",
|
||||
}
|
||||
type result struct {
|
||||
body string
|
||||
@ -413,8 +445,7 @@ func TestHTTPHandlerGatewayOpenClawHandlesFiveConcurrentE2ECases(t *testing.T) {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
body := fmt.Sprintf(
|
||||
`{"jsonrpc":"2.0","id":"e2e-%d","method":"session.start","params":{"sessionId":"e2e-s%d","openclawSessionKey":"e2e-t%d","threadId":"e2e-t%d","taskPrompt":%q,"workingDirectory":%q,"routing":{"routingMode":"explicit","explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}`,
|
||||
index,
|
||||
`{"jsonrpc":"2.0","id":"e2e-%d","method":"session.start","params":{"sessionId":"e2e-s%d","threadId":"e2e-t%d","taskPrompt":%q,"workingDirectory":%q,"routing":{"routingMode":"explicit","explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}`,
|
||||
index,
|
||||
index,
|
||||
index,
|
||||
@ -499,12 +530,12 @@ func TestHTTPHandlerGatewayOpenClawHandlesFiveConcurrentE2ECases(t *testing.T) {
|
||||
if got := gateway.ChatSendCount(); got != expectedGatewayTurns {
|
||||
t.Fatalf("expected five primary chat.send calls without model repair turns, got %d", got)
|
||||
}
|
||||
if got := gateway.AgentWaitCount(); got != 0 {
|
||||
t.Fatalf("expected task polling to use native task-registry without Bridge-owned agent.wait, got %d", got)
|
||||
if got := gateway.AgentWaitCount(); got != expectedGatewayTurns {
|
||||
t.Fatalf("expected five primary agent.wait calls without model repair turns, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPHandlerGatewayOpenClawAdmissionDoesNotHoldAcceptedNativeTasks(t *testing.T) {
|
||||
func TestHTTPHandlerGatewayOpenClawAdmissionRejectsWhenQueueFull(t *testing.T) {
|
||||
gateway := newAcpFakeOpenClawGateway(t)
|
||||
defer gateway.Close()
|
||||
gateway.agentWaitDelayMs.Store(300)
|
||||
@ -522,7 +553,7 @@ func TestHTTPHandlerGatewayOpenClawAdmissionDoesNotHoldAcceptedNativeTasks(t *te
|
||||
firstRequest, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
httpServer.URL+"/acp/rpc",
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":"task-active","method":"session.start","params":{"sessionId":"active","openclawSessionKey":"active","threadId":"active","taskPrompt":"Reply pong","workingDirectory":"`+t.TempDir()+`","routing":{"routingMode":"explicit","explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}`),
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":"task-active","method":"session.start","params":{"sessionId":"active","threadId":"active","taskPrompt":"Reply pong","workingDirectory":"`+t.TempDir()+`","routing":{"routingMode":"explicit","explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}`),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("build first request: %v", err)
|
||||
@ -546,7 +577,7 @@ func TestHTTPHandlerGatewayOpenClawAdmissionDoesNotHoldAcceptedNativeTasks(t *te
|
||||
secondRequest, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
httpServer.URL+"/acp/rpc",
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":"task-rejected","method":"session.start","params":{"sessionId":"rejected","openclawSessionKey":"rejected","threadId":"rejected","taskPrompt":"Reply pong","workingDirectory":"`+t.TempDir()+`","routing":{"routingMode":"explicit","explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}`),
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":"task-rejected","method":"session.start","params":{"sessionId":"rejected","threadId":"rejected","taskPrompt":"Reply pong","workingDirectory":"`+t.TempDir()+`","routing":{"routingMode":"explicit","explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}`),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("build second request: %v", err)
|
||||
@ -564,13 +595,14 @@ func TestHTTPHandlerGatewayOpenClawAdmissionDoesNotHoldAcceptedNativeTasks(t *te
|
||||
t.Fatalf("read second response: %v", err)
|
||||
}
|
||||
bodyText := string(body)
|
||||
envelope := sseFirstResultEnvelope(t, bodyText)
|
||||
result := shared.AsMap(envelope["result"])
|
||||
if result["status"] != "running" {
|
||||
t.Fatalf("expected second request to receive running handle after first native chat was accepted, got %s", bodyText)
|
||||
if !strings.Contains(bodyText, openClawGatewayBusyErrorCode) {
|
||||
t.Fatalf("expected busy error, got %s", bodyText)
|
||||
}
|
||||
if got := gateway.ChatSendCount(); got != 2 {
|
||||
t.Fatalf("accepted native task must not hold admission slot, got %d chat.send calls", got)
|
||||
if strings.Contains(bodyText, `"result"`) {
|
||||
t.Fatalf("busy response must not return a result envelope: %s", bodyText)
|
||||
}
|
||||
if got := gateway.ChatSendCount(); got != 1 {
|
||||
t.Fatalf("rejected request must not reach chat.send, got %d", got)
|
||||
}
|
||||
if err := <-firstDone; err != nil {
|
||||
t.Fatalf("first request failed: %v", err)
|
||||
@ -593,7 +625,7 @@ func TestHTTPHandlerGatewayOpenClawFiltersRawGatewayEventsAndKeepsFinalResult(t
|
||||
request, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
httpServer.URL+"/acp/rpc",
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":"task-filter","method":"session.start","params":{"sessionId":"session-filter","openclawSessionKey":"thread-filter","threadId":"thread-filter","taskPrompt":"make artifact","workingDirectory":"`+t.TempDir()+`","routing":{"routingMode":"explicit","explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}`),
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":"task-filter","method":"session.start","params":{"sessionId":"session-filter","threadId":"thread-filter","taskPrompt":"make artifact","workingDirectory":"`+t.TempDir()+`","routing":{"routingMode":"explicit","explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}`),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("build request: %v", err)
|
||||
@ -688,8 +720,8 @@ func TestHTTPHandlerGatewayOpenClawFiltersRawGatewayEventsAndKeepsFinalResult(t
|
||||
if !strings.Contains(fmt.Sprint(result), openClawArtifactDownloadPath) {
|
||||
t.Fatalf("expected normalized artifact download URL in task result, got %#v", result)
|
||||
}
|
||||
if got := gateway.Methods(); !sameMethods(got, []string{"connect", "xworkmate.session.prepare", "chat.send", "xworkmate.tasks.get"}) {
|
||||
t.Fatalf("expected prepare, chat.send, then native task lookup, got %#v", got)
|
||||
if got := gateway.Methods(); !sameMethods(got, []string{"connect", "xworkmate.session.prepare", "chat.send", "agent.wait", "xworkmate.artifacts.export", "xworkmate.artifacts.collect-and-snapshot"}) {
|
||||
t.Fatalf("expected artifact workflow methods to prepare before chat.send, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@ -707,7 +739,7 @@ func TestHTTPHandlerGatewayOpenClawForcesGatewayRouting(t *testing.T) {
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"http://127.0.0.1/acp/rpc",
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":"task-1","method":"session.start","params":{"sessionId":"s1","openclawSessionKey":"t1","threadId":"t1","taskPrompt":"Reply pong","workingDirectory":"`+t.TempDir()+`","routing":{"routingMode":"explicit","explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}`),
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":"task-1","method":"session.start","params":{"sessionId":"s1","threadId":"t1","taskPrompt":"Reply pong","workingDirectory":"`+t.TempDir()+`","routing":{"routingMode":"explicit","explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}`),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Authorization", "Bearer bridge-test-token")
|
||||
@ -734,8 +766,8 @@ func TestHTTPHandlerGatewayOpenClawForcesGatewayRouting(t *testing.T) {
|
||||
if got := result["status"]; got != "completed" {
|
||||
t.Fatalf("expected completed task result, got %#v", result)
|
||||
}
|
||||
if gateway.AgentWaitCount() != 0 {
|
||||
t.Fatalf("expected native task-registry lookup without Bridge-owned agent.wait, got %d", gateway.AgentWaitCount())
|
||||
if gateway.AgentWaitCount() != 1 {
|
||||
t.Fatalf("expected one OpenClaw agent.wait, got %d", gateway.AgentWaitCount())
|
||||
}
|
||||
}
|
||||
|
||||
@ -753,7 +785,7 @@ func TestHTTPHandlerTasksGetReturnsCompletedOpenClawResult(t *testing.T) {
|
||||
startRequest := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"http://127.0.0.1/acp/rpc",
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":"task-1","method":"session.start","params":{"sessionId":"s1","openclawSessionKey":"t1","threadId":"t1","taskPrompt":"Reply pong","workingDirectory":"`+t.TempDir()+`","routing":{"routingMode":"explicit","explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}`),
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":"task-1","method":"session.start","params":{"sessionId":"s1","threadId":"t1","taskPrompt":"Reply pong","workingDirectory":"`+t.TempDir()+`","routing":{"routingMode":"explicit","explicitExecutionTarget":"gateway","preferredGatewayProviderId":"openclaw"}}}`),
|
||||
)
|
||||
startRequest.Header.Set("Content-Type", "application/json")
|
||||
startRequest.Header.Set("Authorization", "Bearer bridge-test-token")
|
||||
@ -875,7 +907,6 @@ func (w *panicSSEWriter) Write(payload []byte) (int, error) {
|
||||
func (w *panicSSEWriter) WriteHeader(int) {}
|
||||
|
||||
func TestHTTPHandlerPingRequiresBearerAuthorizationWhenBridgeAuthTokenConfigured(t *testing.T) {
|
||||
t.Setenv("AI_WORKSPACE_AUTH_TOKEN", "")
|
||||
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-test-token")
|
||||
t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml")
|
||||
server := NewServer()
|
||||
@ -890,27 +921,7 @@ func TestHTTPHandlerPingRequiresBearerAuthorizationWhenBridgeAuthTokenConfigured
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPHandlerPingAcceptsAIWorkspaceBearerAuthorization(t *testing.T) {
|
||||
t.Setenv("AI_WORKSPACE_AUTH_TOKEN", "ai-workspace-test-token")
|
||||
t.Setenv("BRIDGE_AUTH_TOKEN", "")
|
||||
t.Setenv("BRIDGE_REVIEW_AUTH_TOKEN", "")
|
||||
t.Setenv("INTERNAL_SERVICE_TOKEN", "")
|
||||
t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml")
|
||||
server := NewServer()
|
||||
handler := server.Handler()
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "http://127.0.0.1/api/ping", nil)
|
||||
request.Header.Set("Authorization", "Bearer ai-workspace-test-token")
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 for AI workspace token, got %d", recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPHandlerPingAllowsReviewBearerAuthorizationWhenConfigured(t *testing.T) {
|
||||
t.Setenv("AI_WORKSPACE_AUTH_TOKEN", "ai-workspace-test-token")
|
||||
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-test-token")
|
||||
t.Setenv("BRIDGE_REVIEW_AUTH_TOKEN", "review-bridge-test-token")
|
||||
t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml")
|
||||
@ -971,10 +982,8 @@ func TestHandleRPCAllowsPreflightForConfiguredOrigin(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHandleRPCAllowsUnauthenticatedRequestsWhenBridgeAuthTokenUnset(t *testing.T) {
|
||||
t.Setenv("AI_WORKSPACE_AUTH_TOKEN", "")
|
||||
t.Setenv("BRIDGE_AUTH_TOKEN", "")
|
||||
t.Setenv("BRIDGE_REVIEW_AUTH_TOKEN", "")
|
||||
t.Setenv("INTERNAL_SERVICE_TOKEN", "")
|
||||
t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml")
|
||||
server := NewServer()
|
||||
recorder := httptest.NewRecorder()
|
||||
@ -993,7 +1002,6 @@ func TestHandleRPCAllowsUnauthenticatedRequestsWhenBridgeAuthTokenUnset(t *testi
|
||||
}
|
||||
|
||||
func TestHandleRPCRequiresBearerAuthorizationWhenBridgeAuthTokenConfigured(t *testing.T) {
|
||||
t.Setenv("AI_WORKSPACE_AUTH_TOKEN", "")
|
||||
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-test-token")
|
||||
t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml")
|
||||
server := NewServer()
|
||||
@ -1002,7 +1010,7 @@ func TestHandleRPCRequiresBearerAuthorizationWhenBridgeAuthTokenConfigured(t *te
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"http://127.0.0.1/acp/rpc",
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"session.start","params":{"sessionId":"test","openclawSessionKey":"test","threadId":"test"}}`),
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"session.start","params":{"sessionId":"test"}}`),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@ -1014,7 +1022,6 @@ func TestHandleRPCRequiresBearerAuthorizationWhenBridgeAuthTokenConfigured(t *te
|
||||
}
|
||||
|
||||
func TestHandleRPCCapabilitiesRequiresBearerAuthorizationWhenBridgeAuthTokenConfigured(t *testing.T) {
|
||||
t.Setenv("AI_WORKSPACE_AUTH_TOKEN", "")
|
||||
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-test-token")
|
||||
t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml")
|
||||
server := NewServer()
|
||||
@ -1034,7 +1041,6 @@ func TestHandleRPCCapabilitiesRequiresBearerAuthorizationWhenBridgeAuthTokenConf
|
||||
}
|
||||
|
||||
func TestHandleRPCAllowsReviewBearerAuthorizationWhenConfigured(t *testing.T) {
|
||||
t.Setenv("AI_WORKSPACE_AUTH_TOKEN", "ai-workspace-test-token")
|
||||
t.Setenv("BRIDGE_AUTH_TOKEN", "bridge-test-token")
|
||||
t.Setenv("BRIDGE_REVIEW_AUTH_TOKEN", "review-bridge-test-token")
|
||||
t.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml")
|
||||
@ -1243,7 +1249,7 @@ func TestHandleRPCSessionStartSucceedsWithExplicitProvider(t *testing.T) {
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"http://127.0.0.1/acp/rpc",
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":"task-1","method":"session.start","params":{"sessionId":"s1","openclawSessionKey":"t1","threadId":"t1","taskPrompt":"Reply with exactly pong","workingDirectory":"`+t.TempDir()+`","routing":{"routingMode":"explicit","explicitExecutionTarget":"singleAgent","explicitProviderId":"opencode"}}}`),
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":"task-1","method":"session.start","params":{"sessionId":"s1","threadId":"t1","taskPrompt":"Reply with exactly pong","workingDirectory":"`+t.TempDir()+`","routing":{"routingMode":"explicit","explicitExecutionTarget":"singleAgent","explicitProviderId":"opencode"}}}`),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Authorization", "Bearer bridge-test-token")
|
||||
|
||||
@ -97,8 +97,9 @@ func (xi *XdotoolInjector) Start() error {
|
||||
// Inject sends a command to the persistent xdotool process
|
||||
func (xi *XdotoolInjector) Inject(event InputEvent) error {
|
||||
xi.mu.Lock()
|
||||
defer xi.mu.Unlock()
|
||||
|
||||
if !xi.isStarted || xi.stdin == nil {
|
||||
xi.mu.Unlock()
|
||||
return fmt.Errorf("injector is not running")
|
||||
}
|
||||
|
||||
@ -109,7 +110,6 @@ func (xi *XdotoolInjector) Inject(event InputEvent) error {
|
||||
xi.moveMu.Lock()
|
||||
xi.pendingMove = &event
|
||||
xi.moveMu.Unlock()
|
||||
xi.mu.Unlock()
|
||||
return nil
|
||||
|
||||
case "mouse_down":
|
||||
@ -135,7 +135,6 @@ func (xi *XdotoolInjector) Inject(event InputEvent) error {
|
||||
}
|
||||
|
||||
default:
|
||||
xi.mu.Unlock()
|
||||
return fmt.Errorf("unsupported input type: %s", event.Type)
|
||||
}
|
||||
|
||||
@ -145,22 +144,14 @@ func (xi *XdotoolInjector) Inject(event InputEvent) error {
|
||||
// Try to restart if pipe is broken
|
||||
log.Printf("xdotool write error: %v. Attempting to restart injector.", err)
|
||||
xi.isStarted = false
|
||||
if xi.stdin != nil {
|
||||
_ = xi.stdin.Close()
|
||||
}
|
||||
xi.stdin = nil
|
||||
xi.cmd = nil
|
||||
xi.mu.Unlock()
|
||||
_ = xi.stdin.Close()
|
||||
if restartErr := xi.Start(); restartErr == nil {
|
||||
xi.mu.Lock()
|
||||
_, _ = xi.stdin.Write([]byte(cmdStr))
|
||||
xi.mu.Unlock()
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
xi.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@ -20,32 +20,6 @@ func normalizeRTPPort(port int) int {
|
||||
return port
|
||||
}
|
||||
|
||||
func normalizeVideoDimension(value, fallback int) int {
|
||||
if value <= 0 {
|
||||
value = fallback
|
||||
}
|
||||
if value%2 != 0 {
|
||||
value--
|
||||
}
|
||||
if value < 2 {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func normalizePipelineConfig(cfg PipelineConfig) PipelineConfig {
|
||||
cfg.Port = normalizeRTPPort(cfg.Port)
|
||||
cfg.Width = normalizeVideoDimension(cfg.Width, 1280)
|
||||
cfg.Height = normalizeVideoDimension(cfg.Height, 720)
|
||||
if cfg.FPS <= 0 {
|
||||
cfg.FPS = 30
|
||||
}
|
||||
if cfg.Bitrate <= 0 {
|
||||
cfg.Bitrate = 2000
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// PipelineManager manages the screen capture process lifecycle
|
||||
type PipelineManager struct {
|
||||
cmd *exec.Cmd
|
||||
@ -84,7 +58,19 @@ func (pm *PipelineManager) Start(cfg PipelineConfig) error {
|
||||
cfg.Display = ":0.0"
|
||||
}
|
||||
}
|
||||
cfg = normalizePipelineConfig(cfg)
|
||||
cfg.Port = normalizeRTPPort(cfg.Port)
|
||||
if cfg.Width <= 0 {
|
||||
cfg.Width = 1280
|
||||
}
|
||||
if cfg.Height <= 0 {
|
||||
cfg.Height = 720
|
||||
}
|
||||
if cfg.FPS <= 0 {
|
||||
cfg.FPS = 30
|
||||
}
|
||||
if cfg.Bitrate <= 0 {
|
||||
cfg.Bitrate = 2000
|
||||
}
|
||||
|
||||
tool, args, err := pm.resolvePipeline(cfg)
|
||||
if err != nil {
|
||||
@ -186,21 +172,15 @@ func (pm *PipelineManager) hasExecutable(name string) bool {
|
||||
}
|
||||
|
||||
func (pm *PipelineManager) buildGStreamer(cfg PipelineConfig) (string, []string, error) {
|
||||
cfg = normalizePipelineConfig(cfg)
|
||||
var pipelineParts []string
|
||||
|
||||
// 1. Capture Source (X11)
|
||||
pipelineParts = append(pipelineParts, fmt.Sprintf("ximagesrc display-name=%s", cfg.Display))
|
||||
pipelineParts = append(pipelineParts, fmt.Sprintf("video/x-raw,framerate=%d/1", cfg.FPS))
|
||||
pipelineParts = append(pipelineParts, "video/x-raw,framerate=30/1")
|
||||
pipelineParts = append(pipelineParts, "videoconvert")
|
||||
pipelineParts = append(pipelineParts, "videoscale")
|
||||
pipelineParts = append(
|
||||
pipelineParts,
|
||||
fmt.Sprintf("video/x-raw,format=I420,width=%d,height=%d,framerate=%d/1", cfg.Width, cfg.Height, cfg.FPS),
|
||||
)
|
||||
|
||||
// 2. Encoder
|
||||
encoderStr := "x264enc speed-preset=ultrafast tune=zerolatency bitrate=" + fmt.Sprintf("%d", cfg.Bitrate) + " byte-stream=true key-int-max=30"
|
||||
encoderStr := "x264enc speed-preset=ultrafast tune=zerolatency bitrate=" + fmt.Sprintf("%d", cfg.Bitrate)
|
||||
if cfg.UseGPU {
|
||||
// Detect if nvcodec is present by calling gst-inspect-1.0 or simply attempting it.
|
||||
// We'll default to nvh264enc.
|
||||
@ -209,7 +189,6 @@ func (pm *PipelineManager) buildGStreamer(cfg PipelineConfig) (string, []string,
|
||||
pipelineParts = append(pipelineParts, encoderStr)
|
||||
|
||||
// 3. Payload and Sink
|
||||
pipelineParts = append(pipelineParts, "video/x-h264,profile=baseline")
|
||||
pipelineParts = append(pipelineParts, "rtph264pay config-interval=1 pt=96")
|
||||
pipelineParts = append(pipelineParts, fmt.Sprintf("udpsink host=127.0.0.1 port=%d sync=false async=false", cfg.Port))
|
||||
|
||||
@ -230,7 +209,6 @@ func (pm *PipelineManager) buildGStreamer(cfg PipelineConfig) (string, []string,
|
||||
}
|
||||
|
||||
func (pm *PipelineManager) buildFFmpeg(cfg PipelineConfig) (string, []string, error) {
|
||||
cfg = normalizePipelineConfig(cfg)
|
||||
args := []string{
|
||||
"-f", "x11grab",
|
||||
"-draw_mouse", "1",
|
||||
@ -246,16 +224,12 @@ func (pm *PipelineManager) buildFFmpeg(cfg PipelineConfig) (string, []string, er
|
||||
"-preset", "llhp", // low latency high quality
|
||||
"-tune", "zerolatency",
|
||||
"-g", "30",
|
||||
"-pix_fmt", "yuv420p",
|
||||
)
|
||||
} else {
|
||||
args = append(args,
|
||||
"-c:v", "libx264",
|
||||
"-preset", "ultrafast",
|
||||
"-tune", "zerolatency",
|
||||
"-profile:v", "baseline",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-x264-params", "repeat-headers=1",
|
||||
"-g", "30",
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,85 +0,0 @@
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildGStreamerUsesBrowserFriendlyH264(t *testing.T) {
|
||||
pm := NewPipelineManager()
|
||||
|
||||
tool, args, err := pm.buildGStreamer(PipelineConfig{
|
||||
Display: ":11.0",
|
||||
Port: 5004,
|
||||
Bitrate: 2000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildGStreamer returned error: %v", err)
|
||||
}
|
||||
|
||||
if tool != "gst-launch-1.0" {
|
||||
t.Fatalf("expected gst-launch-1.0, got %q", tool)
|
||||
}
|
||||
joined := strings.Join(args, " ")
|
||||
for _, expected := range []string{
|
||||
"video/x-raw,format=I420",
|
||||
"width=1280",
|
||||
"height=720",
|
||||
"x264enc",
|
||||
"tune=zerolatency",
|
||||
"key-int-max=30",
|
||||
"byte-stream=true",
|
||||
"video/x-h264,profile=baseline",
|
||||
"rtph264pay",
|
||||
"config-interval=1",
|
||||
} {
|
||||
if !strings.Contains(joined, expected) {
|
||||
t.Fatalf("expected GStreamer args to contain %q, got %s", expected, joined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeVideoDimensionUsesEvenValues(t *testing.T) {
|
||||
if got := normalizeVideoDimension(1353, 1280); got != 1352 {
|
||||
t.Fatalf("expected odd dimension to be rounded down to 1352, got %d", got)
|
||||
}
|
||||
if got := normalizeVideoDimension(0, 720); got != 720 {
|
||||
t.Fatalf("expected fallback for zero dimension, got %d", got)
|
||||
}
|
||||
if got := normalizeVideoDimension(1, 720); got != 720 {
|
||||
t.Fatalf("expected fallback for too-small dimension, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFFmpegUsesBrowserFriendlyH264(t *testing.T) {
|
||||
pm := NewPipelineManager()
|
||||
|
||||
tool, args, err := pm.buildFFmpeg(PipelineConfig{
|
||||
Display: ":11.0",
|
||||
Port: 5004,
|
||||
Bitrate: 2000,
|
||||
FPS: 30,
|
||||
Width: 1280,
|
||||
Height: 720,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildFFmpeg returned error: %v", err)
|
||||
}
|
||||
|
||||
if tool != "ffmpeg" {
|
||||
t.Fatalf("expected ffmpeg, got %q", tool)
|
||||
}
|
||||
joined := strings.Join(args, " ")
|
||||
for _, expected := range []string{
|
||||
"-c:v libx264",
|
||||
"-tune zerolatency",
|
||||
"-profile:v baseline",
|
||||
"-pix_fmt yuv420p",
|
||||
"-x264-params repeat-headers=1",
|
||||
"-g 30",
|
||||
} {
|
||||
if !strings.Contains(joined, expected) {
|
||||
t.Fatalf("expected FFmpeg args to contain %q, got %s", expected, joined)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -11,7 +11,6 @@ import (
|
||||
type DesktopSession struct {
|
||||
SessionID string
|
||||
Port int
|
||||
Config PipelineConfig
|
||||
Pipeline *PipelineManager
|
||||
Injector *XdotoolInjector
|
||||
WebRTC *WebRTCServer
|
||||
@ -73,11 +72,18 @@ func (s *Service) StartSession(sessionID string, cfg PipelineConfig, iceServers
|
||||
return nil, fmt.Errorf("failed to start RTP receiver: %w", err)
|
||||
}
|
||||
|
||||
// 3. Initialize screen capture pipeline
|
||||
pipeline := NewPipelineManager()
|
||||
if err := pipeline.Start(cfg); err != nil {
|
||||
webrtcSrv.Close()
|
||||
_ = injector.Close()
|
||||
return nil, fmt.Errorf("failed to start capture pipeline: %w", err)
|
||||
}
|
||||
|
||||
sess := &DesktopSession{
|
||||
SessionID: sessionID,
|
||||
Port: cfg.Port,
|
||||
Config: cfg,
|
||||
Pipeline: NewPipelineManager(),
|
||||
Pipeline: pipeline,
|
||||
Injector: injector,
|
||||
WebRTC: webrtcSrv,
|
||||
}
|
||||
@ -86,23 +92,6 @@ func (s *Service) StartSession(sessionID string, cfg PipelineConfig, iceServers
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
func (s *Service) StartCapture(sessionID string) error {
|
||||
sess, err := s.GetSession(sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sess.Pipeline == nil {
|
||||
sess.Pipeline = NewPipelineManager()
|
||||
}
|
||||
if sess.Pipeline.IsRunning() {
|
||||
return nil
|
||||
}
|
||||
if err := sess.Pipeline.Start(sess.Config); err != nil {
|
||||
return fmt.Errorf("failed to start capture pipeline: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) GetSession(sessionID string) (*DesktopSession, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@ -5,20 +5,11 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
const (
|
||||
desktopReliableInputChannelLabel = "input"
|
||||
desktopMoveInputChannelLabel = "input-move"
|
||||
desktopICEGatheringTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
type WebRTCServer struct {
|
||||
peerConnection *webrtc.PeerConnection
|
||||
videoTrack *webrtc.TrackLocalStaticRTP
|
||||
@ -26,19 +17,6 @@ type WebRTCServer struct {
|
||||
inputInjector *XdotoolInjector
|
||||
mu sync.Mutex
|
||||
isClosed bool
|
||||
rtpPackets uint64
|
||||
rtpBytes uint64
|
||||
rtpWriteErrors uint64
|
||||
}
|
||||
|
||||
func desktopWebRTCDiagnosticsEnabled() bool {
|
||||
value := os.Getenv("XWORKMATE_DESKTOP_WEBRTC_DEBUG")
|
||||
switch value {
|
||||
case "0", "false", "FALSE", "off", "OFF", "no", "NO":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func NewWebRTCServer(injector *XdotoolInjector) (*WebRTCServer, error) {
|
||||
@ -94,24 +72,18 @@ func (w *WebRTCServer) InitPeerConnection(iceServers []string) error {
|
||||
// Handle Data Channel for inputs
|
||||
pc.OnDataChannel(func(d *webrtc.DataChannel) {
|
||||
log.Printf("Data channel '%s'-'%d' opened", d.Label(), d.ID())
|
||||
label := d.Label()
|
||||
if !isDesktopInputDataChannelLabel(label) {
|
||||
return
|
||||
if d.Label() == "input" {
|
||||
d.OnMessage(func(msg webrtc.DataChannelMessage) {
|
||||
var event InputEvent
|
||||
if err := json.Unmarshal(msg.Data, &event); err != nil {
|
||||
log.Printf("Failed to unmarshal input event: %v", err)
|
||||
return
|
||||
}
|
||||
if err := w.inputInjector.Inject(event); err != nil {
|
||||
log.Printf("Failed to inject input event: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
d.OnMessage(func(msg webrtc.DataChannelMessage) {
|
||||
var event InputEvent
|
||||
if err := json.Unmarshal(msg.Data, &event); err != nil {
|
||||
log.Printf("Failed to unmarshal input event: %v", err)
|
||||
return
|
||||
}
|
||||
if label == desktopMoveInputChannelLabel && event.Type != "mouse_move" {
|
||||
log.Printf("Ignoring non-mouse_move input event on %s channel: %s", label, event.Type)
|
||||
return
|
||||
}
|
||||
if err := w.inputInjector.Inject(event); err != nil {
|
||||
log.Printf("Failed to inject input event: %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
w.peerConnection = pc
|
||||
@ -120,10 +92,6 @@ func (w *WebRTCServer) InitPeerConnection(iceServers []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func isDesktopInputDataChannelLabel(label string) bool {
|
||||
return label == desktopReliableInputChannelLabel || label == desktopMoveInputChannelLabel
|
||||
}
|
||||
|
||||
// StartRTPReceiver listens on local UDP port for GStreamer RTP stream and forwards to WebRTC video track
|
||||
func (w *WebRTCServer) StartRTPReceiver(port int) error {
|
||||
addr := fmt.Sprintf("127.0.0.1:%d", port)
|
||||
@ -139,36 +107,6 @@ func (w *WebRTCServer) StartRTPReceiver(port int) error {
|
||||
go func() {
|
||||
buf := make([]byte, 2048)
|
||||
log.Printf("WebRTC RTP receiver listening on UDP %s", addr)
|
||||
statsDone := make(chan struct{})
|
||||
if desktopWebRTCDiagnosticsEnabled() {
|
||||
statsTicker := time.NewTicker(5 * time.Second)
|
||||
defer statsTicker.Stop()
|
||||
defer close(statsDone)
|
||||
go func() {
|
||||
var lastPackets uint64
|
||||
var lastBytes uint64
|
||||
for {
|
||||
select {
|
||||
case <-statsTicker.C:
|
||||
packets := atomic.LoadUint64(&w.rtpPackets)
|
||||
bytes := atomic.LoadUint64(&w.rtpBytes)
|
||||
errors := atomic.LoadUint64(&w.rtpWriteErrors)
|
||||
log.Printf(
|
||||
"WebRTC RTP stats: packets=%d bytes=%d packetDelta=%d byteDelta=%d writeErrors=%d",
|
||||
packets,
|
||||
bytes,
|
||||
packets-lastPackets,
|
||||
bytes-lastBytes,
|
||||
errors,
|
||||
)
|
||||
lastPackets = packets
|
||||
lastBytes = bytes
|
||||
case <-statsDone:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
for {
|
||||
n, _, err := conn.ReadFrom(buf)
|
||||
if err != nil {
|
||||
@ -180,8 +118,6 @@ func (w *WebRTCServer) StartRTPReceiver(port int) error {
|
||||
}
|
||||
break
|
||||
}
|
||||
atomic.AddUint64(&w.rtpPackets, 1)
|
||||
atomic.AddUint64(&w.rtpBytes, uint64(n))
|
||||
|
||||
// Forward packet directly to WebRTC track (zero-copy)
|
||||
w.mu.Lock()
|
||||
@ -190,7 +126,6 @@ func (w *WebRTCServer) StartRTPReceiver(port int) error {
|
||||
|
||||
if track != nil {
|
||||
if _, err := track.Write(buf[:n]); err != nil {
|
||||
atomic.AddUint64(&w.rtpWriteErrors, 1)
|
||||
log.Printf("Failed to write RTP packet to track: %v", err)
|
||||
}
|
||||
}
|
||||
@ -231,9 +166,7 @@ func (w *WebRTCServer) ProcessOffer(sdpOffer string) (string, error) {
|
||||
return "", fmt.Errorf("failed to set local description: %w", err)
|
||||
}
|
||||
|
||||
if err := waitForICEGatheringComplete(gatherComplete, desktopICEGatheringTimeout); err != nil {
|
||||
return "", err
|
||||
}
|
||||
<-gatherComplete
|
||||
|
||||
localDesc := pc.LocalDescription()
|
||||
if localDesc == nil {
|
||||
@ -243,15 +176,6 @@ func (w *WebRTCServer) ProcessOffer(sdpOffer string) (string, error) {
|
||||
return localDesc.SDP, nil
|
||||
}
|
||||
|
||||
func waitForICEGatheringComplete(done <-chan struct{}, timeout time.Duration) error {
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case <-time.After(timeout):
|
||||
return fmt.Errorf("timed out waiting for ICE gathering after %s", timeout)
|
||||
}
|
||||
}
|
||||
|
||||
// AddICECandidate adds a remote ICE candidate
|
||||
func (w *WebRTCServer) AddICECandidate(candidate webrtc.ICECandidateInit) error {
|
||||
w.mu.Lock()
|
||||
@ -290,10 +214,4 @@ func (w *WebRTCServer) Close() {
|
||||
if pc != nil {
|
||||
_ = pc.Close()
|
||||
}
|
||||
log.Printf(
|
||||
"WebRTC RTP final stats: packets=%d bytes=%d writeErrors=%d",
|
||||
atomic.LoadUint64(&w.rtpPackets),
|
||||
atomic.LoadUint64(&w.rtpBytes),
|
||||
atomic.LoadUint64(&w.rtpWriteErrors),
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,40 +0,0 @@
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestIsDesktopInputDataChannelLabelAllowsReliableAndMoveChannels(t *testing.T) {
|
||||
if !isDesktopInputDataChannelLabel(desktopReliableInputChannelLabel) {
|
||||
t.Fatalf("expected reliable input channel label to be accepted")
|
||||
}
|
||||
if !isDesktopInputDataChannelLabel(desktopMoveInputChannelLabel) {
|
||||
t.Fatalf("expected mouse move input channel label to be accepted")
|
||||
}
|
||||
if isDesktopInputDataChannelLabel("debug") {
|
||||
t.Fatalf("expected unrelated data channel label to be ignored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForICEGatheringCompleteReturnsWhenDoneCloses(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
close(done)
|
||||
|
||||
if err := waitForICEGatheringComplete(done, time.Second); err != nil {
|
||||
t.Fatalf("expected closed gathering channel to succeed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForICEGatheringCompleteTimesOut(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
|
||||
start := time.Now()
|
||||
err := waitForICEGatheringComplete(done, 10*time.Millisecond)
|
||||
if err == nil {
|
||||
t.Fatalf("expected timeout error")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > time.Second {
|
||||
t.Fatalf("timeout helper waited too long: %s", elapsed)
|
||||
}
|
||||
}
|
||||
@ -360,11 +360,7 @@ func sameConnectTarget(current ConnectRequest, next ConnectRequest) bool {
|
||||
strings.TrimSpace(current.Endpoint.Host) == strings.TrimSpace(next.Endpoint.Host) &&
|
||||
current.Endpoint.Port == next.Endpoint.Port &&
|
||||
current.Endpoint.TLS == next.Endpoint.TLS &&
|
||||
normalizeEndpointPath(current.Endpoint.Path) == normalizeEndpointPath(next.Endpoint.Path) &&
|
||||
strings.TrimSpace(current.Identity.DeviceID) == strings.TrimSpace(next.Identity.DeviceID) &&
|
||||
strings.TrimSpace(current.Auth.Token) == strings.TrimSpace(next.Auth.Token) &&
|
||||
strings.TrimSpace(current.Auth.DeviceToken) == strings.TrimSpace(next.Auth.DeviceToken) &&
|
||||
strings.TrimSpace(current.Auth.Password) == strings.TrimSpace(next.Auth.Password)
|
||||
normalizeEndpointPath(current.Endpoint.Path) == normalizeEndpointPath(next.Endpoint.Path)
|
||||
}
|
||||
|
||||
func (s *session) connectAttempt() (ConnectResult, *GatewayError) {
|
||||
@ -416,6 +412,11 @@ func (s *session) connectAttempt() (ConnectResult, *GatewayError) {
|
||||
snapshotPayload := asMap(payload["snapshot"])
|
||||
sessionDefaults := asMap(snapshotPayload["sessionDefaults"])
|
||||
returnedDeviceToken := strings.TrimSpace(stringValue(auth["deviceToken"]))
|
||||
if returnedDeviceToken != "" {
|
||||
s.mu.Lock()
|
||||
s.config.Auth.DeviceToken = returnedDeviceToken
|
||||
s.mu.Unlock()
|
||||
}
|
||||
negotiatedScopes := stringSlice(auth["scopes"])
|
||||
negotiatedRole := strings.TrimSpace(stringValue(auth["role"]))
|
||||
if negotiatedRole == "" {
|
||||
|
||||
50
internal/handler/auth_handler.go
Normal file
50
internal/handler/auth_handler.go
Normal file
@ -0,0 +1,50 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"xworkmate-bridge/internal/service"
|
||||
)
|
||||
|
||||
type Authenticator interface {
|
||||
Authenticate(username, password string) error
|
||||
}
|
||||
|
||||
type AuthHandler struct {
|
||||
service Authenticator
|
||||
}
|
||||
|
||||
func NewAuthHandler(svc Authenticator) *AuthHandler {
|
||||
return &AuthHandler{service: svc}
|
||||
}
|
||||
|
||||
func NewServiceAdapter(svc *service.AuthService) Authenticator {
|
||||
return authServiceAdapter{service: svc}
|
||||
}
|
||||
|
||||
func (h *AuthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
var payload struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := h.service.Authenticate(payload.Username, payload.Password); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
type authServiceAdapter struct {
|
||||
service *service.AuthService
|
||||
}
|
||||
|
||||
func (a authServiceAdapter) Authenticate(username, password string) error {
|
||||
return a.service.Authenticate(context.TODO(), username, password)
|
||||
}
|
||||
53
internal/handler/auth_handler_test.go
Normal file
53
internal/handler/auth_handler_test.go
Normal file
@ -0,0 +1,53 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeAuthenticator struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakeAuthenticator) Authenticate(username, password string) error {
|
||||
return f.err
|
||||
}
|
||||
|
||||
func TestAuthHandlerRejectsInvalidJSON(t *testing.T) {
|
||||
handler := NewAuthHandler(fakeAuthenticator{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/auth", bytes.NewBufferString("{"))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandlerReturnsUnauthorizedOnServiceFailure(t *testing.T) {
|
||||
handler := NewAuthHandler(fakeAuthenticator{err: errors.New("invalid credentials")})
|
||||
req := httptest.NewRequest(http.MethodPost, "/auth", bytes.NewBufferString(`{"username":"alice","password":"secret"}`))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandlerReturnsOKOnSuccess(t *testing.T) {
|
||||
handler := NewAuthHandler(fakeAuthenticator{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/auth", bytes.NewBufferString(`{"username":"alice","password":"secret"}`))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
40
internal/handler/token_auth_handler.go
Normal file
40
internal/handler/token_auth_handler.go
Normal file
@ -0,0 +1,40 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"xworkmate-bridge/internal/service"
|
||||
"xworkmate-bridge/internal/shared"
|
||||
)
|
||||
|
||||
type TokenAuthHandler struct {
|
||||
service *service.StaticTokenAuthService
|
||||
}
|
||||
|
||||
func NewTokenAuthHandler(service *service.StaticTokenAuthService) *TokenAuthHandler {
|
||||
return &TokenAuthHandler{service: service}
|
||||
}
|
||||
|
||||
func (h *TokenAuthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if h.service == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_ = json.NewEncoder(w).Encode(shared.ErrorEnvelope(nil, -32000, "auth service unavailable"))
|
||||
return
|
||||
}
|
||||
token := r.Header.Get("Authorization")
|
||||
if h.service.ValidateAuthorizationHeader(token) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"ok": true,
|
||||
"type": "res",
|
||||
"payload": map[string]any{"authenticated": true},
|
||||
})
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_ = json.NewEncoder(w).Encode(shared.ErrorEnvelope(nil, -32001, "unauthorized"))
|
||||
}
|
||||
34
internal/handler/token_auth_handler_test.go
Normal file
34
internal/handler/token_auth_handler_test.go
Normal file
@ -0,0 +1,34 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"xworkmate-bridge/internal/service"
|
||||
)
|
||||
|
||||
func TestTokenAuthHandlerServeHTTP(t *testing.T) {
|
||||
h := NewTokenAuthHandler(service.NewStaticTokenAuthService("secret"))
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("Authorization", "secret")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenAuthHandlerRejectsUnauthorized(t *testing.T) {
|
||||
h := NewTokenAuthHandler(service.NewStaticTokenAuthService("secret"))
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
146
internal/mounts/config.go
Normal file
146
internal/mounts/config.go
Normal file
@ -0,0 +1,146 @@
|
||||
package mounts
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
codexManagedMCPBlockStart = "# BEGIN XWORKMATE MANAGED MCP BLOCK"
|
||||
codexManagedMCPBlockEnd = "# END XWORKMATE MANAGED MCP BLOCK"
|
||||
opencodeManagedMCPBlockStart = "# BEGIN XWORKMATE MANAGED MCP BLOCK"
|
||||
opencodeManagedMCPBlockEnd = "# END XWORKMATE MANAGED MCP BLOCK"
|
||||
)
|
||||
|
||||
var mcpServerSectionPattern = regexp.MustCompile(
|
||||
`(?m)^\[mcp_servers\.[^\]]+\]`,
|
||||
)
|
||||
|
||||
func countMCPSections(content string) int {
|
||||
return len(mcpServerSectionPattern.FindAllStringIndex(content, -1))
|
||||
}
|
||||
|
||||
func defaultCodexHome() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || strings.TrimSpace(home) == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(home, ".codex")
|
||||
}
|
||||
|
||||
func defaultOpencodeHome() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || strings.TrimSpace(home) == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(home, ".opencode")
|
||||
}
|
||||
|
||||
func defaultOpenClawHome() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || strings.TrimSpace(home) == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(home, ".openclaw")
|
||||
}
|
||||
|
||||
func stripManagedBlock(content, startMarker, endMarker string) string {
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return content
|
||||
}
|
||||
|
||||
remaining := content
|
||||
for {
|
||||
start := strings.Index(remaining, startMarker)
|
||||
if start < 0 {
|
||||
break
|
||||
}
|
||||
end := strings.Index(remaining[start:], endMarker)
|
||||
if end < 0 {
|
||||
remaining = remaining[:start]
|
||||
break
|
||||
}
|
||||
end += start
|
||||
remaining = remaining[:start] + remaining[end+len(endMarker):]
|
||||
}
|
||||
return remaining
|
||||
}
|
||||
|
||||
func mergeManagedBlock(content, block, startMarker, endMarker string) string {
|
||||
preserved := strings.TrimRight(
|
||||
stripManagedBlock(content, startMarker, endMarker),
|
||||
"\n",
|
||||
)
|
||||
if preserved == "" {
|
||||
return block + "\n"
|
||||
}
|
||||
return preserved + "\n\n" + block + "\n"
|
||||
}
|
||||
|
||||
func buildCodexManagedMCPBlock(servers []ManagedMCPServer) string {
|
||||
var buffer strings.Builder
|
||||
buffer.WriteString(codexManagedMCPBlockStart)
|
||||
buffer.WriteString("\n# Generated by XWorkmate - Managed MCP Server Configuration\n")
|
||||
_, _ = fmt.Fprintf(&buffer, "# Last updated: %s\n\n", time.Now().Format(time.RFC3339Nano))
|
||||
for _, server := range servers {
|
||||
_, _ = fmt.Fprintf(&buffer, "[mcp_servers.%s]\n", server.ID)
|
||||
_, _ = fmt.Fprintf(&buffer, "command = %q\n", server.Command)
|
||||
if len(server.Args) > 0 {
|
||||
_, _ = fmt.Fprintf(&buffer, "args = %s\n", formatTOMLArray(server.Args))
|
||||
}
|
||||
buffer.WriteString("\n")
|
||||
}
|
||||
buffer.WriteString(codexManagedMCPBlockEnd)
|
||||
return strings.TrimRight(buffer.String(), "\n")
|
||||
}
|
||||
|
||||
func buildOpencodeManagedMCPBlock(servers []ManagedMCPServer) string {
|
||||
var buffer strings.Builder
|
||||
buffer.WriteString(opencodeManagedMCPBlockStart)
|
||||
buffer.WriteString("\n# Generated by XWorkmate - Managed MCP Server Configuration\n")
|
||||
_, _ = fmt.Fprintf(&buffer, "# Last updated: %s\n\n", time.Now().Format(time.RFC3339Nano))
|
||||
for _, server := range servers {
|
||||
_, _ = fmt.Fprintf(&buffer, "[mcp_servers.%s]\n", server.ID)
|
||||
if strings.TrimSpace(server.URL) != "" {
|
||||
_, _ = fmt.Fprintf(&buffer, "url = %q\n", strings.TrimSpace(server.URL))
|
||||
} else {
|
||||
buffer.WriteString("type = \"stdio\"\n")
|
||||
_, _ = fmt.Fprintf(&buffer, "command = %q\n", server.Command)
|
||||
if len(server.Args) > 0 {
|
||||
_, _ = fmt.Fprintf(&buffer, "args = %s\n", formatTOMLArray(server.Args))
|
||||
}
|
||||
}
|
||||
buffer.WriteString("\n")
|
||||
}
|
||||
buffer.WriteString(opencodeManagedMCPBlockEnd)
|
||||
return strings.TrimRight(buffer.String(), "\n")
|
||||
}
|
||||
|
||||
func formatTOMLArray(items []string) string {
|
||||
if len(items) == 0 {
|
||||
return "[]"
|
||||
}
|
||||
var quoted []string
|
||||
for _, item := range items {
|
||||
quoted = append(quoted, fmt.Sprintf("%q", item))
|
||||
}
|
||||
return "[" + strings.Join(quoted, ", ") + "]"
|
||||
}
|
||||
|
||||
func applyManagedBlock(configPath, block, startMarker, endMarker string) error {
|
||||
configDir := filepath.Dir(configPath)
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(configPath)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
merged := mergeManagedBlock(string(content), block, startMarker, endMarker)
|
||||
return os.WriteFile(configPath, []byte(merged), 0o644)
|
||||
}
|
||||
444
internal/mounts/reconcile.go
Normal file
444
internal/mounts/reconcile.go
Normal file
@ -0,0 +1,444 @@
|
||||
package mounts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ManagedMCPServer struct {
|
||||
ID string
|
||||
Name string
|
||||
Transport string
|
||||
Command string
|
||||
URL string
|
||||
Args []string
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
AutoSync bool
|
||||
UsesAris bool
|
||||
ManagedMCPServers []ManagedMCPServer
|
||||
}
|
||||
|
||||
type ArisInput struct {
|
||||
Available bool
|
||||
BundleVersion string
|
||||
LLMChatServerPath string
|
||||
SkillCount int
|
||||
BridgeAvailable bool
|
||||
Error string
|
||||
}
|
||||
|
||||
type Request struct {
|
||||
Config Config
|
||||
AIGatewayURL string
|
||||
ConfiguredCodexCLIPath string
|
||||
CodexHome string
|
||||
OpencodeHome string
|
||||
OpenClawHome string
|
||||
Aris ArisInput
|
||||
}
|
||||
|
||||
type MountTargetState struct {
|
||||
TargetID string
|
||||
Label string
|
||||
Available bool
|
||||
SupportsSkills bool
|
||||
SupportsMCP bool
|
||||
SupportsAIGatewayInjection bool
|
||||
DiscoveryState string
|
||||
SyncState string
|
||||
DiscoveredSkillCount int
|
||||
DiscoveredMCPCount int
|
||||
ManagedMCPCount int
|
||||
Detail string
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
MountTargets []MountTargetState
|
||||
ArisBundleVersion string
|
||||
ArisCompatStatus string
|
||||
}
|
||||
|
||||
func Reconcile(request Request) Result {
|
||||
states := []MountTargetState{
|
||||
reconcileAris(request.Config, request.Aris),
|
||||
reconcileCodex(
|
||||
request.Config,
|
||||
request.AIGatewayURL,
|
||||
request.ConfiguredCodexCLIPath,
|
||||
request.CodexHome,
|
||||
),
|
||||
reconcileCLIListTarget(
|
||||
request.Config,
|
||||
"claude",
|
||||
"Claude",
|
||||
[]string{"claude", "mcp", "list"},
|
||||
),
|
||||
reconcileCLIListTarget(
|
||||
request.Config,
|
||||
"gemini",
|
||||
"Gemini",
|
||||
[]string{"gemini", "mcp", "list"},
|
||||
),
|
||||
reconcileOpencode(request.Config, request.OpencodeHome),
|
||||
reconcileOpenClaw(request.Config, request.OpenClawHome),
|
||||
}
|
||||
|
||||
result := Result{
|
||||
MountTargets: states,
|
||||
ArisBundleVersion: strings.TrimSpace(request.Aris.BundleVersion),
|
||||
ArisCompatStatus: "idle",
|
||||
}
|
||||
for _, state := range states {
|
||||
if state.TargetID == "aris" {
|
||||
result.ArisCompatStatus = state.SyncState
|
||||
break
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func ResultMap(result Result) map[string]any {
|
||||
rawTargets := make([]map[string]any, 0, len(result.MountTargets))
|
||||
for _, target := range result.MountTargets {
|
||||
rawTargets = append(rawTargets, map[string]any{
|
||||
"targetId": target.TargetID,
|
||||
"label": target.Label,
|
||||
"available": target.Available,
|
||||
"supportsSkills": target.SupportsSkills,
|
||||
"supportsMcp": target.SupportsMCP,
|
||||
"supportsAiGatewayInjection": target.SupportsAIGatewayInjection,
|
||||
"discoveryState": target.DiscoveryState,
|
||||
"syncState": target.SyncState,
|
||||
"discoveredSkillCount": target.DiscoveredSkillCount,
|
||||
"discoveredMcpCount": target.DiscoveredMCPCount,
|
||||
"managedMcpCount": target.ManagedMCPCount,
|
||||
"detail": target.Detail,
|
||||
})
|
||||
}
|
||||
return map[string]any{
|
||||
"mountTargets": rawTargets,
|
||||
"arisBundleVersion": result.ArisBundleVersion,
|
||||
"arisCompatStatus": result.ArisCompatStatus,
|
||||
}
|
||||
}
|
||||
|
||||
func reconcileAris(config Config, input ArisInput) MountTargetState {
|
||||
state := placeholderState("aris", "ARIS", true, true, false)
|
||||
if strings.TrimSpace(input.Error) != "" {
|
||||
state.Available = false
|
||||
state.DiscoveryState = "error"
|
||||
state.SyncState = "error"
|
||||
state.Detail = strings.TrimSpace(input.Error)
|
||||
return state
|
||||
}
|
||||
if !input.Available {
|
||||
state.DiscoveryState = "missing"
|
||||
state.SyncState = "missing"
|
||||
state.Detail = "Embedded ARIS bundle is unavailable."
|
||||
return state
|
||||
}
|
||||
|
||||
state.Available = true
|
||||
state.DiscoveryState = "ready"
|
||||
state.DiscoveredSkillCount = input.SkillCount
|
||||
llmChatReady := strings.TrimSpace(input.LLMChatServerPath) != ""
|
||||
if config.UsesAris && llmChatReady && input.BridgeAvailable {
|
||||
state.SyncState = "ready"
|
||||
state.DiscoveredMCPCount = 1
|
||||
state.ManagedMCPCount = 1
|
||||
state.Detail = "Embedded bundle " +
|
||||
strings.TrimSpace(input.BundleVersion) +
|
||||
" ready; XWorkmate Go core manages llm-chat and claude-review."
|
||||
return state
|
||||
}
|
||||
state.SyncState = "embedded"
|
||||
if llmChatReady {
|
||||
state.DiscoveredMCPCount = 1
|
||||
}
|
||||
if llmChatReady {
|
||||
state.Detail = "Embedded bundle extracted, but the XWorkmate Go core is not available yet."
|
||||
} else {
|
||||
state.Detail = "Embedded bundle extracted, but llm-chat metadata is missing."
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
func reconcileCodex(
|
||||
config Config,
|
||||
aiGatewayURL string,
|
||||
configuredCodexCLIPath string,
|
||||
codexHome string,
|
||||
) MountTargetState {
|
||||
state := placeholderState("codex", "Codex", true, true, true)
|
||||
available := codexAvailable(configuredCodexCLIPath)
|
||||
configHome := strings.TrimSpace(codexHome)
|
||||
if configHome == "" {
|
||||
configHome = defaultCodexHome()
|
||||
}
|
||||
configPath := filepath.Join(configHome, "config.toml")
|
||||
content, _ := os.ReadFile(configPath)
|
||||
discovered := countMCPSections(string(content))
|
||||
managedServers := enabledCodexServers(config.ManagedMCPServers)
|
||||
if available && config.AutoSync && len(managedServers) > 0 {
|
||||
_ = applyManagedBlock(
|
||||
configPath,
|
||||
buildCodexManagedMCPBlock(managedServers),
|
||||
codexManagedMCPBlockStart,
|
||||
codexManagedMCPBlockEnd,
|
||||
)
|
||||
}
|
||||
state.Available = available
|
||||
if available {
|
||||
state.DiscoveryState = "ready"
|
||||
} else {
|
||||
state.DiscoveryState = "missing"
|
||||
}
|
||||
switch {
|
||||
case !available:
|
||||
state.SyncState = "missing"
|
||||
case config.AutoSync:
|
||||
state.SyncState = "ready"
|
||||
default:
|
||||
state.SyncState = "disabled"
|
||||
}
|
||||
state.DiscoveredMCPCount = discovered
|
||||
state.ManagedMCPCount = len(managedServers)
|
||||
state.Detail = "Codex is exposed through the bridge control plane.\n" +
|
||||
"Canonical WebSocket endpoint: https://xworkmate-bridge.svc.plus/acp\n" +
|
||||
"Secondary HTTP RPC endpoint: https://xworkmate-bridge.svc.plus/acp/rpc"
|
||||
return state
|
||||
}
|
||||
|
||||
func reconcileCLIListTarget(
|
||||
config Config,
|
||||
targetID string,
|
||||
label string,
|
||||
command []string,
|
||||
) MountTargetState {
|
||||
state := placeholderState(targetID, label, true, true, true)
|
||||
available := binaryExists(command[0])
|
||||
discovered := 0
|
||||
if available {
|
||||
discovered = countListedEntries(command)
|
||||
}
|
||||
state.Available = available
|
||||
if available {
|
||||
state.DiscoveryState = "ready"
|
||||
} else {
|
||||
state.DiscoveryState = "missing"
|
||||
}
|
||||
if available && config.AutoSync {
|
||||
state.SyncState = "launch-only"
|
||||
} else {
|
||||
state.SyncState = "disabled"
|
||||
}
|
||||
state.DiscoveredMCPCount = discovered
|
||||
state.ManagedMCPCount = len(enabledServers(config.ManagedMCPServers))
|
||||
|
||||
if targetID == "gemini" {
|
||||
state.Detail = "Gemini is exposed through the bridge control plane.\n" +
|
||||
"Canonical WebSocket endpoint: https://xworkmate-bridge.svc.plus/acp\n" +
|
||||
"Secondary HTTP RPC endpoint: https://xworkmate-bridge.svc.plus/acp/rpc"
|
||||
} else {
|
||||
state.Detail = "MCP discovery uses `" + strings.Join(command, " ") +
|
||||
"`; LLM API stays launch-scoped."
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
func reconcileOpencode(config Config, opencodeHome string) MountTargetState {
|
||||
state := placeholderState("opencode", "OpenCode", true, true, true)
|
||||
available := binaryExists("opencode")
|
||||
configHome := strings.TrimSpace(opencodeHome)
|
||||
if configHome == "" {
|
||||
configHome = defaultOpencodeHome()
|
||||
}
|
||||
configPath := filepath.Join(configHome, "config.toml")
|
||||
content, _ := os.ReadFile(configPath)
|
||||
discovered := countMCPSections(string(content))
|
||||
managedServers := enabledServers(config.ManagedMCPServers)
|
||||
if available && config.AutoSync && len(managedServers) > 0 {
|
||||
_ = applyManagedBlock(
|
||||
configPath,
|
||||
buildOpencodeManagedMCPBlock(managedServers),
|
||||
opencodeManagedMCPBlockStart,
|
||||
opencodeManagedMCPBlockEnd,
|
||||
)
|
||||
}
|
||||
state.Available = available
|
||||
if available {
|
||||
state.DiscoveryState = "ready"
|
||||
} else {
|
||||
state.DiscoveryState = "missing"
|
||||
}
|
||||
switch {
|
||||
case !available:
|
||||
state.SyncState = "missing"
|
||||
case config.AutoSync:
|
||||
state.SyncState = "ready"
|
||||
default:
|
||||
state.SyncState = "disabled"
|
||||
}
|
||||
state.DiscoveredMCPCount = discovered
|
||||
state.ManagedMCPCount = len(managedServers)
|
||||
state.Detail = "OpenCode is exposed through the bridge control plane.\n" +
|
||||
"Canonical WebSocket endpoint: https://xworkmate-bridge.svc.plus/acp\n" +
|
||||
"Secondary HTTP RPC endpoint: https://xworkmate-bridge.svc.plus/acp/rpc"
|
||||
return state
|
||||
}
|
||||
|
||||
func reconcileOpenClaw(config Config, openClawHome string) MountTargetState {
|
||||
state := placeholderState("openclaw", "OpenClaw", true, false, true)
|
||||
available := binaryExists("openclaw")
|
||||
state.Available = available
|
||||
if available {
|
||||
state.DiscoveryState = "ready"
|
||||
} else {
|
||||
state.DiscoveryState = "missing"
|
||||
}
|
||||
if available && config.AutoSync {
|
||||
state.SyncState = "launch-only"
|
||||
} else {
|
||||
state.SyncState = "disabled"
|
||||
}
|
||||
state.Detail = "OpenClaw acts as the host/control plane mount."
|
||||
|
||||
configHome := strings.TrimSpace(openClawHome)
|
||||
if configHome == "" {
|
||||
configHome = defaultOpenClawHome()
|
||||
}
|
||||
configPath := filepath.Join(configHome, "openclaw.json")
|
||||
if content, err := os.ReadFile(configPath); err == nil {
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(content, &decoded); err == nil {
|
||||
agents := 0
|
||||
if rawAgents, ok := decoded["agents"].(map[string]any); ok {
|
||||
if rawList, ok := rawAgents["list"].([]any); ok {
|
||||
agents = len(rawList)
|
||||
}
|
||||
}
|
||||
skillsDir := filepath.Join(configHome, "skills")
|
||||
if entries, err := os.ReadDir(skillsDir); err == nil {
|
||||
state.DiscoveredSkillCount = len(entries)
|
||||
}
|
||||
state.Detail = "agents: " + itoa(agents) + " · skills: " +
|
||||
itoa(state.DiscoveredSkillCount)
|
||||
} else {
|
||||
state.Detail = "OpenClaw config detected but could not be fully parsed."
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
func placeholderState(
|
||||
targetID string,
|
||||
label string,
|
||||
supportsSkills bool,
|
||||
supportsMCP bool,
|
||||
supportsAIGatewayInjection bool,
|
||||
) MountTargetState {
|
||||
return MountTargetState{
|
||||
TargetID: targetID,
|
||||
Label: label,
|
||||
SupportsSkills: supportsSkills,
|
||||
SupportsMCP: supportsMCP,
|
||||
SupportsAIGatewayInjection: supportsAIGatewayInjection,
|
||||
DiscoveryState: "idle",
|
||||
SyncState: "idle",
|
||||
}
|
||||
}
|
||||
|
||||
func codexAvailable(configuredPath string) bool {
|
||||
if strings.TrimSpace(configuredPath) != "" {
|
||||
if _, err := os.Stat(strings.TrimSpace(configuredPath)); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return binaryExists("codex")
|
||||
}
|
||||
|
||||
func binaryExists(command string) bool {
|
||||
_, err := exec.LookPath(command)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func countListedEntries(command []string) int {
|
||||
output := strings.TrimSpace(runCommand(command))
|
||||
if output == "" ||
|
||||
strings.Contains(output, "No MCP servers configured") ||
|
||||
strings.Contains(output, "No MCP servers configured yet") ||
|
||||
strings.Contains(output, "No MCP servers configured.") {
|
||||
return 0
|
||||
}
|
||||
lines := strings.Split(output, "\n")
|
||||
count := 0
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
switch {
|
||||
case trimmed == "":
|
||||
case strings.HasPrefix(trimmed, "Usage:"):
|
||||
case strings.HasPrefix(trimmed, "┌"):
|
||||
case strings.HasPrefix(trimmed, "│"):
|
||||
case strings.HasPrefix(trimmed, "└"):
|
||||
default:
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func runCommand(command []string) string {
|
||||
if len(command) == 0 {
|
||||
return ""
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, command[0], command[1:]...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil && len(output) == 0 {
|
||||
return ""
|
||||
}
|
||||
return string(output)
|
||||
}
|
||||
|
||||
func enabledServers(servers []ManagedMCPServer) []ManagedMCPServer {
|
||||
filtered := make([]ManagedMCPServer, 0, len(servers))
|
||||
for _, server := range servers {
|
||||
if !server.Enabled {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, server)
|
||||
}
|
||||
sort.SliceStable(filtered, func(i, j int) bool {
|
||||
return filtered[i].ID < filtered[j].ID
|
||||
})
|
||||
return filtered
|
||||
}
|
||||
|
||||
func enabledCodexServers(servers []ManagedMCPServer) []ManagedMCPServer {
|
||||
filtered := make([]ManagedMCPServer, 0, len(servers))
|
||||
for _, server := range servers {
|
||||
if !server.Enabled || strings.TrimSpace(server.Command) == "" {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, server)
|
||||
}
|
||||
sort.SliceStable(filtered, func(i, j int) bool {
|
||||
return filtered[i].ID < filtered[j].ID
|
||||
})
|
||||
return filtered
|
||||
}
|
||||
|
||||
func itoa(value int) string {
|
||||
return strconv.Itoa(value)
|
||||
}
|
||||
115
internal/mounts/reconcile_test.go
Normal file
115
internal/mounts/reconcile_test.go
Normal file
@ -0,0 +1,115 @@
|
||||
package mounts
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReconcileCodexAppliesManagedBlockAndPreservesUserEntries(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
configuredBinary := filepath.Join(tempDir, "custom-codex")
|
||||
if err := os.WriteFile(configuredBinary, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
|
||||
t.Fatalf("write configured binary: %v", err)
|
||||
}
|
||||
configPath := filepath.Join(tempDir, "config.toml")
|
||||
if err := os.WriteFile(configPath, []byte(`
|
||||
[mcp_servers.user_server]
|
||||
command = "user-mcp"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
result := Reconcile(Request{
|
||||
Config: Config{
|
||||
AutoSync: true,
|
||||
ManagedMCPServers: []ManagedMCPServer{
|
||||
{ID: "xworkmate_server", Command: "xworkmate-mcp", Args: []string{"--port", "7777"}, Enabled: true},
|
||||
},
|
||||
},
|
||||
ConfiguredCodexCLIPath: configuredBinary,
|
||||
CodexHome: tempDir,
|
||||
})
|
||||
|
||||
content, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read config: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(content), `[mcp_servers.user_server]`) {
|
||||
t.Fatalf("expected user entry preserved: %s", string(content))
|
||||
}
|
||||
if !strings.Contains(string(content), `[mcp_servers.xworkmate_server]`) {
|
||||
t.Fatalf("expected managed entry written: %s", string(content))
|
||||
}
|
||||
if strings.Count(string(content), codexManagedMCPBlockStart) != 1 {
|
||||
t.Fatalf("expected single managed block: %s", string(content))
|
||||
}
|
||||
if result.MountTargets[1].ManagedMCPCount != 1 {
|
||||
t.Fatalf("expected codex managed count 1, got %d", result.MountTargets[1].ManagedMCPCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileOpencodeAppliesManagedBlockAndPreservesUserEntries(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
binDir := t.TempDir()
|
||||
originalPath := os.Getenv("PATH")
|
||||
t.Setenv("PATH", binDir+string(os.PathListSeparator)+originalPath)
|
||||
if err := os.WriteFile(filepath.Join(binDir, "opencode"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
|
||||
t.Fatalf("write opencode binary: %v", err)
|
||||
}
|
||||
configPath := filepath.Join(tempDir, "config.toml")
|
||||
if err := os.WriteFile(configPath, []byte(`
|
||||
[model]
|
||||
name = "user-default"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
result := Reconcile(Request{
|
||||
Config: Config{
|
||||
AutoSync: true,
|
||||
ManagedMCPServers: []ManagedMCPServer{
|
||||
{ID: "xworkmate_server", Command: "xworkmate-mcp", Args: []string{"--port", "3001"}, Enabled: true},
|
||||
},
|
||||
},
|
||||
OpencodeHome: tempDir,
|
||||
})
|
||||
|
||||
content, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read config: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(content), `[model]`) {
|
||||
t.Fatalf("expected user config preserved: %s", string(content))
|
||||
}
|
||||
if !strings.Contains(string(content), `[mcp_servers.xworkmate_server]`) {
|
||||
t.Fatalf("expected managed opencode entry written: %s", string(content))
|
||||
}
|
||||
if strings.Count(string(content), opencodeManagedMCPBlockStart) != 1 {
|
||||
t.Fatalf("expected single opencode managed block: %s", string(content))
|
||||
}
|
||||
if result.MountTargets[4].ManagedMCPCount != 1 {
|
||||
t.Fatalf("expected opencode managed count 1, got %d", result.MountTargets[4].ManagedMCPCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileArisReportsReadyWhenBundleAndBridgeAreAvailable(t *testing.T) {
|
||||
result := Reconcile(Request{
|
||||
Config: Config{UsesAris: true},
|
||||
Aris: ArisInput{
|
||||
Available: true,
|
||||
BundleVersion: "test",
|
||||
LLMChatServerPath: "mcp-server.py",
|
||||
SkillCount: 2,
|
||||
BridgeAvailable: true,
|
||||
},
|
||||
})
|
||||
|
||||
if got := result.MountTargets[0].SyncState; got != "ready" {
|
||||
t.Fatalf("expected ready aris state, got %q", got)
|
||||
}
|
||||
if got := result.ArisBundleVersion; got != "test" {
|
||||
t.Fatalf("expected bundle version test, got %q", got)
|
||||
}
|
||||
}
|
||||
@ -92,3 +92,22 @@ func NotificationEnvelope(method string, params map[string]any) map[string]any {
|
||||
"seq": 0,
|
||||
}
|
||||
}
|
||||
|
||||
func ToolTextResult(id any, content string) map[string]any {
|
||||
result := map[string]any{
|
||||
"content": []map[string]any{
|
||||
{"type": "text", "text": content},
|
||||
},
|
||||
}
|
||||
return ResultEnvelope(id, result)
|
||||
}
|
||||
|
||||
func ToolErrorResult(id any, err error) map[string]any {
|
||||
result := map[string]any{
|
||||
"content": []map[string]any{
|
||||
{"type": "text", "text": fmt.Sprintf("Error: %v", err)},
|
||||
},
|
||||
"isError": true,
|
||||
}
|
||||
return ResultEnvelope(id, result)
|
||||
}
|
||||
|
||||
@ -342,6 +342,30 @@ func HandleChatTool(arguments map[string]any) (string, error) {
|
||||
return CallOpenAICompatible(baseURL, apiKey, model, messages)
|
||||
}
|
||||
|
||||
func HandleClaudeReviewTool(arguments map[string]any) (string, error) {
|
||||
prompt := strings.TrimSpace(StringArg(arguments, "prompt", ""))
|
||||
if prompt == "" {
|
||||
return "", errors.New("prompt is required")
|
||||
}
|
||||
model := strings.TrimSpace(
|
||||
StringArg(arguments, "model", EnvOrDefault("CLAUDE_REVIEW_MODEL", "")),
|
||||
)
|
||||
system := strings.TrimSpace(
|
||||
StringArg(arguments, "system", EnvOrDefault("CLAUDE_REVIEW_SYSTEM", "")),
|
||||
)
|
||||
tools := strings.TrimSpace(
|
||||
StringArg(arguments, "tools", EnvOrDefault("CLAUDE_REVIEW_TOOLS", "")),
|
||||
)
|
||||
timeout := IntArg(EnvOrDefault("CLAUDE_REVIEW_TIMEOUT_SEC", "600"), 600)
|
||||
return RunClaudeReview(
|
||||
prompt,
|
||||
model,
|
||||
system,
|
||||
tools,
|
||||
time.Duration(timeout)*time.Second,
|
||||
)
|
||||
}
|
||||
|
||||
func CallOpenAICompatible(
|
||||
baseURL,
|
||||
apiKey,
|
||||
|
||||
@ -2,10 +2,10 @@
|
||||
set -euo pipefail
|
||||
|
||||
BRIDGE_SERVER_URL="${BRIDGE_SERVER_URL:-https://xworkmate-bridge.svc.plus}"
|
||||
BRIDGE_AUTH_TOKEN="${AI_WORKSPACE_AUTH_TOKEN:-${BRIDGE_AUTH_TOKEN:-}}"
|
||||
BRIDGE_AUTH_TOKEN="${BRIDGE_AUTH_TOKEN:-}"
|
||||
|
||||
if [[ -z "${BRIDGE_AUTH_TOKEN}" ]]; then
|
||||
echo "Error: AI_WORKSPACE_AUTH_TOKEN or BRIDGE_AUTH_TOKEN is required" >&2
|
||||
echo "Error: BRIDGE_AUTH_TOKEN is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@ -3,10 +3,10 @@
|
||||
set -euo pipefail
|
||||
|
||||
BRIDGE_SERVER_URL="${BRIDGE_SERVER_URL:-https://xworkmate-bridge.svc.plus}"
|
||||
BRIDGE_AUTH_TOKEN="${AI_WORKSPACE_AUTH_TOKEN:-${BRIDGE_AUTH_TOKEN:-}}"
|
||||
BRIDGE_AUTH_TOKEN="${BRIDGE_AUTH_TOKEN:-}"
|
||||
|
||||
if [[ -z "${BRIDGE_AUTH_TOKEN}" ]]; then
|
||||
echo "Error: AI_WORKSPACE_AUTH_TOKEN or BRIDGE_AUTH_TOKEN is required" >&2
|
||||
echo "Error: BRIDGE_AUTH_TOKEN is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@ -2,11 +2,11 @@
|
||||
set -euo pipefail
|
||||
|
||||
BRIDGE_SERVER_URL="${BRIDGE_SERVER_URL:-https://xworkmate-bridge.svc.plus}"
|
||||
BRIDGE_AUTH_TOKEN="${AI_WORKSPACE_AUTH_TOKEN:-${BRIDGE_AUTH_TOKEN:-}}"
|
||||
BRIDGE_AUTH_TOKEN="${BRIDGE_AUTH_TOKEN:-}"
|
||||
HERMES_RPC_URL="${HERMES_RPC_URL:-${BRIDGE_SERVER_URL%/}/acp/rpc}"
|
||||
|
||||
if [[ -z "${BRIDGE_AUTH_TOKEN}" ]]; then
|
||||
echo "Error: AI_WORKSPACE_AUTH_TOKEN or BRIDGE_AUTH_TOKEN is required" >&2
|
||||
echo "Error: BRIDGE_AUTH_TOKEN is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@ -4,33 +4,16 @@ set -euo pipefail
|
||||
TARGET_HOST="${1:?target host is required}"
|
||||
BINARY_PATH="${2:?binary path is required}"
|
||||
EXPECTED_COMMIT="${3:?expected short commit is required}"
|
||||
|
||||
DEPLOY_USER="${DEPLOY_USER:-ubuntu}"
|
||||
REMOTE_TMP="/tmp/xworkmate-bridge-${EXPECTED_COMMIT}"
|
||||
REMOTE_BINARY="${REMOTE_BINARY:-/home/${DEPLOY_USER}/.local/bin/xworkmate-go-core}"
|
||||
REMOTE_WORKING_DIR="${REMOTE_WORKING_DIR:-/opt/cloud-neutral/xworkmate-bridge}"
|
||||
BRIDGE_CONFIG_PATH="${BRIDGE_CONFIG_PATH:-/opt/cloud-neutral/xworkmate-bridge/config.yaml}"
|
||||
SERVICE_NAME="${SERVICE_NAME:-xworkmate-bridge.service}"
|
||||
SERVICE_LISTEN_ADDR="${SERVICE_LISTEN_ADDR:-127.0.0.1:8787}"
|
||||
USER_SYSTEMD_DIR="${USER_SYSTEMD_DIR:-/home/${DEPLOY_USER}/.config/systemd/user}"
|
||||
SYSTEM_SERVICE_NAME="${SYSTEM_SERVICE_NAME:-xworkmate-bridge.service}"
|
||||
MIGRATE_SYSTEM_SERVICE="${MIGRATE_SYSTEM_SERVICE:-true}"
|
||||
SYSTEM_MIGRATION_USER="${SYSTEM_MIGRATION_USER:-root}"
|
||||
STALE_DROPIN="${STALE_DROPIN:-/etc/systemd/system/xworkmate-bridge.service.d/10-hotfix-openclaw-artifacts.conf}"
|
||||
DEPLOY_NATIVE_SKIP_PROC_CHECK="${DEPLOY_NATIVE_SKIP_PROC_CHECK:-false}"
|
||||
SYSTEMD_SYSTEM_DIR="${SYSTEMD_SYSTEM_DIR:-/etc/systemd/system}"
|
||||
SYSTEM_SERVICE_UNIT_PATH="${SYSTEM_SERVICE_UNIT_PATH:-${SYSTEMD_SYSTEM_DIR}/${SYSTEM_SERVICE_NAME}}"
|
||||
REMOTE_BINARY="${REMOTE_BINARY:-/usr/local/bin/xworkmate-go-core}"
|
||||
STALE_DROPIN="/etc/systemd/system/xworkmate-bridge.service.d/10-hotfix-openclaw-artifacts.conf"
|
||||
SERVICE_NAME="xworkmate-bridge.service"
|
||||
|
||||
if [[ ! "${TARGET_HOST}" =~ ^[A-Za-z0-9._-]+$ ]]; then
|
||||
echo "invalid target host: ${TARGET_HOST}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! "${DEPLOY_USER}" =~ ^[A-Za-z0-9._-]+$ ]]; then
|
||||
echo "invalid deploy user: ${DEPLOY_USER}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! "${EXPECTED_COMMIT}" =~ ^[0-9a-f]{7,40}$ ]]; then
|
||||
echo "invalid expected commit: ${EXPECTED_COMMIT}" >&2
|
||||
exit 1
|
||||
@ -41,84 +24,34 @@ if [[ ! -f "${BINARY_PATH}" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
escape_systemd_env() {
|
||||
python3 - "$1" <<'PY'
|
||||
import sys
|
||||
value = sys.argv[1]
|
||||
print(value.replace("\\", "\\\\").replace('"', '\\"'))
|
||||
PY
|
||||
}
|
||||
|
||||
resolve_token_from_unit() {
|
||||
local unit_path="$1"
|
||||
local key="$2"
|
||||
sed -n "s/^Environment=\"${key}=\\(.*\\)\"$/\\1/p" "${unit_path}" 2>/dev/null | head -n 1
|
||||
}
|
||||
|
||||
REMOTE_SYSTEM_SERVICE_UNIT_CONTENT="$(ssh -o BatchMode=yes "${SYSTEM_MIGRATION_USER}@${TARGET_HOST}" "cat '${SYSTEM_SERVICE_UNIT_PATH}' 2>/dev/null || true" 2>/dev/null || true)"
|
||||
|
||||
if [[ -z "${AI_WORKSPACE_AUTH_TOKEN:-}" && -n "${REMOTE_SYSTEM_SERVICE_UNIT_CONTENT}" ]]; then
|
||||
AI_WORKSPACE_AUTH_TOKEN="$(printf '%s\n' "${REMOTE_SYSTEM_SERVICE_UNIT_CONTENT}" | resolve_token_from_unit /dev/stdin "AI_WORKSPACE_AUTH_TOKEN")"
|
||||
if [[ -n "${AI_WORKSPACE_AUTH_TOKEN}" ]]; then
|
||||
echo "recovered AI_WORKSPACE_AUTH_TOKEN from ${SYSTEM_SERVICE_UNIT_PATH} on ${TARGET_HOST}" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "${AI_WORKSPACE_AUTH_TOKEN:-}" && -z "${BRIDGE_AUTH_TOKEN:-}" && -n "${REMOTE_SYSTEM_SERVICE_UNIT_CONTENT}" ]]; then
|
||||
BRIDGE_AUTH_TOKEN="$(printf '%s\n' "${REMOTE_SYSTEM_SERVICE_UNIT_CONTENT}" | resolve_token_from_unit /dev/stdin "BRIDGE_AUTH_TOKEN")"
|
||||
if [[ -n "${BRIDGE_AUTH_TOKEN}" ]]; then
|
||||
echo "recovered BRIDGE_AUTH_TOKEN from ${SYSTEM_SERVICE_UNIT_PATH} on ${TARGET_HOST}" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "${AI_WORKSPACE_AUTH_TOKEN:-}" && -z "${BRIDGE_AUTH_TOKEN:-}" ]]; then
|
||||
echo "::error::AI_WORKSPACE_AUTH_TOKEN is required: pass it via env, -e ai_workspace_auth_token=, or keep AI_WORKSPACE_AUTH_TOKEN/BRIDGE_AUTH_TOKEN in the existing system service unit at ${SYSTEM_SERVICE_UNIT_PATH}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${BRIDGE_REVIEW_AUTH_TOKEN:-}" && -n "${REMOTE_SYSTEM_SERVICE_UNIT_CONTENT}" ]]; then
|
||||
BRIDGE_REVIEW_AUTH_TOKEN="$(printf '%s\n' "${REMOTE_SYSTEM_SERVICE_UNIT_CONTENT}" | resolve_token_from_unit /dev/stdin "BRIDGE_REVIEW_AUTH_TOKEN")"
|
||||
fi
|
||||
|
||||
AUTH_TOKEN_LINE=""
|
||||
if [[ -n "${AI_WORKSPACE_AUTH_TOKEN:-}" ]]; then
|
||||
AUTH_TOKEN_LINE="Environment=\"AI_WORKSPACE_AUTH_TOKEN=$(escape_systemd_env "${AI_WORKSPACE_AUTH_TOKEN}")\""
|
||||
else
|
||||
AUTH_TOKEN_LINE="Environment=\"BRIDGE_AUTH_TOKEN=$(escape_systemd_env "${BRIDGE_AUTH_TOKEN}")\""
|
||||
fi
|
||||
|
||||
REVIEW_TOKEN_LINE=""
|
||||
if [[ -n "${BRIDGE_REVIEW_AUTH_TOKEN:-}" ]]; then
|
||||
REVIEW_TOKEN_LINE="Environment=\"BRIDGE_REVIEW_AUTH_TOKEN=$(escape_systemd_env "${BRIDGE_REVIEW_AUTH_TOKEN}")\""
|
||||
fi
|
||||
UNIT_ENV_LINES_B64="$(printf '%s\n%s\n' "${AUTH_TOKEN_LINE}" "${REVIEW_TOKEN_LINE}" | base64 | tr -d '\n')"
|
||||
|
||||
chmod +x "${BINARY_PATH}"
|
||||
scp -q "${BINARY_PATH}" "root@${TARGET_HOST}:${REMOTE_TMP}"
|
||||
|
||||
if [[ "${MIGRATE_SYSTEM_SERVICE}" == "true" ]]; then
|
||||
ssh "${SYSTEM_MIGRATION_USER}@${TARGET_HOST}" \
|
||||
"SYSTEM_SERVICE_NAME='${SYSTEM_SERVICE_NAME}' STALE_DROPIN='${STALE_DROPIN}' bash -s" <<'REMOTE_ROOT'
|
||||
ssh "root@${TARGET_HOST}" "EXPECTED_COMMIT='${EXPECTED_COMMIT}' REMOTE_TMP='${REMOTE_TMP}' REMOTE_BINARY='${REMOTE_BINARY}' STALE_DROPIN='${STALE_DROPIN}' SERVICE_NAME='${SERVICE_NAME}' bash -s" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
|
||||
if systemctl list-unit-files "${SYSTEM_SERVICE_NAME}" >/dev/null 2>&1; then
|
||||
systemctl disable --now "${SYSTEM_SERVICE_NAME}" >/dev/null 2>&1 || systemctl stop "${SYSTEM_SERVICE_NAME}" >/dev/null 2>&1 || true
|
||||
had_immutable=0
|
||||
restore_immutable() {
|
||||
if [[ "${had_immutable}" == "1" ]] && command -v chattr >/dev/null 2>&1 && [[ -e "${REMOTE_BINARY}" ]]; then
|
||||
chattr +i "${REMOTE_BINARY}" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
trap restore_immutable EXIT
|
||||
|
||||
if command -v lsattr >/dev/null 2>&1 && [[ -e "${REMOTE_BINARY}" ]]; then
|
||||
attrs="$(lsattr "${REMOTE_BINARY}" 2>/dev/null || true)"
|
||||
if [[ "${attrs}" == *i* ]]; then
|
||||
had_immutable=1
|
||||
chattr -i "${REMOTE_BINARY}"
|
||||
fi
|
||||
fi
|
||||
|
||||
install -o root -g root -m 0755 "${REMOTE_TMP}" "${REMOTE_BINARY}"
|
||||
|
||||
restore_immutable
|
||||
|
||||
rm -f "${STALE_DROPIN}"
|
||||
rmdir --ignore-fail-on-non-empty "$(dirname "${STALE_DROPIN}")" 2>/dev/null || true
|
||||
systemctl daemon-reload
|
||||
REMOTE_ROOT
|
||||
fi
|
||||
|
||||
scp -q "${BINARY_PATH}" "${DEPLOY_USER}@${TARGET_HOST}:${REMOTE_TMP}"
|
||||
|
||||
ssh "${DEPLOY_USER}@${TARGET_HOST}" \
|
||||
"EXPECTED_COMMIT='${EXPECTED_COMMIT}' REMOTE_TMP='${REMOTE_TMP}' REMOTE_BINARY='${REMOTE_BINARY}' REMOTE_WORKING_DIR='${REMOTE_WORKING_DIR}' BRIDGE_CONFIG_PATH='${BRIDGE_CONFIG_PATH}' SERVICE_NAME='${SERVICE_NAME}' SERVICE_LISTEN_ADDR='${SERVICE_LISTEN_ADDR}' USER_SYSTEMD_DIR='${USER_SYSTEMD_DIR}' SYSTEM_SERVICE_NAME='${SYSTEM_SERVICE_NAME}' SYSTEM_SERVICE_UNIT_PATH='${SYSTEM_SERVICE_UNIT_PATH}' UNIT_ENV_LINES_B64='${UNIT_ENV_LINES_B64}' DEPLOY_NATIVE_SKIP_PROC_CHECK='${DEPLOY_NATIVE_SKIP_PROC_CHECK}' bash -s" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
|
||||
mkdir -p "$(dirname "${REMOTE_BINARY}")" "${USER_SYSTEMD_DIR}"
|
||||
install -m 0755 "${REMOTE_TMP}" "${REMOTE_BINARY}"
|
||||
rm -f "${REMOTE_TMP}"
|
||||
|
||||
version_json="$("${REMOTE_BINARY}" version)"
|
||||
actual_commit="$(VERSION_JSON="${version_json}" python3 - <<'PY'
|
||||
@ -133,76 +66,14 @@ if [[ "${actual_commit}" != "${EXPECTED_COMMIT}" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
unit_env_lines="$(printf '%s' "${UNIT_ENV_LINES_B64}" | base64 -d | sed '/^$/d')"
|
||||
existing_env="$(
|
||||
{
|
||||
systemctl --user show -p Environment --value "${SERVICE_NAME}" 2>/dev/null || true
|
||||
systemctl show -p Environment --value "${SYSTEM_SERVICE_NAME}" 2>/dev/null || true
|
||||
if [[ -f "${SYSTEM_SERVICE_UNIT_PATH}" ]]; then
|
||||
sed -n 's/^Environment="\(AI_WORKSPACE_AUTH_TOKEN=[^"]*\|BRIDGE_AUTH_TOKEN=[^"]*\|BRIDGE_REVIEW_AUTH_TOKEN=[^"]*\)"$/\1/p' "${SYSTEM_SERVICE_UNIT_PATH}"
|
||||
fi
|
||||
} | sed '/^$/d' | head -n 1
|
||||
)"
|
||||
unit_env_lines="$(
|
||||
UNIT_ENV_LINES="${unit_env_lines}" EXISTING_ENV="${existing_env}" python3 - <<'PY'
|
||||
import os
|
||||
import shlex
|
||||
|
||||
lines = [line for line in os.environ.get("UNIT_ENV_LINES", "").splitlines() if line.strip()]
|
||||
present = set()
|
||||
for line in lines:
|
||||
prefix = 'Environment="'
|
||||
if line.startswith(prefix):
|
||||
key = line[len(prefix):].split("=", 1)[0]
|
||||
present.add(key)
|
||||
|
||||
for item in shlex.split(os.environ.get("EXISTING_ENV", "")):
|
||||
key, sep, value = item.partition("=")
|
||||
if sep and key in {"AI_WORKSPACE_AUTH_TOKEN", "BRIDGE_AUTH_TOKEN", "BRIDGE_REVIEW_AUTH_TOKEN"} and key not in present:
|
||||
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
||||
lines.append(f'Environment="{key}={escaped}"')
|
||||
present.add(key)
|
||||
|
||||
print("\n".join(lines))
|
||||
PY
|
||||
)"
|
||||
|
||||
cat >"${USER_SYSTEMD_DIR}/${SERVICE_NAME}" <<UNIT
|
||||
[Unit]
|
||||
Description=XWorkmate bridge control plane
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=${REMOTE_WORKING_DIR}
|
||||
Environment="HOME=/home/${USER}"
|
||||
Environment="TERM=xterm-256color"
|
||||
Environment="BRIDGE_CONFIG_PATH=${BRIDGE_CONFIG_PATH}"
|
||||
${unit_env_lines}
|
||||
ExecStart=${REMOTE_BINARY} serve --listen ${SERVICE_LISTEN_ADDR}
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
UNIT
|
||||
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable "${SERVICE_NAME}" >/dev/null
|
||||
|
||||
if systemctl is-active --quiet "${SYSTEM_SERVICE_NAME}" 2>/dev/null; then
|
||||
echo "${SYSTEM_SERVICE_NAME} is still active as a system service; disable it before starting the user service" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
systemctl --user restart "${SERVICE_NAME}"
|
||||
systemctl daemon-reload
|
||||
systemctl restart "${SERVICE_NAME}"
|
||||
deadline=$((SECONDS + 20))
|
||||
actual_exe=""
|
||||
pid=""
|
||||
while (( SECONDS < deadline )); do
|
||||
pid="$(systemctl --user show -p MainPID --value "${SERVICE_NAME}")"
|
||||
if [[ -n "${pid}" && "${pid}" != "0" ]] && [[ "${DEPLOY_NATIVE_SKIP_PROC_CHECK}" == "true" || -e "/proc/${pid}/exe" ]]; then
|
||||
pid="$(systemctl show -p MainPID --value "${SERVICE_NAME}")"
|
||||
if [[ -n "${pid}" && "${pid}" != "0" && -e "/proc/${pid}/exe" ]]; then
|
||||
actual_exe="$(readlink -f "/proc/${pid}/exe" 2>/dev/null || true)"
|
||||
if [[ "${actual_exe}" == "${REMOTE_BINARY}" ]]; then
|
||||
exit 0
|
||||
@ -211,7 +82,7 @@ while (( SECONDS < deadline )); do
|
||||
sleep 1
|
||||
done
|
||||
if [[ -z "${pid}" || "${pid}" == "0" ]]; then
|
||||
echo "${SERVICE_NAME} did not start as a user service" >&2
|
||||
echo "${SERVICE_NAME} did not start" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "${SERVICE_NAME} is not running ${REMOTE_BINARY}; pid=${pid}; actual=${actual_exe:-unknown}" >&2
|
||||
|
||||
@ -20,6 +20,6 @@ if [[ "${RUN_APPLY}" != "true" ]]; then
|
||||
fi
|
||||
|
||||
ANSIBLE_CONFIG="${PWD}/ansible.cfg" \
|
||||
BRIDGE_AUTH_TOKEN="${AI_WORKSPACE_AUTH_TOKEN:-${BRIDGE_AUTH_TOKEN:-}}" \
|
||||
BRIDGE_AUTH_TOKEN="${INTERNAL_SERVICE_TOKEN:-}" \
|
||||
BRIDGE_REVIEW_AUTH_TOKEN="${BRIDGE_REVIEW_AUTH_TOKEN:-}" \
|
||||
"${args[@]}"
|
||||
|
||||
@ -5,4 +5,3 @@ go mod download
|
||||
go mod verify
|
||||
golangci-lint run ./...
|
||||
go test ./...
|
||||
bash scripts/github-actions/test-deploy-native-binary.sh
|
||||
|
||||
@ -4,19 +4,12 @@ set -euo pipefail
|
||||
TARGET_HOST="${1:?target host is required}"
|
||||
SSH_KNOWN_HOSTS_PAYLOAD="${2:-}"
|
||||
|
||||
if [[ -z "${SINGLE_NODE_VPS_SSH_PRIVATE_KEY:-}" && -z "${SINGLE_NODE_VPS_SSH_PRIVATE_KEY_B64:-}" ]]; then
|
||||
echo "::error::SINGLE_NODE_VPS_SSH_PRIVATE_KEY or SINGLE_NODE_VPS_SSH_PRIVATE_KEY_B64 is required"
|
||||
exit 1
|
||||
fi
|
||||
test -n "${SINGLE_NODE_VPS_SSH_PRIVATE_KEY:-}"
|
||||
|
||||
mkdir -p "${HOME}/.ssh"
|
||||
chmod 700 "${HOME}/.ssh"
|
||||
|
||||
if [[ -n "${SINGLE_NODE_VPS_SSH_PRIVATE_KEY_B64:-}" ]]; then
|
||||
printf '%s' "${SINGLE_NODE_VPS_SSH_PRIVATE_KEY_B64}" | base64 -d > "${HOME}/.ssh/id_rsa"
|
||||
else
|
||||
python3 .github/scripts/normalize-private-key.py normalize > "${HOME}/.ssh/id_rsa"
|
||||
fi
|
||||
python3 .github/scripts/normalize-private-key.py normalize > "${HOME}/.ssh/id_rsa"
|
||||
chmod 600 "${HOME}/.ssh/id_rsa"
|
||||
ssh-keygen -y -f "${HOME}/.ssh/id_rsa" >/dev/null
|
||||
|
||||
|
||||
@ -31,9 +31,8 @@ curl_args=(
|
||||
--max-time 20
|
||||
)
|
||||
|
||||
AUTH_TOKEN="${AI_WORKSPACE_AUTH_TOKEN:-${BRIDGE_AUTH_TOKEN:-}}"
|
||||
if [[ -n "${AUTH_TOKEN}" ]]; then
|
||||
curl_args+=(-H "Authorization: Bearer ${AUTH_TOKEN}")
|
||||
if [[ -n "${BRIDGE_AUTH_TOKEN:-}" ]]; then
|
||||
curl_args+=(-H "Authorization: Bearer ${BRIDGE_AUTH_TOKEN}")
|
||||
fi
|
||||
|
||||
for ((attempt = 1; attempt <= attempts; attempt += 1)); do
|
||||
@ -84,3 +83,4 @@ print(f"production_tag={deployed_tag}")
|
||||
print(f"production_commit={deployed_commit}")
|
||||
print(f"production_version={deployed_version}")
|
||||
PY
|
||||
|
||||
|
||||
@ -1,243 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
SCRIPT_PATH="${ROOT_DIR}/scripts/github-actions/deploy-native-binary.sh"
|
||||
EXPECTED_COMMIT="425a38f"
|
||||
|
||||
fail() {
|
||||
printf 'FAIL: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
assert_contains() {
|
||||
local haystack="$1"
|
||||
local needle="$2"
|
||||
if [[ "${haystack}" != *"${needle}"* ]]; then
|
||||
fail "expected output to contain: ${needle}"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_file_contains() {
|
||||
local file="$1"
|
||||
local needle="$2"
|
||||
if [[ ! -f "${file}" ]]; then
|
||||
fail "expected file to exist: ${file}"
|
||||
fi
|
||||
if ! grep -qF -- "${needle}" "${file}"; then
|
||||
fail "expected ${file} to contain: ${needle}"
|
||||
fi
|
||||
}
|
||||
|
||||
build_fake_bin_dir() {
|
||||
local bin_dir="$1"
|
||||
|
||||
cat >"${bin_dir}/scp" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
src="$2"
|
||||
dest="$3"
|
||||
printf 'scp %s\n' "${dest}" >>"${FAKE_DEPLOY_LOG}"
|
||||
dest_path="${dest#*:}"
|
||||
cp "${src}" "${dest_path}"
|
||||
EOF
|
||||
|
||||
cat >"${bin_dir}/ssh" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
while [[ "${1:-}" == -* ]]; do
|
||||
case "${1}" in
|
||||
-o|-i|-p|-l)
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
target="$1"
|
||||
shift
|
||||
printf 'ssh %s\n' "${target}" >>"${FAKE_DEPLOY_LOG}"
|
||||
cmd="$*"
|
||||
case "${cmd}" in
|
||||
cat\ *)
|
||||
path="${cmd#cat }"
|
||||
path="${path%% 2>/dev/null*}"
|
||||
path="${path%% || true*}"
|
||||
path="${path%\'}"
|
||||
path="${path#\'}"
|
||||
cat "${path}" 2>/dev/null || true
|
||||
;;
|
||||
*)
|
||||
bash -c "${cmd}"
|
||||
;;
|
||||
esac
|
||||
EOF
|
||||
|
||||
cat >"${bin_dir}/systemctl" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf 'systemctl %s\n' "$*" >>"${FAKE_DEPLOY_LOG}"
|
||||
if [[ "${1:-}" == "is-active" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${1:-}" == "--user" && "${2:-}" == "show" ]]; then
|
||||
printf '1\n'
|
||||
exit 0
|
||||
fi
|
||||
exit 0
|
||||
EOF
|
||||
|
||||
cat >"${bin_dir}/readlink" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "${1:-}" == "-f" && "${2:-}" == /proc/*/exe ]]; then
|
||||
printf '%s\n' "${REMOTE_BINARY}"
|
||||
exit 0
|
||||
fi
|
||||
/usr/bin/readlink "$@"
|
||||
EOF
|
||||
|
||||
cat >"${bin_dir}/sleep" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
exit 0
|
||||
EOF
|
||||
|
||||
chmod +x "${bin_dir}/"*
|
||||
}
|
||||
|
||||
create_fake_binary() {
|
||||
local path="$1"
|
||||
cat >"${path}" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "${1:-}" == "version" ]]; then
|
||||
printf '{"commit":"425a38f","version":"test"}\n'
|
||||
exit 0
|
||||
fi
|
||||
sleep 3600
|
||||
EOF
|
||||
chmod +x "${path}"
|
||||
}
|
||||
|
||||
setup_test_env() {
|
||||
local label="$1"
|
||||
local tmp_dir
|
||||
tmp_dir="$(mktemp -d)"
|
||||
local bin_dir="${tmp_dir}/bin"
|
||||
local remote_dir="${tmp_dir}/remote"
|
||||
mkdir -p "${bin_dir}" "${remote_dir}/tmp" "${remote_dir}/opt/cloud-neutral/xworkmate-bridge"
|
||||
|
||||
create_fake_binary "${tmp_dir}/xworkmate-bridge"
|
||||
build_fake_bin_dir "${bin_dir}"
|
||||
|
||||
printf '%s\n' "${label}" >&2
|
||||
echo "${tmp_dir}"
|
||||
}
|
||||
|
||||
run_deploy() {
|
||||
local bin_dir="$1"
|
||||
local log_file="$2"
|
||||
shift 2
|
||||
env \
|
||||
PATH="${bin_dir}:${PATH}" \
|
||||
FAKE_DEPLOY_LOG="${log_file}" \
|
||||
"$@" \
|
||||
bash "${SCRIPT_PATH}" "example.test" "${bin_dir}/../xworkmate-bridge" "${EXPECTED_COMMIT}"
|
||||
}
|
||||
|
||||
run_env_token_case() {
|
||||
local tmp_dir
|
||||
tmp_dir="$(setup_test_env "case: AI_WORKSPACE_AUTH_TOKEN env var drives the unit file")"
|
||||
|
||||
local log_file="${tmp_dir}/deploy.log"
|
||||
local unit_file="${tmp_dir}/remote/home/ubuntu/.config/systemd/user/xworkmate-bridge.service"
|
||||
run_deploy "${tmp_dir}/bin" "${log_file}" \
|
||||
REMOTE_TMP="${tmp_dir}/remote/tmp/xworkmate-bridge-${EXPECTED_COMMIT}" \
|
||||
REMOTE_BINARY="${tmp_dir}/remote/home/ubuntu/.local/bin/xworkmate-go-core" \
|
||||
REMOTE_WORKING_DIR="${tmp_dir}/remote/opt/cloud-neutral/xworkmate-bridge" \
|
||||
BRIDGE_CONFIG_PATH="${tmp_dir}/remote/opt/cloud-neutral/xworkmate-bridge/config.yaml" \
|
||||
USER_SYSTEMD_DIR="${tmp_dir}/remote/home/ubuntu/.config/systemd/user" \
|
||||
DEPLOY_NATIVE_SKIP_PROC_CHECK=true \
|
||||
AI_WORKSPACE_AUTH_TOKEN="test-token"
|
||||
|
||||
local log_output
|
||||
log_output="$(cat "${log_file}")"
|
||||
assert_contains "${log_output}" "ssh root@example.test"
|
||||
assert_contains "${log_output}" "scp ubuntu@example.test:"
|
||||
assert_contains "${log_output}" "ssh ubuntu@example.test"
|
||||
assert_contains "${log_output}" "systemctl --user restart xworkmate-bridge.service"
|
||||
assert_file_contains "${unit_file}" 'Environment="AI_WORKSPACE_AUTH_TOKEN=test-token"'
|
||||
assert_file_contains "${unit_file}" "WantedBy=default.target"
|
||||
|
||||
rm -rf "${tmp_dir}"
|
||||
}
|
||||
|
||||
run_unit_fallback_case() {
|
||||
local tmp_dir
|
||||
tmp_dir="$(setup_test_env "case: AI_WORKSPACE_AUTH_TOKEN recovered from system service unit file")"
|
||||
|
||||
local system_unit_dir="${tmp_dir}/remote/etc/systemd/system"
|
||||
mkdir -p "${system_unit_dir}"
|
||||
local system_unit_file="${system_unit_dir}/xworkmate-bridge.service"
|
||||
cat >"${system_unit_file}" <<'EOF'
|
||||
[Unit]
|
||||
Description=Stale system service
|
||||
[Service]
|
||||
Environment="AI_WORKSPACE_AUTH_TOKEN=recovered-from-systemd"
|
||||
Environment="BRIDGE_REVIEW_AUTH_TOKEN=recovered-review-token"
|
||||
ExecStart=/bin/true
|
||||
EOF
|
||||
|
||||
local log_file="${tmp_dir}/deploy.log"
|
||||
local unit_file="${tmp_dir}/remote/home/ubuntu/.config/systemd/user/xworkmate-bridge.service"
|
||||
run_deploy "${tmp_dir}/bin" "${log_file}" \
|
||||
REMOTE_TMP="${tmp_dir}/remote/tmp/xworkmate-bridge-${EXPECTED_COMMIT}" \
|
||||
REMOTE_BINARY="${tmp_dir}/remote/home/ubuntu/.local/bin/xworkmate-go-core" \
|
||||
REMOTE_WORKING_DIR="${tmp_dir}/remote/opt/cloud-neutral/xworkmate-bridge" \
|
||||
BRIDGE_CONFIG_PATH="${tmp_dir}/remote/opt/cloud-neutral/xworkmate-bridge/config.yaml" \
|
||||
USER_SYSTEMD_DIR="${tmp_dir}/remote/home/ubuntu/.config/systemd/user" \
|
||||
SYSTEM_SERVICE_UNIT_PATH="${system_unit_file}" \
|
||||
DEPLOY_NATIVE_SKIP_PROC_CHECK=true
|
||||
|
||||
assert_file_contains "${unit_file}" 'Environment="AI_WORKSPACE_AUTH_TOKEN=recovered-from-systemd"'
|
||||
assert_file_contains "${unit_file}" 'Environment="BRIDGE_REVIEW_AUTH_TOKEN=recovered-review-token"'
|
||||
|
||||
rm -rf "${tmp_dir}"
|
||||
}
|
||||
|
||||
run_fail_fast_case() {
|
||||
local tmp_dir
|
||||
tmp_dir="$(setup_test_env "case: missing AI_WORKSPACE_AUTH_TOKEN fails fast with clear error")"
|
||||
|
||||
local log_file="${tmp_dir}/deploy.log"
|
||||
local stderr_file="${tmp_dir}/deploy.stderr"
|
||||
set +e
|
||||
run_deploy "${tmp_dir}/bin" "${log_file}" \
|
||||
REMOTE_TMP="${tmp_dir}/remote/tmp/xworkmate-bridge-${EXPECTED_COMMIT}" \
|
||||
REMOTE_BINARY="${tmp_dir}/remote/home/ubuntu/.local/bin/xworkmate-go-core" \
|
||||
REMOTE_WORKING_DIR="${tmp_dir}/remote/opt/cloud-neutral/xworkmate-bridge" \
|
||||
BRIDGE_CONFIG_PATH="${tmp_dir}/remote/opt/cloud-neutral/xworkmate-bridge/config.yaml" \
|
||||
USER_SYSTEMD_DIR="${tmp_dir}/remote/home/ubuntu/.config/systemd/user" \
|
||||
DEPLOY_NATIVE_SKIP_PROC_CHECK=true \
|
||||
2>"${stderr_file}"
|
||||
local exit_code=$?
|
||||
set -e
|
||||
|
||||
if [[ "${exit_code}" == "0" ]]; then
|
||||
fail "expected deploy to fail when AI_WORKSPACE_AUTH_TOKEN is empty and no system service unit exists"
|
||||
fi
|
||||
assert_contains "$(cat "${stderr_file}")" "AI_WORKSPACE_AUTH_TOKEN is required"
|
||||
|
||||
rm -rf "${tmp_dir}"
|
||||
}
|
||||
|
||||
main() {
|
||||
run_env_token_case
|
||||
run_unit_fallback_case
|
||||
run_fail_fast_case
|
||||
printf 'deploy-native-binary regression tests passed\n'
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@ -28,11 +28,7 @@ fi
|
||||
|
||||
BASE_URL="$(normalize_url "${BRIDGE_SERVER_URL:-${2:-https://xworkmate-bridge.svc.plus}}")"
|
||||
RPC_URL="${BASE_URL%/}/acp/rpc"
|
||||
AUTH_TOKEN="${AI_WORKSPACE_AUTH_TOKEN:-${BRIDGE_AUTH_TOKEN:-}}"
|
||||
if [[ -z "${AUTH_TOKEN}" ]]; then
|
||||
echo "AI_WORKSPACE_AUTH_TOKEN or BRIDGE_AUTH_TOKEN is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
AUTH_TOKEN="${BRIDGE_AUTH_TOKEN:?BRIDGE_AUTH_TOKEN is required}"
|
||||
|
||||
fast_http_curl_common=(
|
||||
--silent
|
||||
|
||||
@ -2,14 +2,14 @@
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${BRIDGE_SERVER_URL:-https://xworkmate-bridge.svc.plus}"
|
||||
AUTH_TOKEN="${AI_WORKSPACE_AUTH_TOKEN:-${BRIDGE_AUTH_TOKEN:-}}"
|
||||
AUTH_TOKEN="${BRIDGE_AUTH_TOKEN:-}"
|
||||
REQUEST_ORIGIN="${OPENCLAW_SMOKE_ORIGIN:-https://xworkmate.svc.plus}"
|
||||
RPC_TIMEOUT_SECONDS="${OPENCLAW_SMOKE_RPC_TIMEOUT_SECONDS:-180}"
|
||||
POLL_TIMEOUT_SECONDS="${OPENCLAW_SMOKE_POLL_TIMEOUT_SECONDS:-120}"
|
||||
POLL_INTERVAL_SECONDS="${OPENCLAW_SMOKE_POLL_INTERVAL_SECONDS:-2}"
|
||||
|
||||
if [[ -z "${AUTH_TOKEN}" ]]; then
|
||||
echo "AI_WORKSPACE_AUTH_TOKEN or BRIDGE_AUTH_TOKEN is required" >&2
|
||||
echo "BRIDGE_AUTH_TOKEN is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@ -147,39 +147,24 @@ def first_nonempty(payload, *keys):
|
||||
return ""
|
||||
|
||||
|
||||
def openclaw_session_key_for_app_thread(app_thread_key):
|
||||
app_thread_key = str(app_thread_key or "").strip()
|
||||
if not app_thread_key:
|
||||
app_thread_key = "main"
|
||||
return "agent:main:" + app_thread_key
|
||||
|
||||
|
||||
def task_handle_from_payload(payload):
|
||||
if not isinstance(payload, dict):
|
||||
return {}
|
||||
candidates = []
|
||||
for key in ("result", "payload", "params"):
|
||||
if isinstance(payload.get(key), dict):
|
||||
candidates.append(payload[key])
|
||||
if isinstance(payload.get("result"), dict):
|
||||
candidates.append(payload["result"])
|
||||
if isinstance(payload.get("payload"), dict):
|
||||
candidates.append(payload["payload"])
|
||||
if isinstance(payload.get("params"), dict):
|
||||
candidates.append(payload["params"])
|
||||
candidates.append(payload)
|
||||
for candidate in candidates:
|
||||
if not isinstance(candidate, dict):
|
||||
continue
|
||||
session_id = first_nonempty(candidate, "sessionId")
|
||||
thread_id = first_nonempty(candidate, "threadId")
|
||||
turn_id = first_nonempty(candidate, "turnId")
|
||||
run_id = first_nonempty(candidate, "runId")
|
||||
app_thread_key = first_nonempty(candidate, "appThreadKey")
|
||||
openclaw_session_key = first_nonempty(candidate, "openclawSessionKey")
|
||||
if (app_thread_key and openclaw_session_key and run_id) or (session_id and thread_id and (turn_id or run_id)):
|
||||
return {
|
||||
"sessionId": session_id,
|
||||
"threadId": thread_id,
|
||||
"turnId": turn_id,
|
||||
"runId": run_id or turn_id,
|
||||
"appThreadKey": app_thread_key or session_id or thread_id,
|
||||
"openclawSessionKey": openclaw_session_key or openclaw_session_key_for_app_thread(app_thread_key or session_id or thread_id),
|
||||
}
|
||||
if first_nonempty(candidate, "sessionId") and first_nonempty(candidate, "threadId") and (
|
||||
first_nonempty(candidate, "turnId") or first_nonempty(candidate, "runId")
|
||||
):
|
||||
return candidate
|
||||
return {}
|
||||
|
||||
|
||||
@ -207,19 +192,6 @@ def is_valid_no_displayable_contract(payload):
|
||||
return True
|
||||
|
||||
|
||||
def is_valid_no_native_task_record_contract(payload):
|
||||
if not isinstance(payload, dict):
|
||||
return False
|
||||
if payload.get("code") != "no_native_task_record":
|
||||
return False
|
||||
if payload.get("ok") is not False:
|
||||
return False
|
||||
message = str(payload.get("message") or "")
|
||||
if "No native OpenClaw task record found" not in message:
|
||||
raise SystemExit(f"OpenClaw smoke native task lookup returned unexpected message: {json.dumps(payload, ensure_ascii=False, sort_keys=True)[:1000]}")
|
||||
return True
|
||||
|
||||
|
||||
final = next(
|
||||
(item for item in payloads if isinstance(item, dict) and item.get("id") == "validate-openclaw"),
|
||||
None,
|
||||
@ -228,27 +200,16 @@ if final is None:
|
||||
raise SystemExit("missing final OpenClaw result envelope")
|
||||
if not payloads or payloads[-1].get("done") is not True:
|
||||
raise SystemExit("missing SSE done marker")
|
||||
if isinstance(final.get("error"), dict) and final["error"]:
|
||||
error = final["error"]
|
||||
message = error.get("message") or json.dumps(error, ensure_ascii=False, sort_keys=True)
|
||||
preview = json.dumps(final, ensure_ascii=False, sort_keys=True)[:1500]
|
||||
raise SystemExit(f"OpenClaw smoke RPC error: {message}\nenvelope preview: {preview}")
|
||||
|
||||
result = terminal_result(final.get("result") or final.get("payload") or {})
|
||||
handle = result
|
||||
if result.get("status") != "running":
|
||||
handle = find_task_handle(payloads, final)
|
||||
if handle.get("status") == "running" or (not result and handle):
|
||||
run_id = first_nonempty(handle, "runId", "turnId")
|
||||
app_thread_key = first_nonempty(handle, "appThreadKey", "sessionId", "threadId")
|
||||
openclaw_session_key = first_nonempty(handle, "openclawSessionKey") or openclaw_session_key_for_app_thread(app_thread_key)
|
||||
|
||||
if not app_thread_key:
|
||||
raise SystemExit(f"OpenClaw smoke running handle missing appThreadKey: {json.dumps(handle, ensure_ascii=False, sort_keys=True)[:1000]}")
|
||||
if not openclaw_session_key:
|
||||
raise SystemExit(f"OpenClaw smoke running handle missing openclawSessionKey: {json.dumps(handle, ensure_ascii=False, sort_keys=True)[:1000]}")
|
||||
if not run_id:
|
||||
raise SystemExit(f"OpenClaw smoke running handle missing runId: {json.dumps(handle, ensure_ascii=False, sort_keys=True)[:1000]}")
|
||||
session_id = first_nonempty(handle, "sessionId")
|
||||
thread_id = first_nonempty(handle, "threadId")
|
||||
turn_id = first_nonempty(handle, "turnId")
|
||||
run_id = first_nonempty(handle, "runId")
|
||||
|
||||
deadline = time.time() + poll_timeout
|
||||
while time.time() < deadline:
|
||||
@ -257,8 +218,9 @@ if handle.get("status") == "running" or (not result and handle):
|
||||
"id": "poll-task",
|
||||
"method": "xworkmate.tasks.get",
|
||||
"params": {
|
||||
"appThreadKey": app_thread_key,
|
||||
"openclawSessionKey": openclaw_session_key,
|
||||
"sessionId": session_id,
|
||||
"threadId": thread_id,
|
||||
"turnId": turn_id,
|
||||
"runId": run_id,
|
||||
},
|
||||
}).encode("utf-8")
|
||||
@ -276,10 +238,6 @@ if handle.get("status") == "running" or (not result and handle):
|
||||
resp_data = json.loads(resp.read().decode("utf-8"))
|
||||
poll_result = resp_data.get("result") or {}
|
||||
status = poll_result.get("status")
|
||||
if is_valid_no_native_task_record_contract(poll_result):
|
||||
result = poll_result
|
||||
final["result"] = poll_result
|
||||
break
|
||||
if status in ("completed", "failed", "cancelled"):
|
||||
terminal = terminal_result(poll_result)
|
||||
result = terminal if terminal else poll_result
|
||||
@ -316,9 +274,6 @@ if "pong" not in output_text.lower():
|
||||
if is_valid_no_displayable_contract(result):
|
||||
print("OpenClaw smoke OK: session contract completed without displayable output")
|
||||
sys.exit(0)
|
||||
if is_valid_no_native_task_record_contract(result):
|
||||
print("OpenClaw smoke OK: session started; no native task record available for polling")
|
||||
sys.exit(0)
|
||||
result_preview = json.dumps(result, ensure_ascii=False, sort_keys=True)[:1000]
|
||||
payload_preview = json.dumps(payloads[:6], ensure_ascii=False, sort_keys=True)[:1500]
|
||||
raise SystemExit(f"OpenClaw smoke did not return pong: {output_text[:500]}\nresult preview: {result_preview}\nSSE preview: {payload_preview}")
|
||||
|
||||
@ -2,12 +2,12 @@
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${BRIDGE_SERVER_URL:-https://xworkmate-bridge.svc.plus}"
|
||||
AUTH_TOKEN="${AI_WORKSPACE_AUTH_TOKEN:-${BRIDGE_AUTH_TOKEN:-}}"
|
||||
AUTH_TOKEN="${BRIDGE_AUTH_TOKEN:-}"
|
||||
HTTP_TIMEOUT_SECONDS="${HTTP_TIMEOUT_SECONDS:-30}"
|
||||
RPC_TIMEOUT_SECONDS="${RPC_TIMEOUT_SECONDS:-90}"
|
||||
|
||||
if [[ -z "${AUTH_TOKEN}" ]]; then
|
||||
echo "AI_WORKSPACE_AUTH_TOKEN or BRIDGE_AUTH_TOKEN is required" >&2
|
||||
echo "BRIDGE_AUTH_TOKEN is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user