mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-24 00:04:25 +08:00
Compare commits
82 Commits
fix/codex-
...
fix/exec-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8bc882853 | ||
|
|
9fa14ff61a | ||
|
|
760f86453e | ||
|
|
e08ef9f893 | ||
|
|
14b912261b | ||
|
|
9b9b058ebf | ||
|
|
1b7c1c2eb7 | ||
|
|
026123dc76 | ||
|
|
2920dc3282 | ||
|
|
ea56b135c8 | ||
|
|
32494c7ace | ||
|
|
43f2b61f3b | ||
|
|
0ec12df245 | ||
|
|
2592f8a51a | ||
|
|
fee8ab4764 | ||
|
|
b60f63150f | ||
|
|
391e492f56 | ||
|
|
086c629556 | ||
|
|
d96ac02dc6 | ||
|
|
c51661f1bf | ||
|
|
2f8ad67a5e | ||
|
|
e39249100e | ||
|
|
befe04f465 | ||
|
|
5e342c774d | ||
|
|
321e58c030 | ||
|
|
82316c2f45 | ||
|
|
a70dae40b7 | ||
|
|
3675c01410 | ||
|
|
cc32f277fe | ||
|
|
a409df6f9c | ||
|
|
264b37e9d2 | ||
|
|
3f7ef1be37 | ||
|
|
330fc9f7b9 | ||
|
|
3c06770a82 | ||
|
|
fc15c58715 | ||
|
|
2ce4a7483a | ||
|
|
fac091b39d | ||
|
|
89de454f82 | ||
|
|
de9c94cbbb | ||
|
|
5e915e1f89 | ||
|
|
dcb6b0dd6f | ||
|
|
961130c707 | ||
|
|
ef62076789 | ||
|
|
a1c2454b08 | ||
|
|
63b13ea837 | ||
|
|
c0b6183b7b | ||
|
|
0edd84f910 | ||
|
|
ea9065bc68 | ||
|
|
adc4d9fe02 | ||
|
|
75af913ba6 | ||
|
|
739e6cbbf8 | ||
|
|
8357260081 | ||
|
|
aeedfceb28 | ||
|
|
75b9e761b7 | ||
|
|
1cdc28605d | ||
|
|
037ee6de0a | ||
|
|
d6111ff72c | ||
|
|
ed2dfee7d7 | ||
|
|
af328b2b21 | ||
|
|
88c3bb5391 | ||
|
|
e9756f9e71 | ||
|
|
2e0dd66d39 | ||
|
|
1423487351 | ||
|
|
01d212bfa3 | ||
|
|
3d787b5181 | ||
|
|
89c90210fb | ||
|
|
65a20ca4c5 | ||
|
|
d5d9a8256d | ||
|
|
5dfbb9d1e0 | ||
|
|
3a32d24395 | ||
|
|
90fb2ee4e1 | ||
|
|
5d9daea2b0 | ||
|
|
2dc2d73b07 | ||
|
|
9122e762d8 | ||
|
|
769579bcf0 | ||
|
|
056e5b6b07 | ||
|
|
8fdb1b61db | ||
|
|
a2d7882100 | ||
|
|
e33760c9df | ||
|
|
392377e7e4 | ||
|
|
0a338147a5 | ||
|
|
013e33c6d3 |
10
.github/workflows/ci.yml
vendored
10
.github/workflows/ci.yml
vendored
@@ -305,6 +305,7 @@ jobs:
|
||||
shard_name: shard.shardName,
|
||||
groups: shard.groups,
|
||||
configs: shard.configs,
|
||||
env: shard.env,
|
||||
includePatterns: shard.includePatterns,
|
||||
requires_dist: shard.requiresDist,
|
||||
runner: shard.runner,
|
||||
@@ -1237,6 +1238,7 @@ jobs:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
OPENCLAW_NODE_TEST_GROUPS_JSON: ${{ toJson(matrix.groups || null) }}
|
||||
OPENCLAW_NODE_TEST_CONFIGS_JSON: ${{ toJson(matrix.configs) }}
|
||||
OPENCLAW_NODE_TEST_ENV_JSON: ${{ toJson(matrix.env) }}
|
||||
OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON: ${{ toJson(matrix.includePatterns) }}
|
||||
OPENCLAW_VITEST_SHARD_NAME: ${{ matrix.shard_name }}
|
||||
OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS: "300000"
|
||||
@@ -1255,6 +1257,7 @@ jobs:
|
||||
? groups
|
||||
: [{
|
||||
configs: JSON.parse(process.env.OPENCLAW_NODE_TEST_CONFIGS_JSON ?? "[]"),
|
||||
env: JSON.parse(process.env.OPENCLAW_NODE_TEST_ENV_JSON ?? "null"),
|
||||
includePatterns: JSON.parse(
|
||||
process.env.OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON ?? "null",
|
||||
),
|
||||
@@ -1270,6 +1273,13 @@ jobs:
|
||||
...process.env,
|
||||
...(plan.shard_name ? { OPENCLAW_VITEST_SHARD_NAME: plan.shard_name } : {}),
|
||||
};
|
||||
if (plan.env && typeof plan.env === "object" && !Array.isArray(plan.env)) {
|
||||
for (const [key, value] of Object.entries(plan.env)) {
|
||||
if (typeof value === "string") {
|
||||
childEnv[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Array.isArray(plan.includePatterns) && plan.includePatterns.length > 0) {
|
||||
const includeFile = join(
|
||||
process.env.RUNNER_TEMP ?? ".",
|
||||
|
||||
376
.github/workflows/qa-profile-evidence.yml
vendored
Normal file
376
.github/workflows/qa-profile-evidence.yml
vendored
Normal file
@@ -0,0 +1,376 @@
|
||||
name: QA Profile Evidence
|
||||
|
||||
run-name: ${{ format('QA Profile Evidence {0} {1}', inputs.qa_profile, inputs.ref) }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: OpenClaw branch, tag, or SHA to run
|
||||
required: true
|
||||
default: main
|
||||
type: string
|
||||
expected_sha:
|
||||
description: Optional full SHA that ref must resolve to
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
qa_profile:
|
||||
description: Taxonomy QA profile id to run
|
||||
required: true
|
||||
default: release
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
description: OpenClaw branch, tag, or SHA to run
|
||||
required: true
|
||||
type: string
|
||||
expected_sha:
|
||||
description: Optional full SHA that ref must resolve to
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
qa_profile:
|
||||
description: Taxonomy QA profile id to run
|
||||
required: true
|
||||
type: string
|
||||
fail_on_qa_failure:
|
||||
description: Fail the reusable workflow when the QA profile command exits non-zero
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
outputs:
|
||||
artifact_name:
|
||||
description: Uploaded QA profile evidence artifact name
|
||||
value: ${{ jobs.run_qa_profile.outputs.artifact_name }}
|
||||
qa_profile:
|
||||
description: Taxonomy QA profile id that produced the evidence
|
||||
value: ${{ jobs.run_qa_profile.outputs.qa_profile }}
|
||||
qa_exit_code:
|
||||
description: Exit code from the QA profile run; non-zero evidence is still uploaded
|
||||
value: ${{ jobs.run_qa_profile.outputs.qa_exit_code }}
|
||||
qa_passed:
|
||||
description: Whether the QA profile command exited successfully
|
||||
value: ${{ jobs.run_qa_profile.outputs.qa_passed }}
|
||||
target_sha:
|
||||
description: Resolved OpenClaw SHA that produced the evidence
|
||||
value: ${{ jobs.run_qa_profile.outputs.target_sha }}
|
||||
trusted_reason:
|
||||
description: Trust reason accepted before the secret-bearing QA job
|
||||
value: ${{ jobs.run_qa_profile.outputs.trusted_reason }}
|
||||
qa_evidence_path:
|
||||
description: Path to qa-evidence.json inside the uploaded artifact
|
||||
value: ${{ jobs.run_qa_profile.outputs.qa_evidence_path }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: qa-profile-evidence-${{ inputs.qa_profile }}-${{ inputs.expected_sha || inputs.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.x"
|
||||
OPENCLAW_BUILD_PRIVATE_QA: "1"
|
||||
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
|
||||
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
|
||||
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"
|
||||
|
||||
jobs:
|
||||
authorize_actor:
|
||||
name: Authorize workflow actor
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
outputs:
|
||||
authorized: ${{ steps.permission.outputs.authorized }}
|
||||
steps:
|
||||
- name: Require maintainer-level repository access
|
||||
id: permission
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
script: |
|
||||
if (context.eventName !== "workflow_dispatch") {
|
||||
core.info(`Skipping manual actor permission check for ${context.eventName}.`);
|
||||
core.setOutput("authorized", "true");
|
||||
return;
|
||||
}
|
||||
const allowed = new Set(["admin", "maintain", "write"]);
|
||||
const { owner, repo } = context.repo;
|
||||
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner,
|
||||
repo,
|
||||
username: context.actor,
|
||||
});
|
||||
const permission = data.permission;
|
||||
core.info(`Actor ${context.actor} permission: ${permission}`);
|
||||
if (!allowed.has(permission)) {
|
||||
core.notice(
|
||||
`Workflow requires write/maintain/admin access. Actor "${context.actor}" has "${permission}".`,
|
||||
);
|
||||
core.setOutput("authorized", "false");
|
||||
return;
|
||||
}
|
||||
core.setOutput("authorized", "true");
|
||||
|
||||
validate_selected_ref:
|
||||
name: Validate selected ref
|
||||
needs: authorize_actor
|
||||
if: needs.authorize_actor.outputs.authorized == 'true'
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
outputs:
|
||||
selected_revision: ${{ steps.validate.outputs.selected_revision }}
|
||||
trusted_reason: ${{ steps.validate.outputs.trusted_reason }}
|
||||
steps:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ inputs.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate selected ref
|
||||
id: validate
|
||||
env:
|
||||
EXPECTED_SHA: ${{ inputs.expected_sha }}
|
||||
INPUT_REF: ${{ inputs.ref }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
selected_revision="$(git rev-parse HEAD)"
|
||||
expected_sha="${EXPECTED_SHA,,}"
|
||||
trusted_reason=""
|
||||
|
||||
if [[ -n "${expected_sha// }" && ! "$expected_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "expected_sha must be a full 40-character SHA; got: ${EXPECTED_SHA}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "${expected_sha// }" && "${selected_revision,,}" != "$expected_sha" ]]; then
|
||||
echo "Ref '${INPUT_REF}' resolved to ${selected_revision}, expected ${EXPECTED_SHA}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
|
||||
if git merge-base --is-ancestor "$selected_revision" refs/remotes/origin/main; then
|
||||
trusted_reason="main-ancestor"
|
||||
elif git tag --points-at "$selected_revision" | grep -Eq '^v'; then
|
||||
trusted_reason="release-tag"
|
||||
elif [[ "$INPUT_REF" =~ ^release/[0-9]{4}\.[0-9]+\.[0-9]+$ ]]; then
|
||||
git fetch --no-tags origin "+refs/heads/${INPUT_REF}:refs/remotes/origin/${INPUT_REF}"
|
||||
release_branch_sha="$(git rev-parse "refs/remotes/origin/${INPUT_REF}")"
|
||||
if [[ "$selected_revision" == "$release_branch_sha" ]]; then
|
||||
trusted_reason="release-branch-head"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "$trusted_reason" ]]; then
|
||||
echo "Ref '${INPUT_REF}' resolved to $selected_revision, which is not trusted for this secret-bearing QA evidence run." >&2
|
||||
echo "Allowed refs must be on main, point to a release tag, or match a release branch head." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "selected_revision=$selected_revision" >> "$GITHUB_OUTPUT"
|
||||
echo "trusted_reason=$trusted_reason" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "### Target"
|
||||
echo
|
||||
echo "- Requested ref: \`${INPUT_REF}\`"
|
||||
echo "- Resolved SHA: \`$selected_revision\`"
|
||||
echo "- Trust reason: \`$trusted_reason\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
run_qa_profile:
|
||||
name: Generate QA profile evidence
|
||||
needs: validate_selected_ref
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
artifact_name: ${{ steps.evidence.outputs.artifact_name }}
|
||||
qa_profile: ${{ steps.profile.outputs.profile }}
|
||||
qa_exit_code: ${{ steps.evidence.outputs.qa_exit_code }}
|
||||
qa_passed: ${{ steps.evidence.outputs.qa_passed }}
|
||||
target_sha: ${{ steps.evidence.outputs.target_sha }}
|
||||
trusted_reason: ${{ steps.evidence.outputs.trusted_reason }}
|
||||
qa_evidence_path: ${{ steps.evidence.outputs.qa_evidence_path }}
|
||||
environment: qa-live-shared
|
||||
steps:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ needs.validate_selected_ref.outputs.selected_revision }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Validate QA profile input
|
||||
id: profile
|
||||
env:
|
||||
QA_PROFILE: ${{ inputs.qa_profile }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node --import tsx --input-type=module <<'NODE'
|
||||
import fs from "node:fs";
|
||||
import { readQaScorecardTaxonomyReport } from "./extensions/qa-lab/src/scorecard-taxonomy.ts";
|
||||
|
||||
const requested = process.env.QA_PROFILE?.trim() ?? "";
|
||||
if (!/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/.test(requested)) {
|
||||
throw new Error(`qa_profile must use a taxonomy profile id, got ${JSON.stringify(process.env.QA_PROFILE)}`);
|
||||
}
|
||||
|
||||
const taxonomy = readQaScorecardTaxonomyReport([]);
|
||||
const profile = taxonomy.profiles.find((entry) => entry.id === requested);
|
||||
if (!profile) {
|
||||
const available = taxonomy.profiles.map((entry) => entry.id).join(", ");
|
||||
throw new Error(`Unknown QA profile ${requested}. Available profiles: ${available}`);
|
||||
}
|
||||
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, `profile=${profile.id}\n`);
|
||||
NODE
|
||||
|
||||
echo "QA profile: \`${QA_PROFILE}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: node scripts/build-all.mjs qaRuntime
|
||||
|
||||
- name: Run QA profile
|
||||
id: run_profile
|
||||
env:
|
||||
QA_PROFILE: ${{ steps.profile.outputs.profile }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
output_dir=".artifacts/qa-e2e/profile-${QA_PROFILE}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
echo "output_dir=${output_dir}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
qa_exit_code=0
|
||||
pnpm openclaw qa run \
|
||||
--repo-root . \
|
||||
--qa-profile "${QA_PROFILE}" \
|
||||
--output-dir "${output_dir}" || qa_exit_code=$?
|
||||
|
||||
echo "qa_exit_code=${qa_exit_code}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate QA profile evidence
|
||||
id: evidence
|
||||
if: always()
|
||||
env:
|
||||
ARTIFACT_NAME: qa-profile-evidence-${{ steps.profile.outputs.profile }}-${{ needs.validate_selected_ref.outputs.selected_revision }}
|
||||
OUTPUT_DIR: ${{ steps.run_profile.outputs.output_dir }}
|
||||
QA_EXIT_CODE: ${{ steps.run_profile.outputs.qa_exit_code }}
|
||||
QA_PROFILE: ${{ steps.profile.outputs.profile }}
|
||||
REQUESTED_REF: ${{ inputs.ref }}
|
||||
TARGET_SHA: ${{ needs.validate_selected_ref.outputs.selected_revision }}
|
||||
TRUSTED_REASON: ${{ needs.validate_selected_ref.outputs.trusted_reason }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
node --input-type=module <<'NODE'
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const outputDir = process.env.OUTPUT_DIR;
|
||||
if (!outputDir) {
|
||||
throw new Error("OUTPUT_DIR is required");
|
||||
}
|
||||
if (!process.env.QA_EXIT_CODE) {
|
||||
throw new Error("QA_EXIT_CODE is required");
|
||||
}
|
||||
|
||||
const evidencePath = path.join(outputDir, "qa-evidence.json");
|
||||
const payload = JSON.parse(fs.readFileSync(evidencePath, "utf8"));
|
||||
if (payload.profile !== process.env.QA_PROFILE) {
|
||||
throw new Error(`qa-evidence.json profile must be ${process.env.QA_PROFILE}, got ${JSON.stringify(payload.profile)}`);
|
||||
}
|
||||
if (!payload.scorecard || !Array.isArray(payload.scorecard.categoryReports)) {
|
||||
throw new Error("QA profile qa-evidence.json must include scorecard.categoryReports");
|
||||
}
|
||||
if (payload.scorecard.categoryReports.length === 0) {
|
||||
throw new Error("QA profile qa-evidence.json scorecard has no category reports");
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
artifactName: process.env.ARTIFACT_NAME,
|
||||
generatedAt: new Date().toISOString(),
|
||||
qaProfile: process.env.QA_PROFILE,
|
||||
qaExitCode: Number(process.env.QA_EXIT_CODE),
|
||||
qaPassed: process.env.QA_EXIT_CODE === "0",
|
||||
requestedRef: process.env.REQUESTED_REF,
|
||||
targetSha: process.env.TARGET_SHA,
|
||||
trustedReason: process.env.TRUSTED_REASON,
|
||||
evidenceMode: payload.evidenceMode,
|
||||
qaEvidencePath: "qa-evidence.json",
|
||||
scorecard: {
|
||||
categories: payload.scorecard.categories,
|
||||
features: payload.scorecard.features,
|
||||
categoryReports: payload.scorecard.categoryReports.length,
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(outputDir, "qa-profile-evidence-manifest.json"),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
);
|
||||
NODE
|
||||
|
||||
echo "artifact_name=${ARTIFACT_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "qa_profile=${QA_PROFILE}" >> "$GITHUB_OUTPUT"
|
||||
echo "qa_exit_code=${QA_EXIT_CODE}" >> "$GITHUB_OUTPUT"
|
||||
if [[ "$QA_EXIT_CODE" == "0" ]]; then
|
||||
echo "qa_passed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "qa_passed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::QA profile '${QA_PROFILE}' completed with exit code ${QA_EXIT_CODE}; evidence was still validated and uploaded."
|
||||
fi
|
||||
echo "target_sha=${TARGET_SHA}" >> "$GITHUB_OUTPUT"
|
||||
echo "trusted_reason=${TRUSTED_REASON}" >> "$GITHUB_OUTPUT"
|
||||
echo "qa_evidence_path=qa-evidence.json" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "### QA profile evidence"
|
||||
echo
|
||||
echo "- Artifact: \`${ARTIFACT_NAME}\`"
|
||||
echo "- QA profile: \`${QA_PROFILE}\`"
|
||||
echo "- QA exit code: \`${QA_EXIT_CODE}\`"
|
||||
echo "- Target SHA: \`${TARGET_SHA}\`"
|
||||
echo "- Evidence path: \`${OUTPUT_DIR}/qa-evidence.json\`"
|
||||
echo "- Manifest: \`${OUTPUT_DIR}/qa-profile-evidence-manifest.json\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload QA profile evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: qa-profile-evidence-${{ steps.profile.outputs.profile }}-${{ needs.validate_selected_ref.outputs.selected_revision }}
|
||||
path: ${{ steps.run_profile.outputs.output_dir }}
|
||||
retention-days: 30
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Fail if configured QA gate failed
|
||||
if: always() && (github.event_name == 'workflow_dispatch' || inputs.fail_on_qa_failure)
|
||||
env:
|
||||
QA_EXIT_CODE: ${{ steps.run_profile.outputs.qa_exit_code }}
|
||||
QA_PROFILE: ${{ steps.profile.outputs.profile }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${QA_EXIT_CODE:-}" ]]; then
|
||||
echo "QA profile did not report an exit code." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$QA_EXIT_CODE" != "0" ]]; then
|
||||
echo "QA profile '${QA_PROFILE}' failed with exit code ${QA_EXIT_CODE}." >&2
|
||||
exit "$QA_EXIT_CODE"
|
||||
fi
|
||||
4
.github/workflows/real-behavior-proof.yml
vendored
4
.github/workflows/real-behavior-proof.yml
vendored
@@ -24,7 +24,9 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
# Old PR events can carry a stale base SHA that predates current
|
||||
# trusted checker scripts. Use the workflow revision instead.
|
||||
ref: ${{ github.workflow_sha }}
|
||||
persist-credentials: false
|
||||
- uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
|
||||
id: app-token
|
||||
|
||||
42
.github/workflows/tui-pty.yml
vendored
42
.github/workflows/tui-pty.yml
vendored
@@ -1,42 +0,0 @@
|
||||
name: TUI PTY
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "src/tui/**"
|
||||
- "scripts/dev/tui-pty-test-watch.ts"
|
||||
- "scripts/test-projects.test-support.mjs"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- "test/scripts/test-projects.test.ts"
|
||||
- "test/vitest/vitest.test-shards.mjs"
|
||||
- "test/vitest/vitest.tui-pty.config.ts"
|
||||
- ".github/workflows/tui-pty.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
tui-pty:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 8
|
||||
env:
|
||||
OPENCLAW_TUI_PTY_INCLUDE_LOCAL: "1"
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
|
||||
- name: Run TUI PTY tests
|
||||
run: timeout --kill-after=30s 240s node scripts/run-vitest.mjs run --config test/vitest/vitest.tui-pty.config.ts
|
||||
@@ -1,8 +1,16 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<permission
|
||||
android:name="${applicationId}.permission.RUN_VOICE_E2E"
|
||||
android:protectionLevel="signature" />
|
||||
|
||||
<uses-permission android:name="${applicationId}.permission.RUN_VOICE_E2E" />
|
||||
|
||||
<application>
|
||||
<receiver
|
||||
android:name=".VoiceE2eReceiver"
|
||||
android:exported="true">
|
||||
android:permission="${applicationId}.permission.RUN_VOICE_E2E"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="ai.openclaw.app.debug.RUN_VOICE_E2E" />
|
||||
</intent-filter>
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
import ai.openclaw.app.node.asObjectOrNull
|
||||
import ai.openclaw.app.node.asStringOrNull
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
data class GatewayExecApprovalSummary(
|
||||
val id: String,
|
||||
val commandText: String,
|
||||
val commandPreview: String?,
|
||||
val allowedDecisions: List<String>,
|
||||
val host: String?,
|
||||
val nodeId: String?,
|
||||
val agentId: String?,
|
||||
val createdAtMs: Long?,
|
||||
val expiresAtMs: Long?,
|
||||
val resolvingDecision: String? = null,
|
||||
val errorText: String? = null,
|
||||
)
|
||||
|
||||
internal fun parseGatewayExecApprovalListPayload(
|
||||
payloadJson: String,
|
||||
json: Json,
|
||||
): List<GatewayExecApprovalSummary> =
|
||||
try {
|
||||
(json.parseToJsonElement(payloadJson) as? JsonArray)
|
||||
?.mapNotNull(::parseGatewayExecApprovalListEntry)
|
||||
?.sortedBy { it.createdAtMs ?: Long.MAX_VALUE }
|
||||
.orEmpty()
|
||||
} catch (_: Throwable) {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
internal fun parseGatewayExecApprovalListEntry(item: JsonElement): GatewayExecApprovalSummary? {
|
||||
val obj = item.asObjectOrNull() ?: return null
|
||||
val id = obj["id"].asStringOrNull()?.trim().orEmpty()
|
||||
if (id.isEmpty()) return null
|
||||
val request = obj["request"].asObjectOrNull()
|
||||
val commandText = gatewayExecApprovalListCommandText(obj, request)
|
||||
return GatewayExecApprovalSummary(
|
||||
id = id,
|
||||
commandText = commandText,
|
||||
commandPreview = gatewayExecApprovalListCommandPreview(obj, request, commandText),
|
||||
allowedDecisions = emptyList(),
|
||||
host =
|
||||
request
|
||||
?.get("host")
|
||||
.asStringOrNull()
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() },
|
||||
nodeId =
|
||||
request
|
||||
?.get("nodeId")
|
||||
.asStringOrNull()
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() },
|
||||
agentId =
|
||||
request
|
||||
?.get("agentId")
|
||||
.asStringOrNull()
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() },
|
||||
createdAtMs = obj.long("createdAtMs"),
|
||||
expiresAtMs = obj.long("expiresAtMs"),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun parseGatewayExecApprovalDetail(
|
||||
obj: JsonObject,
|
||||
createdAtMs: Long?,
|
||||
): GatewayExecApprovalSummary? {
|
||||
val id = obj["id"].asStringOrNull()?.trim().orEmpty()
|
||||
if (id.isEmpty()) return null
|
||||
return GatewayExecApprovalSummary(
|
||||
id = id,
|
||||
commandText =
|
||||
obj["commandText"]
|
||||
.asStringOrNull()
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: "Command request",
|
||||
commandPreview =
|
||||
obj["commandPreview"]
|
||||
.asStringOrNull()
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() },
|
||||
allowedDecisions = gatewayExecApprovalAllowedDecisions(obj),
|
||||
host = obj["host"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
|
||||
nodeId = obj["nodeId"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
|
||||
agentId = obj["agentId"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
|
||||
createdAtMs = createdAtMs,
|
||||
expiresAtMs = obj.long("expiresAtMs"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun gatewayExecApprovalListCommandText(obj: JsonObject, request: JsonObject?): String =
|
||||
obj["commandText"]
|
||||
.asStringOrNull()
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: request
|
||||
?.get("command")
|
||||
.asStringOrNull()
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: "Command request"
|
||||
|
||||
private fun gatewayExecApprovalListCommandPreview(
|
||||
obj: JsonObject,
|
||||
request: JsonObject?,
|
||||
commandText: String,
|
||||
): String? {
|
||||
val preview =
|
||||
obj["commandPreview"]
|
||||
.asStringOrNull()
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: request
|
||||
?.get("commandPreview")
|
||||
.asStringOrNull()
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
return preview?.takeIf { it != commandText }
|
||||
}
|
||||
|
||||
private fun gatewayExecApprovalAllowedDecisions(request: JsonObject?): List<String> {
|
||||
val explicit = parseGatewayExecApprovalDecisions(request?.get("allowedDecisions") as? JsonArray)
|
||||
if (explicit.isNotEmpty()) return explicit
|
||||
val allowed =
|
||||
if (request
|
||||
?.get("ask")
|
||||
.asStringOrNull()
|
||||
?.trim()
|
||||
?.lowercase() == "always"
|
||||
) {
|
||||
listOf("allow-once", "deny")
|
||||
} else {
|
||||
listOf("allow-once", "allow-always", "deny")
|
||||
}
|
||||
val unavailable = parseGatewayExecApprovalDecisions(request?.get("unavailableDecisions") as? JsonArray).toSet()
|
||||
return allowed.filterNot { it == "allow-always" && it in unavailable }
|
||||
}
|
||||
|
||||
private fun parseGatewayExecApprovalDecisions(items: JsonArray?): List<String> =
|
||||
items
|
||||
?.mapNotNull { item ->
|
||||
when (item.asStringOrNull()?.trim()) {
|
||||
"allow-once" -> "allow-once"
|
||||
"allow-always" -> "allow-always"
|
||||
"deny" -> "deny"
|
||||
else -> null
|
||||
}
|
||||
}?.distinct()
|
||||
.orEmpty()
|
||||
|
||||
private fun JsonObject?.long(key: String): Long? = (this?.get(key) as? JsonPrimitive)?.content?.trim()?.toLongOrNull()
|
||||
@@ -204,6 +204,9 @@ class MainViewModel(
|
||||
val chatPendingToolCalls: StateFlow<List<ChatPendingToolCall>> = runtimeState(initial = emptyList()) { it.chatPendingToolCalls }
|
||||
val chatSessions: StateFlow<List<ChatSessionEntry>> = runtimeState(initial = emptyList()) { it.chatSessions }
|
||||
val pendingRunCount: StateFlow<Int> = runtimeState(initial = 0) { it.pendingRunCount }
|
||||
val execApprovals: StateFlow<List<GatewayExecApprovalSummary>> = runtimeState(initial = emptyList()) { it.execApprovals }
|
||||
val execApprovalsRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.execApprovalsRefreshing }
|
||||
val execApprovalsErrorText: StateFlow<String?> = runtimeState(initial = null) { it.execApprovalsErrorText }
|
||||
|
||||
val canvas: CanvasController
|
||||
get() = ensureRuntime().canvas
|
||||
@@ -537,6 +540,17 @@ class MainViewModel(
|
||||
ensureRuntime().refreshNodesDevices()
|
||||
}
|
||||
|
||||
fun refreshExecApprovals() {
|
||||
ensureRuntime().refreshExecApprovals()
|
||||
}
|
||||
|
||||
fun resolveExecApproval(
|
||||
id: String,
|
||||
decision: String,
|
||||
) {
|
||||
ensureRuntime().resolveExecApproval(id = id, decision = decision)
|
||||
}
|
||||
|
||||
fun refreshChannels() {
|
||||
ensureRuntime().refreshChannels()
|
||||
}
|
||||
|
||||
@@ -74,7 +74,9 @@ import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import java.util.Collections
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/**
|
||||
@@ -400,6 +402,15 @@ class NodeRuntime(
|
||||
private val _nodesDevicesErrorText = MutableStateFlow<String?>(null)
|
||||
val nodesDevicesErrorText: StateFlow<String?> = _nodesDevicesErrorText.asStateFlow()
|
||||
private val nodeApprovalRefreshGuard = GatewayNodeApprovalRefreshGuard()
|
||||
private val _execApprovals = MutableStateFlow<List<GatewayExecApprovalSummary>>(emptyList())
|
||||
val execApprovals: StateFlow<List<GatewayExecApprovalSummary>> = _execApprovals.asStateFlow()
|
||||
private val _execApprovalsRefreshing = MutableStateFlow(false)
|
||||
val execApprovalsRefreshing: StateFlow<Boolean> = _execApprovalsRefreshing.asStateFlow()
|
||||
private val _execApprovalsErrorText = MutableStateFlow<String?>(null)
|
||||
val execApprovalsErrorText: StateFlow<String?> = _execApprovalsErrorText.asStateFlow()
|
||||
private val execApprovalsRefreshSeq = AtomicLong(0)
|
||||
private val execApprovalsStateLock = Any()
|
||||
private val resolvedExecApprovalIds = Collections.newSetFromMap(ConcurrentHashMap<String, Boolean>())
|
||||
private val _channelsSummary = MutableStateFlow(GatewayChannelsSummary(channels = emptyList()))
|
||||
val channelsSummary: StateFlow<GatewayChannelsSummary> = _channelsSummary.asStateFlow()
|
||||
private val _channelsRefreshing = MutableStateFlow(false)
|
||||
@@ -449,6 +460,7 @@ class NodeRuntime(
|
||||
micCapture.onGatewayConnectionChanged(true)
|
||||
scope.launch {
|
||||
subscribeOperatorSessionEvents()
|
||||
refreshExecApprovalsFromGateway()
|
||||
refreshHomeCanvasOverviewIfConnected()
|
||||
if (voiceReplySpeakerLazy.isInitialized()) {
|
||||
voiceReplySpeaker.refreshConfig()
|
||||
@@ -478,6 +490,11 @@ class NodeRuntime(
|
||||
pendingDevices = emptyList(),
|
||||
pairedDevices = emptyList(),
|
||||
)
|
||||
invalidateExecApprovalRefreshes()
|
||||
resolvedExecApprovalIds.clear()
|
||||
_execApprovals.value = emptyList()
|
||||
_execApprovalsRefreshing.value = false
|
||||
_execApprovalsErrorText.value = null
|
||||
_channelsSummary.value = GatewayChannelsSummary(channels = emptyList())
|
||||
_dreamingSummary.value = GatewayDreamingSummary()
|
||||
_healthLogsSummary.value = GatewayHealthLogsSummary()
|
||||
@@ -825,6 +842,24 @@ class NodeRuntime(
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshExecApprovals() {
|
||||
scope.launch {
|
||||
refreshExecApprovalsFromGateway()
|
||||
}
|
||||
}
|
||||
|
||||
fun resolveExecApproval(
|
||||
id: String,
|
||||
decision: String,
|
||||
) {
|
||||
val normalizedId = id.trim()
|
||||
val normalizedDecision = decision.trim()
|
||||
if (normalizedId.isEmpty() || normalizedDecision.isEmpty()) return
|
||||
scope.launch {
|
||||
resolveExecApprovalOnGateway(id = normalizedId, decision = normalizedDecision)
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshChannels() {
|
||||
scope.launch {
|
||||
refreshChannelsFromGateway()
|
||||
@@ -1000,6 +1035,9 @@ class NodeRuntime(
|
||||
_isForeground.value = value
|
||||
if (value) {
|
||||
reconnectPreferredGatewayOnForeground()
|
||||
scope.launch {
|
||||
refreshExecApprovalsFromGateway()
|
||||
}
|
||||
} else {
|
||||
stopManualVoiceSession()
|
||||
publishNodePresenceAliveBeacon(NodePresenceAliveBeacon.Trigger.Background, throttleRecentSuccess = true)
|
||||
@@ -1829,11 +1867,47 @@ class NodeRuntime(
|
||||
if (event == "update.available") {
|
||||
_gatewayUpdateAvailable.value = parseGatewayUpdateAvailable(payloadJson)
|
||||
}
|
||||
handleExecApprovalGatewayEvent(event = event, payloadJson = payloadJson)
|
||||
micCapture.handleGatewayEvent(event, payloadJson)
|
||||
talkMode.handleGatewayEvent(event, payloadJson)
|
||||
chat.handleGatewayEvent(event, payloadJson)
|
||||
}
|
||||
|
||||
private fun handleExecApprovalGatewayEvent(
|
||||
event: String,
|
||||
payloadJson: String?,
|
||||
) {
|
||||
when (event) {
|
||||
"exec.approval.requested" -> {
|
||||
val approvalId = parseExecApprovalEventId(payloadJson)
|
||||
approvalId?.let(resolvedExecApprovalIds::remove)
|
||||
scope.launch {
|
||||
if (approvalId == null) {
|
||||
refreshExecApprovalsFromGateway()
|
||||
} else {
|
||||
refreshExecApprovalFromGateway(approvalId)
|
||||
}
|
||||
}
|
||||
}
|
||||
"exec.approval.resolved" -> {
|
||||
val approvalId = parseExecApprovalEventId(payloadJson) ?: return
|
||||
markExecApprovalResolved(approvalId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseExecApprovalEventId(payloadJson: String?): String? =
|
||||
try {
|
||||
payloadJson
|
||||
?.let { json.parseToJsonElement(it).asObjectOrNull() }
|
||||
?.get("id")
|
||||
.asStringOrNull()
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun parseGatewayUpdateAvailable(payloadJson: String?): GatewayUpdateAvailableSummary? {
|
||||
return try {
|
||||
val root = payloadJson?.let { json.parseToJsonElement(it).asObjectOrNull() }
|
||||
@@ -2080,6 +2154,196 @@ class NodeRuntime(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshExecApprovalsFromGateway() {
|
||||
val refreshGeneration = execApprovalsRefreshSeq.incrementAndGet()
|
||||
_execApprovalsRefreshing.value = true
|
||||
_execApprovalsErrorText.value = null
|
||||
if (!operatorConnected) {
|
||||
if (execApprovalsRefreshSeq.get() == refreshGeneration) {
|
||||
_execApprovals.value = emptyList()
|
||||
_execApprovalsRefreshing.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
val res = operatorSession.request("exec.approval.list", "{}")
|
||||
val existing = _execApprovals.value.associateBy { it.id }
|
||||
val rows =
|
||||
parseGatewayExecApprovalListPayload(res, json)
|
||||
.filterNot { it.id in resolvedExecApprovalIds }
|
||||
.map { row ->
|
||||
val hydrated =
|
||||
try {
|
||||
fetchExecApprovalDetailFromGateway(
|
||||
id = row.id,
|
||||
createdAtMs = row.createdAtMs ?: System.currentTimeMillis(),
|
||||
)
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} ?: row.copy(errorText = "Could not load approval details. Refresh and try again.")
|
||||
val current = existing[row.id]
|
||||
if (current == null) {
|
||||
hydrated
|
||||
} else {
|
||||
hydrated.copy(
|
||||
resolvingDecision = current.resolvingDecision,
|
||||
errorText = current.errorText ?: hydrated.errorText,
|
||||
)
|
||||
}
|
||||
}
|
||||
publishExecApprovalsIfCurrent(refreshGeneration, rows)
|
||||
} catch (_: Throwable) {
|
||||
if (execApprovalsRefreshSeq.get() == refreshGeneration) {
|
||||
_execApprovalsErrorText.value = "Could not load approvals."
|
||||
}
|
||||
} finally {
|
||||
if (execApprovalsRefreshSeq.get() == refreshGeneration) {
|
||||
_execApprovalsRefreshing.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshExecApprovalFromGateway(id: String) {
|
||||
if (!operatorConnected) return
|
||||
if (id in resolvedExecApprovalIds) return
|
||||
try {
|
||||
val current = _execApprovals.value.firstOrNull { it.id == id }
|
||||
val row =
|
||||
fetchExecApprovalDetailFromGateway(
|
||||
id = id,
|
||||
createdAtMs = current?.createdAtMs ?: System.currentTimeMillis(),
|
||||
) ?: return
|
||||
if (id in resolvedExecApprovalIds) return
|
||||
invalidateExecApprovalRefreshes()
|
||||
upsertExecApproval(row)
|
||||
} catch (_: Throwable) {
|
||||
refreshExecApprovalsFromGateway()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchExecApprovalDetailFromGateway(
|
||||
id: String,
|
||||
createdAtMs: Long,
|
||||
): GatewayExecApprovalSummary? {
|
||||
val params = buildJsonObject { put("id", JsonPrimitive(id)) }.toString()
|
||||
val res = operatorSession.request("exec.approval.get", params)
|
||||
val root = json.parseToJsonElement(res).asObjectOrNull() ?: return null
|
||||
return parseGatewayExecApprovalDetail(root, createdAtMs = createdAtMs)
|
||||
}
|
||||
|
||||
private suspend fun resolveExecApprovalOnGateway(
|
||||
id: String,
|
||||
decision: String,
|
||||
) {
|
||||
synchronized(execApprovalsStateLock) {
|
||||
if (!operatorConnected || id in resolvedExecApprovalIds) return
|
||||
val currentRows = _execApprovals.value
|
||||
if (currentRows.none { it.id == id }) return
|
||||
invalidateExecApprovalRefreshes()
|
||||
_execApprovals.value =
|
||||
currentRows.map { row ->
|
||||
if (row.id == id) row.copy(resolvingDecision = decision, errorText = null) else row
|
||||
}
|
||||
}
|
||||
try {
|
||||
val params =
|
||||
buildJsonObject {
|
||||
put("id", JsonPrimitive(id))
|
||||
put("decision", JsonPrimitive(decision))
|
||||
}.toString()
|
||||
operatorSession.request("exec.approval.resolve", params)
|
||||
markExecApprovalResolved(id)
|
||||
} catch (_: Throwable) {
|
||||
synchronized(execApprovalsStateLock) {
|
||||
if (!operatorConnected || id in resolvedExecApprovalIds) return
|
||||
_execApprovals.value =
|
||||
_execApprovals.value.map { row ->
|
||||
if (row.id == id) {
|
||||
row.copy(resolvingDecision = null, errorText = "Could not resolve approval. Refresh and try again.")
|
||||
} else {
|
||||
row
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun upsertExecApproval(row: GatewayExecApprovalSummary) {
|
||||
synchronized(execApprovalsStateLock) {
|
||||
if (!operatorConnected || row.id in resolvedExecApprovalIds) return
|
||||
if (row.isExpiredExecApproval()) return
|
||||
val rows = _execApprovals.value
|
||||
val replaced = rows.any { it.id == row.id }
|
||||
val nextRows =
|
||||
(
|
||||
if (replaced) {
|
||||
rows.map { current ->
|
||||
if (current.id == row.id) {
|
||||
row.copy(
|
||||
resolvingDecision = current.resolvingDecision,
|
||||
errorText = current.errorText,
|
||||
)
|
||||
} else {
|
||||
current
|
||||
}
|
||||
}
|
||||
} else {
|
||||
rows + row
|
||||
}
|
||||
).filterActiveExecApprovals()
|
||||
.sortedBy { it.createdAtMs ?: Long.MAX_VALUE }
|
||||
_execApprovals.value = nextRows
|
||||
scheduleExecApprovalExpiryPrune(nextRows)
|
||||
}
|
||||
}
|
||||
|
||||
private fun invalidateExecApprovalRefreshes() {
|
||||
execApprovalsRefreshSeq.incrementAndGet()
|
||||
_execApprovalsRefreshing.value = false
|
||||
}
|
||||
|
||||
private fun markExecApprovalResolved(id: String) {
|
||||
synchronized(execApprovalsStateLock) {
|
||||
resolvedExecApprovalIds.add(id)
|
||||
invalidateExecApprovalRefreshes()
|
||||
_execApprovals.value = _execApprovals.value.filterNot { it.id == id }
|
||||
}
|
||||
}
|
||||
|
||||
private fun publishExecApprovalsIfCurrent(
|
||||
refreshGeneration: Long,
|
||||
rows: List<GatewayExecApprovalSummary>,
|
||||
) {
|
||||
synchronized(execApprovalsStateLock) {
|
||||
if (execApprovalsRefreshSeq.get() == refreshGeneration && operatorConnected) {
|
||||
val nextRows = rows.filterNot { it.id in resolvedExecApprovalIds }.filterActiveExecApprovals()
|
||||
_execApprovals.value = nextRows
|
||||
scheduleExecApprovalExpiryPrune(nextRows)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleExecApprovalExpiryPrune(rows: List<GatewayExecApprovalSummary>) {
|
||||
val now = System.currentTimeMillis()
|
||||
val nextExpiry = rows.mapNotNull { it.expiresAtMs }.filter { it > now }.minOrNull() ?: return
|
||||
scope.launch {
|
||||
delay((nextExpiry - now + 250).coerceAtLeast(0))
|
||||
pruneExpiredExecApprovals()
|
||||
}
|
||||
}
|
||||
|
||||
private fun pruneExpiredExecApprovals() {
|
||||
synchronized(execApprovalsStateLock) {
|
||||
_execApprovals.value = _execApprovals.value.filterActiveExecApprovals()
|
||||
}
|
||||
}
|
||||
|
||||
private fun GatewayExecApprovalSummary.isExpiredExecApproval(nowMs: Long = System.currentTimeMillis()): Boolean = expiresAtMs?.let { it <= nowMs } == true
|
||||
|
||||
private fun List<GatewayExecApprovalSummary>.filterActiveExecApprovals(
|
||||
nowMs: Long = System.currentTimeMillis(),
|
||||
): List<GatewayExecApprovalSummary> = filterNot { it.isExpiredExecApproval(nowMs) }
|
||||
|
||||
private fun invalidateNodeCapabilityApprovalState() {
|
||||
val refreshGeneration = nodeApprovalRefreshGuard.begin()
|
||||
nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
|
||||
@@ -2194,12 +2458,19 @@ class NodeRuntime(
|
||||
}.orEmpty()
|
||||
|
||||
private fun parseGatewayLogEntry(line: String): GatewayLogEntry {
|
||||
val sanitizedLine = sanitizeGatewayLogText(line)
|
||||
val root =
|
||||
try {
|
||||
json.parseToJsonElement(line).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} ?: return GatewayLogEntry(time = null, level = null, subsystem = null, message = line.trim().ifEmpty { "Empty log entry" })
|
||||
} ?: return GatewayLogEntry(
|
||||
time = null,
|
||||
level = null,
|
||||
subsystem = null,
|
||||
message = sanitizedLine.trim().ifEmpty { "Empty log entry" },
|
||||
raw = sanitizedLine,
|
||||
)
|
||||
val meta = root["_meta"].asObjectOrNull()
|
||||
val time = root["time"].asStringOrNull() ?: meta?.get("date").asStringOrNull()
|
||||
val level = normalizeLogLevel(meta?.get("logLevelName").asStringOrNull() ?: meta?.get("level").asStringOrNull())
|
||||
@@ -2217,7 +2488,7 @@ class NodeRuntime(
|
||||
?: root["message"].asStringOrNull()
|
||||
?: line
|
||||
val normalizedMessage =
|
||||
message
|
||||
sanitizeGatewayLogText(message)
|
||||
.trim()
|
||||
.replace(Regex("\\s+"), " ")
|
||||
.take(240)
|
||||
@@ -2225,8 +2496,9 @@ class NodeRuntime(
|
||||
return GatewayLogEntry(
|
||||
time = time,
|
||||
level = level,
|
||||
subsystem = subsystem?.trim()?.takeIf { it.isNotEmpty() },
|
||||
subsystem = subsystem?.let(::sanitizeGatewayLogText)?.trim()?.takeIf { it.isNotEmpty() },
|
||||
message = normalizedMessage,
|
||||
raw = sanitizedLine,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2315,6 +2587,7 @@ class NodeRuntime(
|
||||
if (name.isEmpty()) return@mapNotNull null
|
||||
val missing = obj["missing"].asObjectOrNull()
|
||||
GatewaySkillSummary(
|
||||
skillKey = obj["skillKey"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: name,
|
||||
name = name,
|
||||
description = obj["description"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
|
||||
source = obj["source"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: "unknown",
|
||||
@@ -2765,11 +3038,6 @@ internal fun resolveOperatorSessionConnectAuth(
|
||||
)
|
||||
}
|
||||
|
||||
internal fun shouldConnectOperatorSession(
|
||||
auth: NodeRuntime.GatewayConnectAuth,
|
||||
storedOperatorToken: String?,
|
||||
): Boolean = resolveOperatorSessionConnectAuth(auth, storedOperatorToken) != null
|
||||
|
||||
private enum class HomeCanvasGatewayState {
|
||||
Connected,
|
||||
Connecting,
|
||||
@@ -2842,6 +3110,7 @@ data class GatewaySkillsSummary(
|
||||
)
|
||||
|
||||
data class GatewaySkillSummary(
|
||||
val skillKey: String,
|
||||
val name: String,
|
||||
val description: String?,
|
||||
val source: String,
|
||||
@@ -3039,8 +3308,19 @@ data class GatewayLogEntry(
|
||||
val level: String?,
|
||||
val subsystem: String?,
|
||||
val message: String,
|
||||
val raw: String,
|
||||
)
|
||||
|
||||
private val gatewayAnsiControlPattern = Regex("\\u001B\\[[0-?]*[ -/]*[@-~]")
|
||||
private val gatewayEscapedAnsiControlPattern = Regex("""\\u001[Bb]\[[0-?]*[ -/]*[@-~]""")
|
||||
private val gatewayVisibleSgrPattern = Regex("\\[(?:0|\\d{1,3}(?:;\\d{1,3})*)m(?!])")
|
||||
|
||||
internal fun sanitizeGatewayLogText(value: String): String =
|
||||
value
|
||||
.replace(gatewayAnsiControlPattern, "")
|
||||
.replace(gatewayEscapedAnsiControlPattern, "")
|
||||
.replace(gatewayVisibleSgrPattern, "")
|
||||
|
||||
private fun JsonObject?.long(key: String): Long? = (this?.get(key) as? JsonPrimitive)?.content?.trim()?.toLongOrNull()
|
||||
|
||||
private fun JsonObject?.double(key: String): Double? = (this?.get(key) as? JsonPrimitive)?.content?.trim()?.toDoubleOrNull()
|
||||
|
||||
@@ -393,12 +393,6 @@ class SecurePrefs(
|
||||
return stored?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
/** Saves the paired gateway token under the current Android instance id. */
|
||||
fun saveGatewayToken(token: String) {
|
||||
val key = "gateway.token.${_instanceId.value}"
|
||||
securePrefs.edit { putString(key, token.trim()) }
|
||||
}
|
||||
|
||||
/** Loads the bootstrap token used during gateway setup and device-token handoff. */
|
||||
fun loadGatewayBootstrapToken(): String? {
|
||||
val key = "gateway.bootstrapToken.${_instanceId.value}"
|
||||
|
||||
@@ -6,14 +6,6 @@ internal fun normalizeMainKey(raw: String?): String {
|
||||
return if (!trimmed.isNullOrEmpty()) trimmed else "main"
|
||||
}
|
||||
|
||||
/** Accepts only gateway session keys that can represent the main chat stream. */
|
||||
internal fun isCanonicalMainSessionKey(raw: String?): Boolean {
|
||||
val trimmed = raw?.trim().orEmpty()
|
||||
if (trimmed.isEmpty()) return false
|
||||
if (trimmed == "global") return true
|
||||
return trimmed.startsWith("agent:")
|
||||
}
|
||||
|
||||
/** Extracts the agent id from canonical agent-scoped main session keys. */
|
||||
internal fun resolveAgentIdFromMainSessionKey(raw: String?): String? {
|
||||
val trimmed = raw?.trim().orEmpty()
|
||||
|
||||
@@ -538,8 +538,7 @@ class ChatController internal constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseEventSessionEntry(payload: JsonObject): ChatSessionEntry? =
|
||||
payload["session"].asObjectOrNull()?.let(::parseSessionEntry) ?: parseSessionEntry(payload)
|
||||
private fun parseEventSessionEntry(payload: JsonObject): ChatSessionEntry? = payload["session"].asObjectOrNull()?.let(::parseSessionEntry) ?: parseSessionEntry(payload)
|
||||
|
||||
private fun handleAgentEvent(payloadJson: String) {
|
||||
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
|
||||
@@ -747,9 +746,16 @@ class ChatController internal constructor(
|
||||
): ChatSessionEntry? {
|
||||
if (obj == null) return null
|
||||
val key =
|
||||
obj["key"].asStringOrNull()?.trim().orEmpty()
|
||||
.ifEmpty { obj["sessionKey"].asStringOrNull()?.trim().orEmpty() }
|
||||
.ifEmpty { fallbackKey?.trim().orEmpty() }
|
||||
obj["key"]
|
||||
.asStringOrNull()
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
.ifEmpty {
|
||||
obj["sessionKey"]
|
||||
.asStringOrNull()
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
}.ifEmpty { fallbackKey?.trim().orEmpty() }
|
||||
if (key.isEmpty()) return null
|
||||
return ChatSessionEntry(
|
||||
key = key,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package ai.openclaw.app.gateway
|
||||
|
||||
import android.annotation.TargetApi
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.DnsResolver
|
||||
@@ -12,6 +11,7 @@ import android.net.nsd.NsdServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.CancellationSignal
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -56,7 +56,7 @@ private fun createDnsResolver(context: Context): DnsResolver =
|
||||
createLegacyDnsResolver()
|
||||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.CINNAMON_BUN)
|
||||
@RequiresApi(Build.VERSION_CODES.CINNAMON_BUN)
|
||||
private fun createContextDnsResolver(context: Context): DnsResolver = DnsResolver(context, null)
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@@ -166,14 +166,6 @@ class GatewayDiscovery(
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopLocalDiscovery() {
|
||||
try {
|
||||
nsd.stopServiceDiscovery(discoveryListener)
|
||||
} catch (_: Throwable) {
|
||||
// ignore (best-effort)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startUnicastDiscovery(domain: String) {
|
||||
unicastJob =
|
||||
scope.launch(Dispatchers.IO) {
|
||||
@@ -197,7 +189,7 @@ class GatewayDiscovery(
|
||||
}
|
||||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
||||
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
||||
private fun resolveWithServiceInfoCallback(serviceInfo: NsdServiceInfo) {
|
||||
val serviceName = BonjourEscapes.decode(serviceInfo.serviceName)
|
||||
val id = stableId(serviceName, "local.")
|
||||
|
||||
@@ -260,24 +260,6 @@ class GatewaySession(
|
||||
currentConnection?.closeQuietly()
|
||||
}
|
||||
|
||||
fun currentCanvasHostUrl(): String? = pluginSurfaceUrls["canvas"]
|
||||
|
||||
/** Refreshes the canvas plugin surface URL and caches the normalized Android-reachable URL. */
|
||||
suspend fun refreshCanvasHostUrl(timeoutMs: Long = 8_000): String? {
|
||||
val refreshed =
|
||||
refreshPluginSurfaceUrl(
|
||||
method = "node.pluginSurface.refresh",
|
||||
params = buildJsonObject { put("surface", JsonPrimitive("canvas")) },
|
||||
timeoutMs = timeoutMs,
|
||||
)
|
||||
if (!refreshed.isNullOrBlank()) {
|
||||
pluginSurfaceUrls = pluginSurfaceUrls + ("canvas" to refreshed)
|
||||
}
|
||||
return refreshed
|
||||
}
|
||||
|
||||
fun currentMainSessionKey(): String? = mainSessionKey
|
||||
|
||||
/** Sends a best-effort node.event and returns false instead of throwing on failure. */
|
||||
suspend fun sendNodeEvent(
|
||||
event: String,
|
||||
@@ -297,28 +279,6 @@ class GatewaySession(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshPluginSurfaceUrl(
|
||||
method: String,
|
||||
params: JsonElement?,
|
||||
timeoutMs: Long,
|
||||
): String? {
|
||||
val conn = currentConnection ?: return null
|
||||
return try {
|
||||
val res = conn.request(method, params, timeoutMs)
|
||||
if (!res.ok) return null
|
||||
val obj = res.payloadJson?.let { json.parseToJsonElement(it).asObjectOrNull() } ?: return null
|
||||
val raw =
|
||||
obj["pluginSurfaceUrls"]
|
||||
.asObjectOrNull()
|
||||
?.get("canvas")
|
||||
.asStringOrNull()
|
||||
normalizeCanvasHostUrl(raw, conn.endpoint, isTlsConnection = conn.tls != null)
|
||||
} catch (err: Throwable) {
|
||||
Log.d("OpenClawGateway", "$method failed: ${err.message ?: err::class.java.simpleName}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** Sends node.event and preserves the gateway RPC error shape for callers that need diagnostics. */
|
||||
suspend fun sendNodeEventDetailed(
|
||||
event: String,
|
||||
|
||||
@@ -97,8 +97,6 @@ class CanvasController {
|
||||
|
||||
fun currentUrl(): String? = url
|
||||
|
||||
fun isDefaultCanvas(): Boolean = url == null
|
||||
|
||||
fun setDebugStatusEnabled(enabled: Boolean) {
|
||||
debugStatusEnabled = enabled
|
||||
applyDebugStatus()
|
||||
@@ -205,24 +203,6 @@ class CanvasController {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun snapshotPngBase64(maxWidth: Int?): String =
|
||||
withContext(Dispatchers.Main) {
|
||||
val wv = webView ?: throw IllegalStateException("no webview")
|
||||
val bmp = wv.captureBitmap()
|
||||
try {
|
||||
val scaled = bmp.scaleForMaxWidth(maxWidth)
|
||||
try {
|
||||
val out = ByteArrayOutputStream()
|
||||
scaled.compress(Bitmap.CompressFormat.PNG, 100, out)
|
||||
Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
|
||||
} finally {
|
||||
if (scaled !== bmp) scaled.recycle()
|
||||
}
|
||||
} finally {
|
||||
bmp.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
/** Captures the WebView as PNG/JPEG base64 with optional width and quality bounds. */
|
||||
suspend fun snapshotBase64(
|
||||
format: SnapshotFormat,
|
||||
|
||||
@@ -4,6 +4,7 @@ import ai.openclaw.app.BuildConfig
|
||||
import ai.openclaw.app.SensitiveFeatureConfig
|
||||
import ai.openclaw.app.gateway.GatewaySession
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -63,7 +64,7 @@ private class AndroidDeviceAppSource(
|
||||
|
||||
val appInfos =
|
||||
if (includeNonLaunchable) {
|
||||
packageManager.getInstalledApplications(PackageManager.MATCH_ALL)
|
||||
visibleInstalledApplications(packageManager)
|
||||
} else {
|
||||
launchablePackages.mapNotNull { packageName ->
|
||||
runCatching { packageManager.getApplicationInfo(packageName, 0) }.getOrNull()
|
||||
@@ -90,6 +91,13 @@ private class AndroidDeviceAppSource(
|
||||
.sortedWith(compareBy<DeviceAppEntry> { it.label.lowercase() }.thenBy { it.packageName })
|
||||
.toList()
|
||||
}
|
||||
|
||||
@SuppressLint("QueryPermissionsNeeded")
|
||||
private fun visibleInstalledApplications(packageManager: PackageManager): List<ApplicationInfo> {
|
||||
// Android package visibility intentionally bounds this result to packages the app can see.
|
||||
// OpenClaw should not request QUERY_ALL_PACKAGES for this optional device-context surface.
|
||||
return packageManager.getInstalledApplications(PackageManager.MATCH_ALL)
|
||||
}
|
||||
}
|
||||
|
||||
private data class DeviceAppsRequest(
|
||||
|
||||
@@ -109,6 +109,3 @@ fun normalizeMainKey(raw: String?): String? {
|
||||
val trimmed = raw?.trim().orEmpty()
|
||||
return if (trimmed.isEmpty()) null else trimmed
|
||||
}
|
||||
|
||||
/** Returns true only for the canonical main-session key understood by gateway UI. */
|
||||
fun isCanonicalMainSessionKey(key: String): Boolean = key == "main"
|
||||
|
||||
@@ -5,6 +5,7 @@ import ai.openclaw.app.GatewayModelSummary
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.ui.design.ClawEmptyState
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawPlainIconButton
|
||||
import ai.openclaw.app.ui.design.ClawScaffold
|
||||
import ai.openclaw.app.ui.design.ClawSeparatedColumn
|
||||
import ai.openclaw.app.ui.design.ClawTextField
|
||||
@@ -94,7 +95,11 @@ internal fun CommandPalette(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
CommandIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close search", onClick = onDismiss)
|
||||
ClawPlainIconButton(
|
||||
icon = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Close search",
|
||||
onClick = onDismiss,
|
||||
)
|
||||
Text(text = "Search", style = ClawTheme.type.title, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), textAlign = TextAlign.Center)
|
||||
CommandAvatar(text = "OC")
|
||||
}
|
||||
@@ -262,19 +267,6 @@ private fun CommandSessionListRow(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CommandIconButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(onClick = onClick, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CommandAvatar(text: String) {
|
||||
Surface(
|
||||
|
||||
@@ -5,8 +5,7 @@ import ai.openclaw.app.GatewayDreamingSummary
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawSecondaryButton
|
||||
import ai.openclaw.app.ui.design.ClawStatus
|
||||
import ai.openclaw.app.ui.design.ClawStatusPill
|
||||
import ai.openclaw.app.ui.design.ClawStatusRow
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -92,19 +91,19 @@ private fun DreamingPanel(summary: GatewayDreamingSummary) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
Column {
|
||||
DreamingHealthRow(
|
||||
ClawStatusRow(
|
||||
title = "Memory Store",
|
||||
value = if (summary.storeHealthy) "Healthy" else "Needs attention",
|
||||
healthy = summary.storeHealthy,
|
||||
)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
DreamingHealthRow(
|
||||
ClawStatusRow(
|
||||
title = "Signal Index",
|
||||
value = if (summary.phaseSignalHealthy) "Healthy" else "Needs attention",
|
||||
healthy = summary.phaseSignalHealthy,
|
||||
)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
DreamingHealthRow(
|
||||
ClawStatusRow(
|
||||
title = "Promoted",
|
||||
value = "${summary.promotedToday} today · ${summary.promotedTotal} total",
|
||||
healthy = true,
|
||||
@@ -115,23 +114,6 @@ private fun DreamingPanel(summary: GatewayDreamingSummary) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DreamingHealthRow(
|
||||
title: String,
|
||||
value: String,
|
||||
healthy: Boolean,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
Box(modifier = Modifier.size(7.dp))
|
||||
Text(text = title, style = ClawTheme.type.body, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1)
|
||||
ClawStatusPill(text = value, status = if (healthy) ClawStatus.Success else ClawStatus.Warning)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DreamDiaryPanel(summary: GatewayDreamingSummary) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
|
||||
@@ -206,9 +206,6 @@ internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? {
|
||||
}
|
||||
}
|
||||
|
||||
/** Extracts a setup code from QR scanner text when the embedded endpoint is valid. */
|
||||
internal fun resolveScannedSetupCode(rawInput: String): String? = resolveScannedSetupCodeResult(rawInput).setupCode
|
||||
|
||||
/** Resolves QR scanner text to setup-code or validation error for UI copy. */
|
||||
internal fun resolveScannedSetupCodeResult(rawInput: String): GatewayScannedSetupCodeResult {
|
||||
val setupCode =
|
||||
|
||||
@@ -7,7 +7,10 @@ import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawSecondaryButton
|
||||
import ai.openclaw.app.ui.design.ClawStatus
|
||||
import ai.openclaw.app.ui.design.ClawStatusPill
|
||||
import ai.openclaw.app.ui.design.ClawStatusRow
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
@@ -15,13 +18,18 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -43,6 +51,7 @@ internal fun HealthLogsSettingsScreen(
|
||||
val logsSummary by viewModel.healthLogsSummary.collectAsState()
|
||||
val logsRefreshing by viewModel.healthLogsRefreshing.collectAsState()
|
||||
val logsErrorText by viewModel.healthLogsErrorText.collectAsState()
|
||||
var selectedLogEntry by remember { mutableStateOf<GatewayLogEntry?>(null) }
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
@@ -52,6 +61,11 @@ internal fun HealthLogsSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
selectedLogEntry?.let { entry ->
|
||||
GatewayLogDetailSettingsScreen(entry = entry, onBack = { selectedLogEntry = null })
|
||||
return
|
||||
}
|
||||
|
||||
SettingsDetailFrame(
|
||||
title = "Health",
|
||||
subtitle = "Gateway status, phone node readiness, and recent log stream.",
|
||||
@@ -93,7 +107,46 @@ internal fun HealthLogsSettingsScreen(
|
||||
Text(text = error, style = ClawTheme.type.body, color = ClawTheme.colors.warning)
|
||||
}
|
||||
}
|
||||
GatewayLogsPanel(isConnected = isConnected, summary = logsSummary)
|
||||
GatewayLogsPanel(isConnected = isConnected, summary = logsSummary, onLogClick = { selectedLogEntry = it })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GatewayLogDetailSettingsScreen(
|
||||
entry: GatewayLogEntry,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
BackHandler(onBack = onBack)
|
||||
SettingsDetailFrame(
|
||||
title = "Log Entry",
|
||||
subtitle = "Readable gateway log detail.",
|
||||
icon = Icons.Default.Settings,
|
||||
onBack = onBack,
|
||||
) {
|
||||
SettingsMetricPanel(
|
||||
rows =
|
||||
listOf(
|
||||
SettingsMetric("Time", compactLogTime(entry.time)),
|
||||
SettingsMetric("Level", entry.level?.uppercase() ?: "LOG"),
|
||||
SettingsMetric("Subsystem", entry.subsystem ?: "Unknown"),
|
||||
),
|
||||
)
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(text = "Message", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = entry.message, style = ClawTheme.type.body, color = ClawTheme.colors.text)
|
||||
}
|
||||
}
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(text = "Raw", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(
|
||||
text = entry.raw.take(4_000),
|
||||
style = ClawTheme.type.caption,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,41 +166,26 @@ private fun HealthStatusPanel(
|
||||
) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
Column {
|
||||
HealthStatusRow(title = "Gateway", value = gateway, healthy = isConnected)
|
||||
ClawStatusRow(title = "Gateway", value = gateway, healthy = isConnected)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
HealthStatusRow(title = "Phone Node", value = node, healthy = isNodeConnected)
|
||||
ClawStatusRow(title = "Phone Node", value = node, healthy = isNodeConnected)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
HealthStatusRow(title = "Chat", value = chat, healthy = chatHealthOk)
|
||||
ClawStatusRow(title = "Chat", value = chat, healthy = chatHealthOk)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
HealthStatusRow(title = "Models", value = models, healthy = modelsReady)
|
||||
ClawStatusRow(title = "Models", value = models, healthy = modelsReady)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
HealthStatusRow(title = "Voice", value = voice, healthy = voiceReady)
|
||||
ClawStatusRow(title = "Voice", value = voice, healthy = voiceReady)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
HealthStatusRow(title = "Runs", value = runs, healthy = true)
|
||||
ClawStatusRow(title = "Runs", value = runs, healthy = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HealthStatusRow(
|
||||
title: String,
|
||||
value: String,
|
||||
healthy: Boolean,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
Text(text = title, style = ClawTheme.type.body, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1)
|
||||
ClawStatusPill(text = value, status = if (healthy) ClawStatus.Success else ClawStatus.Warning)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GatewayLogsPanel(
|
||||
isConnected: Boolean,
|
||||
summary: GatewayHealthLogsSummary,
|
||||
onLogClick: (GatewayLogEntry) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
||||
@@ -170,7 +208,7 @@ private fun GatewayLogsPanel(
|
||||
val entries = summary.entries.takeLast(12)
|
||||
Column {
|
||||
entries.forEachIndexed { index, entry ->
|
||||
GatewayLogRow(entry = entry)
|
||||
GatewayLogRow(entry = entry, onClick = { onLogClick(entry) })
|
||||
if (index != entries.lastIndex) {
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
}
|
||||
@@ -185,9 +223,16 @@ private fun GatewayLogsPanel(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GatewayLogRow(entry: GatewayLogEntry) {
|
||||
private fun GatewayLogRow(
|
||||
entry: GatewayLogEntry,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClickLabel = "Open log entry", onClick = onClick)
|
||||
.padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
@@ -199,6 +244,11 @@ private fun GatewayLogRow(entry: GatewayLogEntry) {
|
||||
}
|
||||
}
|
||||
ClawStatusPill(text = entry.level?.uppercase() ?: "LOG", status = logLevelStatus(entry.level))
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = ClawTheme.colors.textSubtle,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1378,7 +1378,12 @@ private fun rememberPermissionState(
|
||||
photosGranted = permissions[photosPermission] ?: photosGranted
|
||||
contactsGranted = permissions[Manifest.permission.READ_CONTACTS] ?: contactsGranted
|
||||
calendarGranted = permissions[Manifest.permission.READ_CALENDAR] ?: calendarGranted
|
||||
notificationsGranted = permissions[Manifest.permission.POST_NOTIFICATIONS] ?: notificationsGranted
|
||||
notificationsGranted =
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
permissions[Manifest.permission.POST_NOTIFICATIONS] ?: notificationsGranted
|
||||
} else {
|
||||
true
|
||||
}
|
||||
motionGranted = permissions[Manifest.permission.ACTIVITY_RECOGNITION] ?: motionGranted
|
||||
smsGranted =
|
||||
(permissions[Manifest.permission.SEND_SMS] ?: smsGranted) &&
|
||||
|
||||
@@ -9,14 +9,10 @@ import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
|
||||
private val LocalOpenClawDarkTheme = staticCompositionLocalOf { true }
|
||||
|
||||
/**
|
||||
* App theme wrapper that installs dynamic Material colors and legacy mobile color tokens.
|
||||
*/
|
||||
@@ -34,7 +30,6 @@ fun OpenClawTheme(
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalMobileColors provides mobileColors,
|
||||
LocalOpenClawDarkTheme provides isDark,
|
||||
) {
|
||||
MaterialTheme(colorScheme = colorScheme, content = content)
|
||||
}
|
||||
@@ -55,21 +50,3 @@ internal fun OpenClawSystemBarAppearance(lightAppearance: Boolean) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlay background token tuned for panels floating over the mobile canvas.
|
||||
*/
|
||||
@Composable
|
||||
fun overlayContainerColor(): Color {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val isDark = LocalOpenClawDarkTheme.current
|
||||
val base = if (isDark) scheme.surfaceContainerLow else scheme.surfaceContainerHigh
|
||||
// Light mode keeps overlays away from pure-white glare on the app canvas.
|
||||
return if (isDark) base else base.copy(alpha = 0.88f)
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlay icon token kept next to overlayContainerColor for callers outside the design package.
|
||||
*/
|
||||
@Composable
|
||||
fun overlayIconColor(): Color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
|
||||
@@ -2,6 +2,7 @@ package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.ui.design.ClawEmptyState
|
||||
import ai.openclaw.app.ui.design.ClawPlainIconButton
|
||||
import ai.openclaw.app.ui.design.ClawPrimaryButton
|
||||
import ai.openclaw.app.ui.design.ClawScaffold
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
@@ -55,7 +56,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/** Session browser for recent and currently-live chat sessions. */
|
||||
/** Session browser for recent and current chat sessions. */
|
||||
@Composable
|
||||
internal fun SessionsScreen(
|
||||
viewModel: MainViewModel,
|
||||
@@ -73,7 +74,7 @@ internal fun SessionsScreen(
|
||||
.let { rows ->
|
||||
when (filter) {
|
||||
SessionFilter.Recent -> rows
|
||||
SessionFilter.Live -> rows.filter { it.key == chatSessionKey }
|
||||
SessionFilter.Current -> rows.filter { it.key == chatSessionKey }
|
||||
}
|
||||
}.let { rows ->
|
||||
if (recentFirst) {
|
||||
@@ -92,12 +93,12 @@ internal fun SessionsScreen(
|
||||
}
|
||||
|
||||
ClawScaffold(
|
||||
contentPadding = PaddingValues(start = 20.dp, top = 14.dp, end = 20.dp, bottom = 6.dp),
|
||||
contentPadding = PaddingValues(start = 16.dp, top = 10.dp, end = 16.dp, bottom = 4.dp),
|
||||
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(7.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(9.dp),
|
||||
contentPadding = PaddingValues(bottom = 4.dp),
|
||||
) {
|
||||
item {
|
||||
@@ -106,16 +107,16 @@ internal fun SessionsScreen(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(text = "Sessions", style = ClawTheme.type.display.copy(fontSize = 17.4.sp, lineHeight = 21.sp), color = ClawTheme.colors.text, modifier = Modifier.weight(1f))
|
||||
SessionPlainIconButton(icon = Icons.Default.Search, contentDescription = "Search sessions", onClick = onOpenCommand)
|
||||
SessionPlainIconButton(icon = Icons.Default.SwapVert, contentDescription = "Reverse session sort", onClick = { recentFirst = !recentFirst })
|
||||
Text(text = "Sessions", style = ClawTheme.type.display.copy(fontSize = 24.sp, lineHeight = 28.sp), color = ClawTheme.colors.text, modifier = Modifier.weight(1f))
|
||||
ClawPlainIconButton(icon = Icons.Default.Search, contentDescription = "Search sessions", onClick = onOpenCommand)
|
||||
ClawPlainIconButton(icon = Icons.Default.SwapVert, contentDescription = "Reverse session sort", onClick = { recentFirst = !recentFirst })
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
FilterPill(text = "Recent", icon = Icons.Outlined.AccessTime, active = filter == SessionFilter.Recent, onClick = { filter = SessionFilter.Recent })
|
||||
FilterPill(text = "Live", icon = Icons.Outlined.MicNone, active = filter == SessionFilter.Live, live = sessions.any { it.key == chatSessionKey }, onClick = { filter = SessionFilter.Live })
|
||||
FilterPill(text = "Current", icon = Icons.Outlined.MicNone, active = filter == SessionFilter.Current, showDot = sessions.any { it.key == chatSessionKey }, onClick = { filter = SessionFilter.Current })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,7 +180,7 @@ private fun FilterPill(
|
||||
text: String,
|
||||
icon: ImageVector? = null,
|
||||
active: Boolean = false,
|
||||
live: Boolean = false,
|
||||
showDot: Boolean = false,
|
||||
dropdown: Boolean = false,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
@@ -198,7 +199,7 @@ private fun FilterPill(
|
||||
) {
|
||||
icon?.let { Icon(imageVector = it, contentDescription = null, modifier = Modifier.size(12.dp), tint = ClawTheme.colors.text) }
|
||||
Text(text = text, style = ClawTheme.type.label, color = ClawTheme.colors.text, maxLines = 1)
|
||||
if (live) {
|
||||
if (showDot) {
|
||||
Box(modifier = Modifier.size(4.dp).clip(CircleShape).background(ClawTheme.colors.success))
|
||||
}
|
||||
if (dropdown) {
|
||||
@@ -258,7 +259,7 @@ private fun SessionRow(
|
||||
Text(text = subtitle, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
SessionMiniTag(text = "Workspace")
|
||||
SessionMiniTag(text = if (active) "Active" else "OpenClaw")
|
||||
SessionMiniTag(text = if (active) "Current" else "OpenClaw")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -273,19 +274,6 @@ private fun SessionRow(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionPlainIconButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(onClick = onClick, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionOutlineIconButton(
|
||||
icon: ImageVector,
|
||||
@@ -320,21 +308,21 @@ private fun SessionMiniTag(text: String) {
|
||||
|
||||
private enum class SessionFilter {
|
||||
Recent,
|
||||
Live,
|
||||
Current,
|
||||
}
|
||||
|
||||
/** Empty-state title selected by the active session browser filter. */
|
||||
private fun emptySessionTitle(filter: SessionFilter): String =
|
||||
when (filter) {
|
||||
SessionFilter.Recent -> "No sessions yet"
|
||||
SessionFilter.Live -> "No live session"
|
||||
SessionFilter.Current -> "No current session"
|
||||
}
|
||||
|
||||
/** Empty-state body selected by the active session browser filter. */
|
||||
private fun emptySessionBody(filter: SessionFilter): String =
|
||||
when (filter) {
|
||||
SessionFilter.Recent -> "Start a new conversation and it will show up here."
|
||||
SessionFilter.Live -> "Open Chat to start or resume the current session."
|
||||
SessionFilter.Current -> "Open Chat to start or resume the current session."
|
||||
}
|
||||
|
||||
/** Formats session timestamps for compact mobile metadata. */
|
||||
|
||||
@@ -4,6 +4,7 @@ import ai.openclaw.app.AppearanceThemeMode
|
||||
import ai.openclaw.app.BuildConfig
|
||||
import ai.openclaw.app.GatewayAgentSummary
|
||||
import ai.openclaw.app.GatewayCronJobSummary
|
||||
import ai.openclaw.app.GatewayExecApprovalSummary
|
||||
import ai.openclaw.app.GatewayUsageProviderSummary
|
||||
import ai.openclaw.app.LocationMode
|
||||
import ai.openclaw.app.MainViewModel
|
||||
@@ -14,6 +15,7 @@ import ai.openclaw.app.ui.design.ClawDetailRow
|
||||
import ai.openclaw.app.ui.design.ClawIconBadge
|
||||
import ai.openclaw.app.ui.design.ClawListPanel
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawPlainIconButton
|
||||
import ai.openclaw.app.ui.design.ClawPrimaryButton
|
||||
import ai.openclaw.app.ui.design.ClawScaffold
|
||||
import ai.openclaw.app.ui.design.ClawSecondaryButton
|
||||
@@ -90,7 +92,6 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -106,6 +107,7 @@ internal enum class SettingsRoute {
|
||||
Profile,
|
||||
Voice,
|
||||
Agents,
|
||||
ProvidersModels,
|
||||
Approvals,
|
||||
CronJobs,
|
||||
Usage,
|
||||
@@ -136,6 +138,7 @@ internal fun SettingsDetailScreen(
|
||||
SettingsRoute.Profile -> ProfileSettingsScreen(viewModel = viewModel, onBack = onBack)
|
||||
SettingsRoute.Voice -> VoiceSettingsScreen(viewModel = viewModel, onBack = onBack)
|
||||
SettingsRoute.Agents -> AgentsSettingsScreen(viewModel = viewModel, onBack = onBack)
|
||||
SettingsRoute.ProvidersModels -> ProvidersModelsScreen(viewModel = viewModel, onBack = onBack)
|
||||
SettingsRoute.Approvals -> ApprovalsSettingsScreen(viewModel = viewModel, onBack = onBack)
|
||||
SettingsRoute.CronJobs -> CronJobsSettingsScreen(viewModel = viewModel, onBack = onBack)
|
||||
SettingsRoute.Usage -> UsageSettingsScreen(viewModel = viewModel, onBack = onBack)
|
||||
@@ -299,29 +302,62 @@ private fun ApprovalsSettingsScreen(
|
||||
viewModel: MainViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val execApprovals by viewModel.execApprovals.collectAsState()
|
||||
val execApprovalsRefreshing by viewModel.execApprovalsRefreshing.collectAsState()
|
||||
val execApprovalsErrorText by viewModel.execApprovalsErrorText.collectAsState()
|
||||
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
|
||||
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
|
||||
val waitingCount = pendingToolCalls.count { it.isError != true }
|
||||
val issueCount = pendingToolCalls.count { it.isError == true }
|
||||
val issueCount = execApprovals.count { it.errorText != null } + pendingToolCalls.count { it.isError == true }
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
viewModel.refreshExecApprovals()
|
||||
}
|
||||
}
|
||||
|
||||
SettingsDetailFrame(title = "Approvals", subtitle = "Review actions that need your attention.", icon = Icons.Default.Lock, onBack = onBack) {
|
||||
SettingsMetricPanel(
|
||||
rows =
|
||||
listOf(
|
||||
SettingsMetric("Pending", waitingCount.toString()),
|
||||
SettingsMetric("Gateway Pending", execApprovals.size.toString()),
|
||||
SettingsMetric("Session Activity", pendingToolCalls.size.toString()),
|
||||
SettingsMetric("Issues", issueCount.toString()),
|
||||
SettingsMetric("Active Runs", pendingRunCount.toString()),
|
||||
),
|
||||
)
|
||||
if (pendingToolCalls.isEmpty()) {
|
||||
ClawSecondaryButton(
|
||||
text = if (execApprovalsRefreshing) "Refreshing" else "Refresh",
|
||||
onClick = viewModel::refreshExecApprovals,
|
||||
enabled = isConnected && !execApprovalsRefreshing,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (execApprovalsErrorText != null) {
|
||||
ClawPanel {
|
||||
Text(text = execApprovalsErrorText ?: "", style = ClawTheme.type.body, color = ClawTheme.colors.warning)
|
||||
}
|
||||
}
|
||||
if (!isConnected) {
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(text = "Nothing needs approval.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = "OpenClaw will show action requests here when a session pauses for review.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
Text(text = "Gateway disconnected.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = "Connect the gateway to load approval requests in the app.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
} else if (execApprovals.isEmpty()) {
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(text = "No gateway approvals.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = "Exec approval requests will appear here while this phone is connected.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ApprovalsPanel(toolCalls = pendingToolCalls)
|
||||
ExecApprovalsPanel(approvals = execApprovals, onResolve = viewModel::resolveExecApproval)
|
||||
}
|
||||
if (pendingToolCalls.isNotEmpty()) {
|
||||
Text(text = "Session activity", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = "Chat tool calls waiting in the active session remain visible here.", style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
|
||||
SessionToolCallsPanel(toolCalls = pendingToolCalls)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -820,6 +856,7 @@ private fun GatewaySettingsScreen(
|
||||
var bootstrapTokenInput by remember { mutableStateOf("") }
|
||||
var passwordInput by remember { mutableStateOf("") }
|
||||
var validationText by remember { mutableStateOf<String?>(null) }
|
||||
var showSetupCodeHelp by remember { mutableStateOf(false) }
|
||||
|
||||
SettingsDetailFrame(title = "Gateway", subtitle = "Connection between this phone and OpenClaw.", icon = Icons.Default.Cloud, onBack = onBack) {
|
||||
SettingsMetricPanel(
|
||||
@@ -840,7 +877,17 @@ private fun GatewaySettingsScreen(
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(text = "Pair New Gateway", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = "Clear this phone's saved gateway access and scan a fresh setup code.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
ClawSecondaryButton(text = "Pair New Gateway", onClick = viewModel::pairNewGateway, modifier = Modifier.fillMaxWidth(), icon = Icons.Default.QrCode2)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ClawSecondaryButton(text = "Pair New Gateway", onClick = viewModel::pairNewGateway, modifier = Modifier.weight(1f), icon = Icons.Default.QrCode2)
|
||||
ClawSecondaryButton(text = "Setup Code", onClick = { showSetupCodeHelp = !showSetupCodeHelp }, modifier = Modifier.weight(1f), icon = Icons.Default.Info)
|
||||
}
|
||||
if (showSetupCodeHelp) {
|
||||
Text(
|
||||
text = "Android can scan or paste an existing setup code, but this gateway does not expose setup-code generation to the app yet. Generate the QR/code on the gateway host with openclaw qr, then scan it here or paste the setup code below.",
|
||||
style = ClawTheme.type.caption,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
ClawPanel {
|
||||
@@ -1061,7 +1108,11 @@ internal fun SettingsDetailFrame(
|
||||
LazyColumn(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(10.dp), contentPadding = PaddingValues(bottom = 4.dp)) {
|
||||
item {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
SettingsBackButton(onClick = onBack)
|
||||
ClawPlainIconButton(
|
||||
icon = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
onClick = onBack,
|
||||
)
|
||||
Text(text = title, style = ClawTheme.type.title, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
SettingsIconMark(icon = icon)
|
||||
}
|
||||
@@ -1098,7 +1149,70 @@ internal data class SettingsMetric(
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun ApprovalsPanel(toolCalls: List<ChatPendingToolCall>) {
|
||||
private fun ExecApprovalsPanel(
|
||||
approvals: List<GatewayExecApprovalSummary>,
|
||||
onResolve: (String, String) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
approvals.forEach { approval ->
|
||||
ExecApprovalCard(approval = approval, onResolve = onResolve)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExecApprovalCard(
|
||||
approval: GatewayExecApprovalSummary,
|
||||
onResolve: (String, String) -> Unit,
|
||||
) {
|
||||
val resolving = approval.resolvingDecision != null
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(text = approval.commandText, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
approval.commandPreview?.let { preview ->
|
||||
Text(text = preview, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
ClawStatusPill(text = if (resolving) "Sending" else "Review", status = if (resolving) ClawStatus.Warning else ClawStatus.Success)
|
||||
}
|
||||
Text(text = execApprovalMetadata(approval), style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
approval.errorText?.let { errorText ->
|
||||
Text(text = errorText, style = ClawTheme.type.caption, color = ClawTheme.colors.warning)
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if ("allow-once" in approval.allowedDecisions) {
|
||||
ClawPrimaryButton(
|
||||
text = if (approval.resolvingDecision == "allow-once") "Allowing" else "Allow Once",
|
||||
onClick = { onResolve(approval.id, "allow-once") },
|
||||
enabled = !resolving,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
if ("allow-always" in approval.allowedDecisions) {
|
||||
ClawSecondaryButton(
|
||||
text = if (approval.resolvingDecision == "allow-always") "Saving" else "Always",
|
||||
onClick = { onResolve(approval.id, "allow-always") },
|
||||
enabled = !resolving,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
if ("deny" in approval.allowedDecisions) {
|
||||
ClawSecondaryButton(
|
||||
text = if (approval.resolvingDecision == "deny") "Denying" else "Deny",
|
||||
onClick = { onResolve(approval.id, "deny") },
|
||||
enabled = !resolving,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionToolCallsPanel(toolCalls: List<ChatPendingToolCall>) {
|
||||
ClawListPanel(items = toolCalls) { toolCall ->
|
||||
ApprovalListRow(toolCall = toolCall)
|
||||
}
|
||||
@@ -1231,6 +1345,30 @@ private fun approvalSubtitle(
|
||||
return if (minutes < 1) "Waiting for review" else "Waiting ${minutes}m"
|
||||
}
|
||||
|
||||
private fun execApprovalMetadata(approval: GatewayExecApprovalSummary): String {
|
||||
val target =
|
||||
when {
|
||||
approval.host == "node" && approval.nodeId != null -> "Node ${approval.nodeId.take(8)}"
|
||||
approval.host != null -> approval.host.replaceFirstChar { it.uppercaseChar() }
|
||||
else -> "Gateway"
|
||||
}
|
||||
val agent = approval.agentId?.let { "Agent ${it.take(8)}" }
|
||||
val age = approval.createdAtMs?.let { "Waiting ${formatApprovalDuration(System.currentTimeMillis() - it)}" }
|
||||
val expires = approval.expiresAtMs?.let { "Expires ${formatApprovalDuration(it - System.currentTimeMillis())}" }
|
||||
return listOfNotNull(target, agent, age, expires).joinToString(" · ")
|
||||
}
|
||||
|
||||
private fun formatApprovalDuration(deltaMs: Long): String {
|
||||
val safeDelta = deltaMs.coerceAtLeast(0L)
|
||||
val minutes = safeDelta / 60_000L
|
||||
val hours = minutes / 60L
|
||||
return when {
|
||||
minutes < 1 -> "soon"
|
||||
hours < 1 -> "${minutes}m"
|
||||
else -> "${hours}h"
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds the dense cron-job subtitle from schedule, next wake, and prompt preview. */
|
||||
private fun cronJobSubtitle(job: GatewayCronJobSummary): String = "${job.scheduleLabel} · ${formatCronWake(job.nextRunAtMs)} · ${job.promptPreview}"
|
||||
|
||||
@@ -1394,15 +1532,6 @@ internal fun SettingsMetricPanel(rows: List<SettingsMetric>) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsBackButton(onClick: () -> Unit) {
|
||||
Surface(onClick = onClick, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsIconMark(icon: ImageVector) {
|
||||
Surface(
|
||||
|
||||
@@ -1253,16 +1253,6 @@ private fun settingsPrimaryButtonColors() =
|
||||
disabledContentColor = Color.White.copy(alpha = 0.9f),
|
||||
)
|
||||
|
||||
/** Destructive button colors for permission and capability settings actions. */
|
||||
@Composable
|
||||
private fun settingsDangerButtonColors() =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = mobileDanger,
|
||||
contentColor = Color.White,
|
||||
disabledContainerColor = mobileDanger.copy(alpha = 0.45f),
|
||||
disabledContentColor = Color.White.copy(alpha = 0.9f),
|
||||
)
|
||||
|
||||
/** Opens this app's Android settings page for permissions that require system UI. */
|
||||
private fun openAppSettings(context: Context) {
|
||||
val intent =
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,17 +10,24 @@ import ai.openclaw.app.ui.design.ClawStatus
|
||||
import ai.openclaw.app.ui.design.ClawStatusPill
|
||||
import ai.openclaw.app.ui.design.ClawTextBadge
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@@ -37,6 +44,7 @@ internal fun SkillsSettingsScreen(
|
||||
val skills = skillsSummary.skills
|
||||
val readyCount = skills.count { skillReady(it) }
|
||||
val needsSetupCount = skills.count { skillNeedsSetup(it) }
|
||||
var selectedSkillKey by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
@@ -44,6 +52,17 @@ internal fun SkillsSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
selectedSkillKey?.let { skillKey ->
|
||||
val selectedSkill = skills.firstOrNull { it.skillKey == skillKey }
|
||||
SkillDetailSettingsScreen(
|
||||
skill = selectedSkill,
|
||||
skillKey = skillKey,
|
||||
isConnected = isConnected,
|
||||
onBack = { selectedSkillKey = null },
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
SettingsDetailFrame(
|
||||
title = "Skills",
|
||||
subtitle = "Installed capabilities available to OpenClaw.",
|
||||
@@ -83,25 +102,117 @@ internal fun SkillsSettingsScreen(
|
||||
Text(text = "Skills installed on the gateway will appear here.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
else -> SkillsPanel(skills = skills)
|
||||
else -> SkillsPanel(skills = skills, onSkillClick = { selectedSkillKey = it.skillKey })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SkillsPanel(skills: List<GatewaySkillSummary>) {
|
||||
ClawListPanel(items = skills) { skill ->
|
||||
SkillListRow(skill = skill)
|
||||
private fun SkillDetailSettingsScreen(
|
||||
skill: GatewaySkillSummary?,
|
||||
skillKey: String,
|
||||
isConnected: Boolean,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
BackHandler(onBack = onBack)
|
||||
|
||||
SettingsDetailFrame(
|
||||
title = skill?.name ?: skillKey,
|
||||
subtitle = "Inspect installed skill capability and setup state.",
|
||||
icon = Icons.Default.Settings,
|
||||
onBack = onBack,
|
||||
) {
|
||||
skill?.let { summary ->
|
||||
SettingsMetricPanel(
|
||||
rows =
|
||||
listOf(
|
||||
SettingsMetric("Status", skillStatusText(summary)),
|
||||
SettingsMetric("Source", skillSourceLabel(summary)),
|
||||
SettingsMetric("Missing", summary.missingCount.toString()),
|
||||
),
|
||||
)
|
||||
SkillSetupPanel(summary)
|
||||
}
|
||||
SkillDetailPanel(skill = skill, isConnected = isConnected)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SkillListRow(skill: GatewaySkillSummary) {
|
||||
private fun SkillSetupPanel(skill: GatewaySkillSummary) {
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(text = "Setup", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = skillConfigurationText(skill), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SkillDetailPanel(
|
||||
skill: GatewaySkillSummary?,
|
||||
isConnected: Boolean,
|
||||
) {
|
||||
if (!isConnected) {
|
||||
ClawPanel {
|
||||
Text(text = "Connect the gateway to load skill details.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (skill == null) {
|
||||
ClawPanel {
|
||||
Text(text = "Skill detail is not available in the current skills status.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
return
|
||||
}
|
||||
SettingsMetricPanel(
|
||||
rows =
|
||||
listOf(
|
||||
SettingsMetric("Skill Key", skill.skillKey),
|
||||
SettingsMetric("Display", skill.name),
|
||||
SettingsMetric("Source", skillSourceLabel(skill)),
|
||||
SettingsMetric("Install Options", skill.installCount.toString()),
|
||||
),
|
||||
)
|
||||
skill.description?.let { description ->
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(text = "Description", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = description, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SkillsPanel(
|
||||
skills: List<GatewaySkillSummary>,
|
||||
onSkillClick: (GatewaySkillSummary) -> Unit,
|
||||
) {
|
||||
ClawListPanel(items = skills) { skill ->
|
||||
SkillListRow(skill = skill, onClick = { onSkillClick(skill) })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SkillListRow(
|
||||
skill: GatewaySkillSummary,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
ClawDetailRow(
|
||||
title = skill.name,
|
||||
subtitle = skillSubtitle(skill),
|
||||
modifier = Modifier.clickable(onClickLabel = "Open skill detail", onClick = onClick),
|
||||
leading = { ClawTextBadge(text = skillBadge(skill)) },
|
||||
trailing = { ClawStatusPill(text = skillStatusText(skill), status = skillStatus(skill)) },
|
||||
trailing = {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
ClawStatusPill(text = skillStatusText(skill), status = skillStatus(skill))
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = ClawTheme.colors.textSubtle,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -135,6 +246,15 @@ private fun skillSubtitle(skill: GatewaySkillSummary): String {
|
||||
return listOfNotNull(skill.description, skillSourceLabel(skill), issue).joinToString(" · ")
|
||||
}
|
||||
|
||||
private fun skillConfigurationText(skill: GatewaySkillSummary): String =
|
||||
when {
|
||||
skill.disabled -> "This skill is disabled on the gateway. Android shows detail only; enable or configure it from desktop or CLI."
|
||||
skill.blockedByAllowlist -> "This skill is blocked by the gateway allowlist. Android can inspect it, but allowlist changes stay on desktop or CLI."
|
||||
skill.missingCount > 0 -> "This skill needs ${skill.missingCount} setup item(s). Android shows what is installed; setup/config changes stay on desktop or CLI."
|
||||
!skill.eligible -> "This skill is installed but not currently eligible to run. Use desktop or CLI for configuration changes."
|
||||
else -> "Ready on this gateway. Android detail is read-only; install, update, and configuration changes stay on desktop or CLI."
|
||||
}
|
||||
|
||||
private fun skillSourceLabel(skill: GatewaySkillSummary): String =
|
||||
when (skill.source) {
|
||||
"openclaw-bundled" -> if (skill.bundled) "Built-in" else "Bundled"
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.R
|
||||
import ai.openclaw.app.VoiceCaptureMode
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawPlainIconButton
|
||||
import ai.openclaw.app.ui.design.ClawPrimaryButton
|
||||
import ai.openclaw.app.ui.design.ClawSecondaryButton
|
||||
import ai.openclaw.app.ui.design.ClawStatus
|
||||
@@ -68,6 +70,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -177,8 +180,8 @@ fun VoiceScreen(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
VoiceHeader(
|
||||
statusText = voiceAttentionStatus ?: if (voiceActive || !gatewayReady) activeStatus else "Your voice command center.",
|
||||
@@ -267,12 +270,12 @@ private fun DictationScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
VoicePlainIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to voice", onClick = onCancel)
|
||||
ClawPlainIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to voice", onClick = onCancel)
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(text = "Dictation", style = ClawTheme.type.title.copy(fontSize = 16.sp, lineHeight = 20.sp), color = ClawTheme.colors.text)
|
||||
Text(text = "Transcribe then send", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
VoicePlainIconButton(icon = Icons.Default.Settings, contentDescription = "Dictation settings", onClick = onOpenVoiceSettings)
|
||||
ClawPlainIconButton(icon = Icons.Default.Settings, contentDescription = "Dictation settings", onClick = onOpenVoiceSettings)
|
||||
}
|
||||
|
||||
Surface(
|
||||
@@ -404,7 +407,7 @@ private fun TalkSessionScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
VoicePlainIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to voice", onClick = onEndTalk)
|
||||
ClawPlainIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to voice", onClick = onEndTalk)
|
||||
Column(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(text = "Realtime Talk", style = ClawTheme.type.title.copy(fontSize = 16.sp, lineHeight = 20.sp), color = ClawTheme.colors.text)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
@@ -423,7 +426,7 @@ private fun TalkSessionScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
VoicePlainIconButton(icon = Icons.Default.Info, contentDescription = "Talk settings", onClick = onOpenVoiceSettings)
|
||||
ClawPlainIconButton(icon = Icons.Default.Info, contentDescription = "Talk settings", onClick = onOpenVoiceSettings)
|
||||
}
|
||||
|
||||
Surface(
|
||||
@@ -547,14 +550,19 @@ private fun VoiceHeader(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.openclaw_logo),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(25.dp),
|
||||
tint = ClawTheme.colors.text,
|
||||
)
|
||||
Text(
|
||||
text = "O P E N C L A W",
|
||||
style = ClawTheme.type.title.copy(fontSize = 18.sp, lineHeight = 23.sp),
|
||||
text = "OpenClaw",
|
||||
style = ClawTheme.type.title.copy(fontSize = 17.sp, lineHeight = 21.sp),
|
||||
color = ClawTheme.colors.text,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
VoicePlainIconButton(icon = Icons.Default.Search, contentDescription = "Search voice", onClick = onOpenCommand)
|
||||
VoiceAvatar(text = "OC")
|
||||
ClawPlainIconButton(icon = Icons.Default.Search, contentDescription = "Search voice", onClick = onOpenCommand)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -562,7 +570,7 @@ private fun VoiceHeader(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(text = "Voice", style = ClawTheme.type.display.copy(fontSize = 16.sp, lineHeight = 20.sp), color = ClawTheme.colors.text)
|
||||
Text(text = "Voice", style = ClawTheme.type.display.copy(fontSize = 24.sp, lineHeight = 28.sp), color = ClawTheme.colors.text)
|
||||
Text(
|
||||
text = statusText,
|
||||
style = ClawTheme.type.body,
|
||||
@@ -571,7 +579,7 @@ private fun VoiceHeader(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
VoicePlainIconButton(
|
||||
ClawPlainIconButton(
|
||||
icon = if (speakerEnabled) Icons.AutoMirrored.Filled.VolumeUp else Icons.AutoMirrored.Filled.VolumeOff,
|
||||
contentDescription = if (speakerEnabled) "Mute speaker" else "Unmute speaker",
|
||||
onClick = onToggleSpeaker,
|
||||
@@ -580,34 +588,6 @@ private fun VoiceHeader(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceAvatar(text: String) {
|
||||
Surface(
|
||||
modifier = Modifier.size(34.dp),
|
||||
shape = CircleShape,
|
||||
color = ClawTheme.colors.surfaceRaised,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(text = text.take(2).uppercase(), style = ClawTheme.type.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoicePlainIconButton(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(onClick = onClick, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceHero(
|
||||
gatewayStatus: String,
|
||||
@@ -861,8 +841,10 @@ private fun VoiceOrb(
|
||||
Surface(
|
||||
modifier = Modifier.size(112.dp),
|
||||
shape = CircleShape,
|
||||
color = if (active) ClawTheme.colors.surfacePressed else ClawTheme.colors.surface,
|
||||
border = BorderStroke(1.dp, if (active) ClawTheme.colors.borderStrong else ClawTheme.colors.border),
|
||||
color = if (active || listening || speaking) Color(0xFF1976D2) else Color(0xFF123B63),
|
||||
contentColor = Color.White,
|
||||
tonalElevation = 3.dp,
|
||||
shadowElevation = 7.dp,
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
@@ -875,7 +857,7 @@ private fun VoiceOrb(
|
||||
},
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(32.dp),
|
||||
tint = ClawTheme.colors.text,
|
||||
tint = Color.White,
|
||||
)
|
||||
Waveform(active = active)
|
||||
}
|
||||
@@ -892,7 +874,7 @@ private fun Waveform(active: Boolean) {
|
||||
Modifier
|
||||
.size(width = 2.dp, height = (if (active) height else 6 + index % 3 * 3).dp)
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(if (active) ClawTheme.colors.text else ClawTheme.colors.textSubtle),
|
||||
.background(if (active) Color.White else Color.White.copy(alpha = 0.52f)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ai.openclaw.app.ui.chat
|
||||
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.R
|
||||
import ai.openclaw.app.chat.ChatMessage
|
||||
import ai.openclaw.app.chat.ChatMessageContent
|
||||
import ai.openclaw.app.chat.ChatPendingToolCall
|
||||
@@ -39,6 +40,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.AttachFile
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
@@ -63,6 +65,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -153,12 +156,11 @@ fun ChatScreen(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 18.dp, vertical = 6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(5.dp),
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
ChatHeader(
|
||||
sessionTitle = currentSessionTitle(sessionKey = sessionKey, sessions = sessions),
|
||||
thinkingLevel = thinkingLevel,
|
||||
healthOk = healthOk,
|
||||
pendingRunCount = pendingRunCount,
|
||||
onMore = {
|
||||
@@ -261,11 +263,11 @@ private fun ChatSessionSwitcher(
|
||||
if (sessions.size > choices.size) {
|
||||
Surface(
|
||||
onClick = onOpenSessions,
|
||||
modifier = Modifier.heightIn(min = 36.dp),
|
||||
modifier = Modifier.heightIn(min = ClawTheme.spacing.touchTarget),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
color = ClawTheme.colors.canvas,
|
||||
color = ClawTheme.colors.surfaceRaised.copy(alpha = 0.72f),
|
||||
contentColor = ClawTheme.colors.textMuted,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border.copy(alpha = 0.7f)),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
@@ -288,11 +290,11 @@ private fun ChatSessionChip(
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.heightIn(min = 36.dp),
|
||||
modifier = Modifier.heightIn(min = ClawTheme.spacing.touchTarget),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
color = if (active) ClawTheme.colors.primary else ClawTheme.colors.surfaceRaised,
|
||||
contentColor = if (active) ClawTheme.colors.primaryText else ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, if (active) ClawTheme.colors.primary else ClawTheme.colors.border),
|
||||
color = if (active) ClawTheme.colors.surfacePressed.copy(alpha = 0.9f) else ClawTheme.colors.surfaceRaised.copy(alpha = 0.72f),
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, if (active) ClawTheme.colors.borderStrong else ClawTheme.colors.border.copy(alpha = 0.7f)),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
@@ -307,48 +309,56 @@ private fun ChatSessionChip(
|
||||
@Composable
|
||||
private fun ChatHeader(
|
||||
sessionTitle: String,
|
||||
thinkingLevel: String,
|
||||
healthOk: Boolean,
|
||||
pendingRunCount: Int,
|
||||
onMore: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Box(modifier = Modifier.size(ClawTheme.spacing.touchTarget))
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.openclaw_logo),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(25.dp),
|
||||
tint = ClawTheme.colors.text,
|
||||
)
|
||||
Text(
|
||||
text = sessionTitle,
|
||||
style = ClawTheme.type.title.copy(fontSize = 18.sp, lineHeight = 23.sp),
|
||||
text = "OpenClaw",
|
||||
style = ClawTheme.type.title.copy(fontSize = 17.sp, lineHeight = 21.sp),
|
||||
color = ClawTheme.colors.text,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
ModelPill(
|
||||
text =
|
||||
when {
|
||||
pendingRunCount > 0 -> "Working"
|
||||
healthOk -> "auto"
|
||||
else -> "offline"
|
||||
healthOk -> "Ready"
|
||||
else -> "Offline"
|
||||
},
|
||||
status =
|
||||
when {
|
||||
pendingRunCount > 0 -> ClawStatus.Warning
|
||||
healthOk -> ClawStatus.Neutral
|
||||
healthOk -> ClawStatus.Success
|
||||
else -> ClawStatus.Danger
|
||||
},
|
||||
)
|
||||
HeaderIcon(icon = Icons.Default.Refresh, contentDescription = "Refresh chat", onClick = onMore)
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(text = "Chat", style = ClawTheme.type.display.copy(fontSize = 24.sp, lineHeight = 28.sp), color = ClawTheme.colors.text, maxLines = 1)
|
||||
Text(
|
||||
text = sessionTitle,
|
||||
style = ClawTheme.type.caption.copy(fontSize = 13.sp, lineHeight = 17.sp),
|
||||
color = ClawTheme.colors.textMuted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
HeaderIcon(icon = Icons.Default.Refresh, contentDescription = "Refresh chat", onClick = onMore)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,7 +375,13 @@ private fun ModelPill(
|
||||
}
|
||||
Surface(
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
color = ClawTheme.colors.surfaceRaised,
|
||||
color =
|
||||
when (status) {
|
||||
ClawStatus.Success -> ClawTheme.colors.successSoft
|
||||
ClawStatus.Warning -> ClawTheme.colors.warningSoft
|
||||
ClawStatus.Danger -> ClawTheme.colors.dangerSoft
|
||||
ClawStatus.Neutral -> ClawTheme.colors.surfaceRaised
|
||||
},
|
||||
contentColor = ClawTheme.colors.textMuted,
|
||||
border = BorderStroke(1.dp, borderColor),
|
||||
) {
|
||||
@@ -577,13 +593,15 @@ private fun ChatBubble(
|
||||
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(if (isUser) 0.64f else 0.56f),
|
||||
modifier = Modifier.fillMaxWidth(if (isUser) 0.84f else 0.94f),
|
||||
shape = RoundedCornerShape(7.dp),
|
||||
color = ClawTheme.colors.surfaceRaised,
|
||||
color = if (isUser) ClawTheme.colors.surfacePressed.copy(alpha = 0.86f) else ClawTheme.colors.surfaceRaised.copy(alpha = 0.84f),
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, if (live) ClawTheme.colors.borderStrong else ClawTheme.colors.border),
|
||||
border = BorderStroke(1.dp, if (live) ClawTheme.colors.borderStrong else ClawTheme.colors.border.copy(alpha = 0.45f)),
|
||||
tonalElevation = 1.dp,
|
||||
shadowElevation = 2.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 7.dp, vertical = 3.5.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Column(modifier = Modifier.padding(horizontal = 11.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text =
|
||||
when {
|
||||
@@ -764,7 +782,7 @@ private fun ChatContextMeter(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(12.dp), tint = ClawTheme.colors.textSubtle)
|
||||
Icon(imageVector = Icons.Default.ArrowDropDown, contentDescription = null, modifier = Modifier.size(13.dp), tint = ClawTheme.colors.textSubtle)
|
||||
Text(
|
||||
text = contextMeterLabel(contextUsage, thinkingLevel),
|
||||
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp),
|
||||
@@ -936,7 +954,7 @@ internal fun resolveChatContextUsage(
|
||||
sessionKey = sessionKey,
|
||||
mainSessionKey = mainSessionKey,
|
||||
)
|
||||
}
|
||||
}
|
||||
return ChatContextUsage(
|
||||
totalTokens = entry?.totalTokens,
|
||||
totalTokensFresh = entry?.totalTokensFresh,
|
||||
@@ -973,24 +991,6 @@ private fun userFacingChatError(error: String): String {
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalizes persisted thinking values into compact UI labels. */
|
||||
private fun thinkingDisplay(value: String): String =
|
||||
when (value.lowercase(Locale.US)) {
|
||||
"low" -> "Low"
|
||||
"medium" -> "Medium"
|
||||
"high" -> "High"
|
||||
else -> "Off"
|
||||
}
|
||||
|
||||
/** Converts displayed thinking labels back to gateway request values. */
|
||||
private fun thinkingValue(display: String): String =
|
||||
when (display.lowercase(Locale.US)) {
|
||||
"low" -> "low"
|
||||
"medium" -> "medium"
|
||||
"high" -> "high"
|
||||
else -> "off"
|
||||
}
|
||||
|
||||
/** Cycles through context budget presets from the compact composer control. */
|
||||
private fun nextThinkingValue(value: String): String =
|
||||
when (value.lowercase(Locale.US)) {
|
||||
|
||||
@@ -185,6 +185,53 @@ internal fun ClawIconButton(
|
||||
}
|
||||
}
|
||||
|
||||
/** Transparent circular icon button for low-emphasis toolbar actions. */
|
||||
@Composable
|
||||
internal fun ClawPlainIconButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.size(ClawTheme.spacing.touchTarget),
|
||||
shape = CircleShape,
|
||||
color = Color.Transparent,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Compact label/value row for health and readiness summaries. */
|
||||
@Composable
|
||||
internal fun ClawStatusRow(
|
||||
title: String,
|
||||
value: String,
|
||||
healthy: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.text,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
)
|
||||
ClawStatusPill(
|
||||
text = value,
|
||||
status = if (healthy) ClawStatus.Success else ClawStatus.Warning,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Compact status chip with a semantic color dot. */
|
||||
@Composable
|
||||
internal fun ClawStatusPill(
|
||||
|
||||
@@ -95,15 +95,17 @@ internal fun ClawBottomNav(
|
||||
Box(modifier = modifier.fillMaxWidth().background(ClawTheme.colors.canvas)) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = ClawTheme.colors.surface.copy(alpha = 0.96f),
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
color = ClawTheme.colors.surface.copy(alpha = 0.92f),
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border.copy(alpha = 0.42f)),
|
||||
shape = RoundedCornerShape(topStart = ClawTheme.radii.sheet, topEnd = ClawTheme.radii.sheet),
|
||||
tonalElevation = 2.dp,
|
||||
shadowElevation = 8.dp,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.windowInsetsPadding(safeInsets)
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
@@ -131,13 +133,13 @@ private fun ClawBottomNavItem(
|
||||
onClick = onClick,
|
||||
modifier = modifier.heightIn(min = 48.dp),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.control),
|
||||
color = if (selected) ClawTheme.colors.primary else Color.Transparent,
|
||||
contentColor = if (selected) ClawTheme.colors.primaryText else ClawTheme.colors.textMuted,
|
||||
color = if (selected) ClawTheme.colors.surfacePressed.copy(alpha = 0.72f) else Color.Transparent,
|
||||
contentColor = if (selected) ClawTheme.colors.text else ClawTheme.colors.textMuted,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 5.dp, vertical = 6.dp),
|
||||
modifier = Modifier.padding(horizontal = 5.dp, vertical = 5.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Icon(imageVector = item.icon, contentDescription = item.label, modifier = Modifier.size(18.dp))
|
||||
Text(text = item.label, style = ClawTheme.type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
|
||||
@@ -27,31 +27,11 @@ internal fun ClawPanel(
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.panel),
|
||||
color = ClawTheme.colors.surfaceRaised,
|
||||
color = ClawTheme.colors.surfaceRaised.copy(alpha = 0.82f),
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(contentPadding)) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bottom-sheet container with the app surface treatment and top-only rounding.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ClawSheetSurface(
|
||||
modifier: Modifier = Modifier,
|
||||
contentPadding: PaddingValues = PaddingValues(18.dp),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(topStart = ClawTheme.radii.sheet, topEnd = ClawTheme.radii.sheet),
|
||||
color = ClawTheme.colors.surface,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
border = null,
|
||||
tonalElevation = 2.dp,
|
||||
shadowElevation = 4.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(contentPadding)) {
|
||||
content()
|
||||
|
||||
@@ -4,7 +4,6 @@ import ai.openclaw.app.ui.LocalMobileColors
|
||||
import ai.openclaw.app.ui.darkMobileColors
|
||||
import ai.openclaw.app.ui.lightMobileColors
|
||||
import ai.openclaw.app.ui.mobileFontFamily
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Shapes
|
||||
import androidx.compose.material3.Typography
|
||||
@@ -190,12 +189,6 @@ internal fun ClawDesignTheme(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the system dark-mode preference for callers that expose theme selection.
|
||||
*/
|
||||
@Composable
|
||||
internal fun rememberClawDarkPreference(): Boolean = isSystemInDarkTheme()
|
||||
|
||||
private fun clawTypography(fontFamily: FontFamily) =
|
||||
ClawTypography(
|
||||
display =
|
||||
|
||||
@@ -111,7 +111,6 @@ class TalkModeManager internal constructor(
|
||||
private const val tag = "TalkMode"
|
||||
private const val realtimeSampleRateHz = 24_000
|
||||
private const val realtimeAudioFrameMs = 100
|
||||
private const val listenWatchdogMs = 12_000L
|
||||
private const val chatFinalWaitMs = 45_000L
|
||||
private const val maxCachedRunCompletions = 128
|
||||
private const val maxConversationEntries = 40
|
||||
|
||||
@@ -3,13 +3,11 @@
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_MEDIA_IMAGES"
|
||||
tools:node="remove" />
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_MEDIA_VIDEO"
|
||||
tools:node="remove" />
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"
|
||||
tools:node="remove" />
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32"
|
||||
tools:node="remove" />
|
||||
</manifest>
|
||||
|
||||
@@ -33,44 +33,44 @@ class GatewayBootstrapAuthTest {
|
||||
@Test
|
||||
fun doesNotConnectOperatorSessionWhenOnlyBootstrapAuthExists() {
|
||||
assertFalse(
|
||||
shouldConnectOperatorSession(
|
||||
resolveOperatorSessionConnectAuth(
|
||||
NodeRuntime.GatewayConnectAuth(token = "", bootstrapToken = "bootstrap-1", password = ""),
|
||||
storedOperatorToken = "",
|
||||
),
|
||||
) != null,
|
||||
)
|
||||
assertFalse(
|
||||
shouldConnectOperatorSession(
|
||||
resolveOperatorSessionConnectAuth(
|
||||
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null),
|
||||
storedOperatorToken = null,
|
||||
),
|
||||
) != null,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectsOperatorSessionWhenSharedPasswordOrStoredAuthExists() {
|
||||
assertTrue(
|
||||
shouldConnectOperatorSession(
|
||||
resolveOperatorSessionConnectAuth(
|
||||
NodeRuntime.GatewayConnectAuth(token = "shared-token", bootstrapToken = "bootstrap-1", password = null),
|
||||
storedOperatorToken = null,
|
||||
),
|
||||
) != null,
|
||||
)
|
||||
assertTrue(
|
||||
shouldConnectOperatorSession(
|
||||
resolveOperatorSessionConnectAuth(
|
||||
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = "shared-password"),
|
||||
storedOperatorToken = null,
|
||||
),
|
||||
) != null,
|
||||
)
|
||||
assertTrue(
|
||||
shouldConnectOperatorSession(
|
||||
resolveOperatorSessionConnectAuth(
|
||||
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null),
|
||||
storedOperatorToken = "stored-token",
|
||||
),
|
||||
) != null,
|
||||
)
|
||||
assertTrue(
|
||||
shouldConnectOperatorSession(
|
||||
resolveOperatorSessionConnectAuth(
|
||||
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "", password = null),
|
||||
storedOperatorToken = null,
|
||||
),
|
||||
) != null,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
import ai.openclaw.app.node.asObjectOrNull
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class GatewayExecApprovalParsingTest {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@Test
|
||||
fun parsesGatewayExecApprovalListPayload() {
|
||||
val rows =
|
||||
parseGatewayExecApprovalListPayload(
|
||||
"""
|
||||
[
|
||||
{
|
||||
"id": "approval-2",
|
||||
"createdAtMs": 20,
|
||||
"expiresAtMs": 120,
|
||||
"request": {
|
||||
"host": "node",
|
||||
"nodeId": "node-1",
|
||||
"agentId": "agent-1",
|
||||
"command": "Sanitized command",
|
||||
"commandPreview": "Sanitized preview",
|
||||
"systemRunPlan": {
|
||||
"commandText": "/bin/sh -lc 'echo secret'",
|
||||
"commandPreview": "echo secret"
|
||||
},
|
||||
"allowedDecisions": ["allow-once", "deny"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "approval-1",
|
||||
"createdAtMs": 10,
|
||||
"expiresAtMs": 110,
|
||||
"request": {
|
||||
"host": "gateway",
|
||||
"command": "pnpm test --token secret",
|
||||
"commandPreview": "pnpm test",
|
||||
"unavailableDecisions": ["allow-always"]
|
||||
}
|
||||
}
|
||||
]
|
||||
""".trimIndent(),
|
||||
json,
|
||||
)
|
||||
|
||||
assertEquals(listOf("approval-1", "approval-2"), rows.map { it.id })
|
||||
assertEquals("pnpm test --token secret", rows[0].commandText)
|
||||
assertEquals("pnpm test", rows[0].commandPreview)
|
||||
assertEquals(emptyList<String>(), rows[0].allowedDecisions)
|
||||
assertEquals("Sanitized command", rows[1].commandText)
|
||||
assertEquals("Sanitized preview", rows[1].commandPreview)
|
||||
assertEquals("node-1", rows[1].nodeId)
|
||||
assertEquals("agent-1", rows[1].agentId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesGatewayExecApprovalGetPayload() {
|
||||
val root =
|
||||
json
|
||||
.parseToJsonElement(
|
||||
"""
|
||||
{
|
||||
"id": "approval-1",
|
||||
"commandText": "rm -rf build",
|
||||
"commandPreview": "rm build",
|
||||
"allowedDecisions": ["allow-once", "allow-always", "deny"],
|
||||
"host": "gateway",
|
||||
"nodeId": null,
|
||||
"agentId": "agent-main",
|
||||
"expiresAtMs": 200
|
||||
}
|
||||
""".trimIndent(),
|
||||
).asObjectOrNull()
|
||||
|
||||
requireNotNull(root)
|
||||
val row = parseGatewayExecApprovalDetail(root, createdAtMs = 100)
|
||||
|
||||
requireNotNull(row)
|
||||
assertEquals("approval-1", row.id)
|
||||
assertEquals("rm -rf build", row.commandText)
|
||||
assertEquals("rm build", row.commandPreview)
|
||||
assertEquals(listOf("allow-once", "allow-always", "deny"), row.allowedDecisions)
|
||||
assertEquals("gateway", row.host)
|
||||
assertNull(row.nodeId)
|
||||
assertEquals("agent-main", row.agentId)
|
||||
assertEquals(100L, row.createdAtMs)
|
||||
assertEquals(200L, row.expiresAtMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ignoresMalformedGatewayExecApprovalListPayload() {
|
||||
assertTrue(parseGatewayExecApprovalListPayload("""{"approvals":[]}""", json).isEmpty())
|
||||
assertTrue(parseGatewayExecApprovalListPayload("not json", json).isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class GatewayLogTextTest {
|
||||
@Test
|
||||
fun sanitizeGatewayLogTextRemovesAnsiSgrSequences() {
|
||||
assertEquals(
|
||||
"hindsight: Skipping retain",
|
||||
sanitizeGatewayLogText("\u001B[38;5;103mhindsight:\u001B[0m Skipping retain"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sanitizeGatewayLogTextRemovesVisibleSgrFragments() {
|
||||
assertEquals(
|
||||
"hindsight: Skipping retain",
|
||||
sanitizeGatewayLogText("[38;5;103mhindsight:[0m Skipping retain"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sanitizeGatewayLogTextRemovesSingleParameterVisibleSgrFragments() {
|
||||
assertEquals(
|
||||
"error and bold",
|
||||
sanitizeGatewayLogText("[31merror[0m and [1mbold[0m"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sanitizeGatewayLogTextRemovesJsonEscapedAnsiSgrSequences() {
|
||||
assertEquals(
|
||||
"""{"1":"hindsight: Skipping retain"}""",
|
||||
sanitizeGatewayLogText("""{"1":"\u001b[38;5;103mhindsight:\u001b[0m Skipping retain"}"""),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sanitizeGatewayLogTextKeepsPlainBracketedText() {
|
||||
assertEquals(
|
||||
"cache ttl [5m] expired",
|
||||
sanitizeGatewayLogText("cache ttl [5m] expired"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -204,17 +204,18 @@ class GatewayConfigResolverTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveScannedSetupCodeAcceptsRawSetupCode() {
|
||||
fun resolveScannedSetupCodeResultAcceptsRawSetupCode() {
|
||||
val setupCode =
|
||||
encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""")
|
||||
|
||||
val resolved = resolveScannedSetupCode(setupCode)
|
||||
val resolved = resolveScannedSetupCodeResult(setupCode)
|
||||
|
||||
assertEquals(setupCode, resolved)
|
||||
assertEquals(setupCode, resolved.setupCode)
|
||||
assertNull(resolved.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveScannedSetupCodeAcceptsQrJsonPayload() {
|
||||
fun resolveScannedSetupCodeResultAcceptsQrJsonPayload() {
|
||||
val setupCode =
|
||||
encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""")
|
||||
val qrJson =
|
||||
@@ -227,49 +228,55 @@ class GatewayConfigResolverTest {
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val resolved = resolveScannedSetupCode(qrJson)
|
||||
val resolved = resolveScannedSetupCodeResult(qrJson)
|
||||
|
||||
assertEquals(setupCode, resolved)
|
||||
assertEquals(setupCode, resolved.setupCode)
|
||||
assertNull(resolved.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveScannedSetupCodeRejectsInvalidInput() {
|
||||
val resolved = resolveScannedSetupCode("not-a-valid-setup-code")
|
||||
assertNull(resolved)
|
||||
fun resolveScannedSetupCodeResultRejectsInvalidInput() {
|
||||
val resolved = resolveScannedSetupCodeResult("not-a-valid-setup-code")
|
||||
assertNull(resolved.setupCode)
|
||||
assertEquals(GatewayEndpointValidationError.INVALID_URL, resolved.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveScannedSetupCodeRejectsJsonWithInvalidSetupCode() {
|
||||
fun resolveScannedSetupCodeResultRejectsJsonWithInvalidSetupCode() {
|
||||
val qrJson = """{"setupCode":"invalid"}"""
|
||||
val resolved = resolveScannedSetupCode(qrJson)
|
||||
assertNull(resolved)
|
||||
val resolved = resolveScannedSetupCodeResult(qrJson)
|
||||
assertNull(resolved.setupCode)
|
||||
assertEquals(GatewayEndpointValidationError.INVALID_URL, resolved.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveScannedSetupCodeRejectsJsonWithNonStringSetupCode() {
|
||||
fun resolveScannedSetupCodeResultRejectsJsonWithNonStringSetupCode() {
|
||||
val qrJson = """{"setupCode":{"nested":"value"}}"""
|
||||
val resolved = resolveScannedSetupCode(qrJson)
|
||||
assertNull(resolved)
|
||||
val resolved = resolveScannedSetupCodeResult(qrJson)
|
||||
assertNull(resolved.setupCode)
|
||||
assertEquals(GatewayEndpointValidationError.INVALID_URL, resolved.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveScannedSetupCodeRejectsNonLoopbackCleartextGateway() {
|
||||
fun resolveScannedSetupCodeResultRejectsNonLoopbackCleartextGateway() {
|
||||
val setupCode =
|
||||
encodeSetupCode("""{"url":"ws://attacker.example:18789","bootstrapToken":"bootstrap-1"}""")
|
||||
|
||||
val resolved = resolveScannedSetupCode(setupCode)
|
||||
val resolved = resolveScannedSetupCodeResult(setupCode)
|
||||
|
||||
assertNull(resolved)
|
||||
assertNull(resolved.setupCode)
|
||||
assertEquals(GatewayEndpointValidationError.INSECURE_REMOTE_URL, resolved.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveScannedSetupCodeAcceptsPrivateLanCleartextGateway() {
|
||||
fun resolveScannedSetupCodeResultAcceptsPrivateLanCleartextGateway() {
|
||||
val setupCode =
|
||||
encodeSetupCode("""{"url":"ws://192.168.31.100:18789","bootstrapToken":"bootstrap-1"}""")
|
||||
|
||||
val resolved = resolveScannedSetupCode(setupCode)
|
||||
val resolved = resolveScannedSetupCodeResult(setupCode)
|
||||
|
||||
assertEquals(setupCode, resolved)
|
||||
assertEquals(setupCode, resolved.setupCode)
|
||||
assertNull(resolved.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.AppearanceThemeMode
|
||||
import ai.openclaw.app.GatewayAgentSummary
|
||||
import ai.openclaw.app.GatewayChannelSummary
|
||||
import ai.openclaw.app.GatewayChannelsSummary
|
||||
import ai.openclaw.app.GatewayNodeApprovalState
|
||||
import ai.openclaw.app.GatewayNodeSummary
|
||||
import ai.openclaw.app.GatewayNodesDevicesSummary
|
||||
import ai.openclaw.app.GatewayPendingDeviceSummary
|
||||
import ai.openclaw.app.ui.design.ClawStatus
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -105,7 +107,7 @@ class ShellScreenLogicTest {
|
||||
assertEquals(listOf("Approvals", "Channels", "Nodes & Devices", "Providers"), rows.map { it.title })
|
||||
val providersRow = rows.single { it.title == "Providers" }
|
||||
assertEquals(Tab.Settings, providersRow.tab)
|
||||
assertEquals(SettingsRoute.Gateway, providersRow.settingsRoute)
|
||||
assertEquals(SettingsRoute.ProvidersModels, providersRow.settingsRoute)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -157,10 +159,206 @@ class ShellScreenLogicTest {
|
||||
assertEquals("Node approval pending", rows.single().subtitle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overviewHeaderStateReflectsGatewayConnectionAndAttention() {
|
||||
assertEquals(OverviewHeaderState("Offline", ClawStatus.Neutral), overviewHeaderState(isConnected = false, hasAttention = true))
|
||||
assertEquals(OverviewHeaderState("Needs attention", ClawStatus.Warning), overviewHeaderState(isConnected = true, hasAttention = true))
|
||||
assertEquals(OverviewHeaderState("Online", ClawStatus.Success), overviewHeaderState(isConnected = true, hasAttention = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overviewHeaderRouteUsesFirstAttentionDestination() {
|
||||
assertEquals(SettingsRoute.Gateway, overviewHeaderRoute(emptyList()))
|
||||
assertEquals(
|
||||
SettingsRoute.Approvals,
|
||||
overviewHeaderRoute(
|
||||
listOf(
|
||||
HomeAttentionRow("Approvals", "2 pending", Icons.Default.Settings, Tab.Settings, SettingsRoute.Approvals),
|
||||
HomeAttentionRow("Nodes & Devices", "Review node access", Icons.Default.Settings, Tab.Settings, SettingsRoute.NodesDevices),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overviewMetricCardsUseRealGatewayNodeApprovalAndSessionCounts() {
|
||||
val cards =
|
||||
overviewMetricCardSpecs(
|
||||
isConnected = true,
|
||||
hasAttention = true,
|
||||
nodesDevicesSummary =
|
||||
GatewayNodesDevicesSummary(
|
||||
nodes =
|
||||
listOf(
|
||||
GatewayNodeSummary(
|
||||
id = "android-node",
|
||||
displayName = "Android",
|
||||
remoteIp = null,
|
||||
version = null,
|
||||
deviceFamily = "Android",
|
||||
paired = true,
|
||||
connected = true,
|
||||
approvalState = GatewayNodeApprovalState.PendingReapproval,
|
||||
pendingRequestId = "node-request",
|
||||
capabilities = emptyList(),
|
||||
commands = emptyList(),
|
||||
),
|
||||
),
|
||||
pendingDevices = emptyList(),
|
||||
pairedDevices = emptyList(),
|
||||
),
|
||||
pendingApprovals = 2,
|
||||
sessionCount = 4,
|
||||
)
|
||||
|
||||
assertEquals(listOf("Gateway", "Nodes", "Approvals", "Sessions"), cards.map { it.title })
|
||||
assertEquals("Online", cards.single { it.title == "Gateway" }.value)
|
||||
assertEquals("Review highlighted items", cards.single { it.title == "Gateway" }.subtitle)
|
||||
assertEquals("1/1", cards.single { it.title == "Nodes" }.value)
|
||||
assertEquals("Review node access", cards.single { it.title == "Nodes" }.subtitle)
|
||||
assertEquals(ClawStatus.Warning, cards.single { it.title == "Nodes" }.status)
|
||||
assertEquals(1f, cards.single { it.title == "Nodes" }.progressFraction ?: 0f, 0.001f)
|
||||
assertEquals("2", cards.single { it.title == "Approvals" }.value)
|
||||
assertEquals("4", cards.single { it.title == "Sessions" }.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overviewNodeCardShowsRoundedOnlinePercentWhenNoNodeApprovalIsPending() {
|
||||
val cards =
|
||||
overviewMetricCardSpecs(
|
||||
isConnected = true,
|
||||
hasAttention = false,
|
||||
nodesDevicesSummary =
|
||||
GatewayNodesDevicesSummary(
|
||||
nodes =
|
||||
(1..3).map { index ->
|
||||
GatewayNodeSummary(
|
||||
id = "node-$index",
|
||||
displayName = "Node $index",
|
||||
remoteIp = null,
|
||||
version = null,
|
||||
deviceFamily = null,
|
||||
paired = true,
|
||||
connected = index <= 2,
|
||||
approvalState = GatewayNodeApprovalState.Approved,
|
||||
pendingRequestId = null,
|
||||
capabilities = emptyList(),
|
||||
commands = emptyList(),
|
||||
)
|
||||
},
|
||||
pendingDevices = emptyList(),
|
||||
pairedDevices = emptyList(),
|
||||
),
|
||||
pendingApprovals = 0,
|
||||
sessionCount = 0,
|
||||
)
|
||||
|
||||
val nodes = cards.single { it.title == "Nodes" }
|
||||
assertEquals("2/3", nodes.value)
|
||||
assertEquals("67% online", nodes.subtitle)
|
||||
assertEquals(2f / 3f, nodes.progressFraction ?: 0f, 0.001f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overviewGatewayCardOnlyClaimsNominalWhenNoAttentionExists() {
|
||||
val cards =
|
||||
overviewMetricCardSpecs(
|
||||
isConnected = true,
|
||||
hasAttention = false,
|
||||
nodesDevicesSummary = emptyNodesDevices(),
|
||||
pendingApprovals = 0,
|
||||
sessionCount = 0,
|
||||
)
|
||||
|
||||
val gateway = cards.single { it.title == "Gateway" }
|
||||
assertEquals("Healthy", gateway.value)
|
||||
assertEquals("All systems nominal", gateway.subtitle)
|
||||
assertEquals(ClawStatus.Success, gateway.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overviewAgentNameUsesDefaultAgentWhenPresent() {
|
||||
val agents =
|
||||
listOf(
|
||||
GatewayAgentSummary(id = "main", name = "Main", emoji = null),
|
||||
GatewayAgentSummary(id = "scout", name = "Scout", emoji = "🦾"),
|
||||
)
|
||||
|
||||
assertEquals("Scout", overviewAgentName(agents = agents, defaultAgentId = "scout"))
|
||||
assertEquals("Main", overviewAgentName(agents = agents, defaultAgentId = null))
|
||||
assertEquals("OpenClaw", overviewAgentName(agents = emptyList(), defaultAgentId = null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overviewAgentBadgeUsesEmojiBeforeInitials() {
|
||||
val agents =
|
||||
listOf(
|
||||
GatewayAgentSummary(id = "main", name = "Main Agent", emoji = null),
|
||||
GatewayAgentSummary(id = "scout", name = "Scout", emoji = "🦾"),
|
||||
)
|
||||
|
||||
assertEquals("🦾", overviewAgentBadgeText(agents = agents, defaultAgentId = "scout"))
|
||||
assertEquals("MA", overviewAgentBadgeText(agents = agents, defaultAgentId = "main"))
|
||||
assertEquals("OC", overviewAgentBadgeText(agents = emptyList(), defaultAgentId = null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overviewAgentActivityTextUsesRealRuntimeCounts() {
|
||||
assertEquals(
|
||||
"Working · 2 active runs",
|
||||
overviewAgentActivityText(isConnected = true, pendingRunCount = 2, sessionCount = 50, cronJobCount = 19, statusText = "Online and ready"),
|
||||
)
|
||||
assertEquals(
|
||||
"Monitoring · 50 sessions",
|
||||
overviewAgentActivityText(isConnected = true, pendingRunCount = 0, sessionCount = 50, cronJobCount = 19, statusText = "Online and ready"),
|
||||
)
|
||||
assertEquals(
|
||||
"Gateway offline",
|
||||
overviewAgentActivityText(isConnected = false, pendingRunCount = 0, sessionCount = 50, cronJobCount = 19, statusText = "Gateway offline"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionSourceLabelDerivesCompactSourceFromRealSessionKey() {
|
||||
assertEquals("Telegram", sessionSourceLabel("telegram:8227096397"))
|
||||
assertEquals("Discord", sessionSourceLabel("discord:1465779285020381361#daily-inf"))
|
||||
assertEquals("Cron", sessionSourceLabel("Cron: nightly-reflection"))
|
||||
assertEquals("Telegram", sessionSourceLabel("agent:main:telegram:direct:584667058"))
|
||||
assertEquals("Discord", sessionSourceLabel("agent:main:discord:channel:1001"))
|
||||
assertEquals("Slack", sessionSourceLabel("agent:main:slack:channel:C123"))
|
||||
assertEquals("OpenClaw", sessionSourceLabel("agent:main:node-android"))
|
||||
assertEquals("OpenClaw", sessionSourceLabel("agent:main:main"))
|
||||
assertEquals("OpenClaw", sessionSourceLabel("Daily standup"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionSourceLabelUsesGatewayChannelLabelsForFutureSources() {
|
||||
val channels =
|
||||
GatewayChannelsSummary(
|
||||
channels =
|
||||
listOf(
|
||||
GatewayChannelSummary(
|
||||
id = "matrix",
|
||||
label = "Matrix",
|
||||
accountCount = 1,
|
||||
enabled = true,
|
||||
configured = true,
|
||||
linked = true,
|
||||
running = true,
|
||||
connected = true,
|
||||
error = null,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("Matrix", sessionSourceLabel("agent:main:matrix:room:abc", channels))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingsSectionTitlesGroupPowerSettingsByMeaning() {
|
||||
assertEquals("Connection", settingsSectionTitleForRoute(SettingsRoute.Gateway))
|
||||
assertEquals("Connection", settingsSectionTitleForRoute(SettingsRoute.NodesDevices))
|
||||
assertEquals("Agents & automation", settingsSectionTitleForRoute(SettingsRoute.ProvidersModels))
|
||||
assertEquals("Agents & automation", settingsSectionTitleForRoute(SettingsRoute.Approvals))
|
||||
assertEquals("Agents & automation", settingsSectionTitleForRoute(SettingsRoute.CronJobs))
|
||||
assertEquals("Phone context & privacy", settingsSectionTitleForRoute(SettingsRoute.PhoneCapabilities))
|
||||
|
||||
@@ -164,7 +164,7 @@ run_mode() {
|
||||
no_connect_flag=false
|
||||
fi
|
||||
|
||||
adb shell am broadcast \
|
||||
adb shell run-as "$PACKAGE_NAME" am broadcast --user 0 \
|
||||
-a "$RUN_ACTION" \
|
||||
-n "$RECEIVER" \
|
||||
--es mode "$test_mode" \
|
||||
@@ -224,7 +224,7 @@ adb logcat -d -v time |
|
||||
tail -250 >"$ARTIFACT_DIR/logcat.txt" || true
|
||||
|
||||
if [[ "$CLEANUP" -eq 1 ]]; then
|
||||
adb shell am broadcast -a "$RUN_ACTION" -n "$RECEIVER" --es mode stop >/dev/null
|
||||
adb shell run-as "$PACKAGE_NAME" am broadcast --user 0 -a "$RUN_ACTION" -n "$RECEIVER" --es mode stop >/dev/null
|
||||
fi
|
||||
|
||||
echo "$ARTIFACT_DIR"
|
||||
|
||||
@@ -2,8 +2,24 @@ parent_config: ../../config/swiftlint.yml
|
||||
|
||||
included:
|
||||
- Sources
|
||||
- ../shared/ClawdisNodeKit/Sources
|
||||
- ShareExtension
|
||||
- ActivityWidget
|
||||
- WatchApp
|
||||
- ../shared/OpenClawKit/Sources/OpenClawChatUI
|
||||
|
||||
excluded:
|
||||
- ../macos
|
||||
|
||||
type_body_length:
|
||||
warning: 900
|
||||
error: 1300
|
||||
|
||||
custom_rules:
|
||||
openclaw_design_colors:
|
||||
name: "OpenClaw design colors"
|
||||
excluded:
|
||||
- Sources/Design/OpenClawBrand.swift
|
||||
- ../shared/OpenClawKit/Sources/OpenClawChatUI/ChatTheme.swift
|
||||
regex: '(Color\.accentColor|(^|[^A-Za-z0-9_])\.accentColor\b|Color\.(red|green|orange|cyan|blue|yellow|purple|pink)\b|\.(foregroundStyle|tint|fill|stroke|strokeBorder|background)\(\s*\.(red|green|orange|cyan|blue|yellow|purple|pink)\b|Color\(red:\s*0\s*/\s*255\.0,\s*green:\s*122\s*/\s*255\.0,\s*blue:\s*255\s*/\s*255\.0\))'
|
||||
message: "Use OpenClawBrand or OpenClawChatTheme design tokens instead of raw accent/status colors."
|
||||
severity: error
|
||||
|
||||
@@ -74,23 +74,30 @@ struct OpenClawLiveActivity: Widget {
|
||||
private func statusIcon(state: OpenClawActivityAttributes.ContentState) -> some View {
|
||||
if state.isConnecting {
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
.foregroundStyle(.cyan)
|
||||
.foregroundStyle(OpenClawActivityStyle.info)
|
||||
} else if state.isDisconnected {
|
||||
Image(systemName: "wifi.slash")
|
||||
.foregroundStyle(.red)
|
||||
.foregroundStyle(OpenClawActivityStyle.danger)
|
||||
} else if state.isIdle {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(.green)
|
||||
.foregroundStyle(OpenClawActivityStyle.ok)
|
||||
} else {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
.foregroundStyle(OpenClawActivityStyle.warn)
|
||||
}
|
||||
}
|
||||
|
||||
private func dotColor(state: OpenClawActivityAttributes.ContentState) -> Color {
|
||||
if state.isDisconnected { return .red }
|
||||
if state.isConnecting { return .cyan }
|
||||
if state.isIdle { return .green }
|
||||
return .orange
|
||||
if state.isDisconnected { return OpenClawActivityStyle.danger }
|
||||
if state.isConnecting { return OpenClawActivityStyle.info }
|
||||
if state.isIdle { return OpenClawActivityStyle.ok }
|
||||
return OpenClawActivityStyle.warn
|
||||
}
|
||||
}
|
||||
|
||||
private enum OpenClawActivityStyle {
|
||||
static let info = Color(red: 0, green: 122 / 255.0, blue: 1)
|
||||
static let danger = Color(red: 185 / 255.0, green: 28 / 255.0, blue: 28 / 255.0)
|
||||
static let ok = Color(red: 34 / 255.0, green: 197 / 255.0, blue: 94 / 255.0)
|
||||
static let warn = Color(red: 245 / 255.0, green: 158 / 255.0, blue: 11 / 255.0)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"platform": "IOS",
|
||||
"profileKey": "OPENCLAW_APP_PROFILE",
|
||||
"profileName": "OpenClaw App Store ai.openclawfoundation.app",
|
||||
"capabilities": ["PUSH_NOTIFICATIONS", "APP_GROUPS"],
|
||||
"capabilities": ["PUSH_NOTIFICATIONS", "APP_GROUPS", "APP_ATTEST"],
|
||||
"appGroups": ["group.ai.openclawfoundation.app.shared"]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ OPENCLAW_IOS_SELECTED_TEAM = $(OPENCLAW_IOS_DEFAULT_TEAM)
|
||||
OPENCLAW_DEVELOPMENT_TEAM = $(OPENCLAW_IOS_SELECTED_TEAM)
|
||||
OPENCLAW_CODE_SIGN_STYLE = Automatic
|
||||
OPENCLAW_CODE_SIGN_IDENTITY = Apple Development
|
||||
OPENCLAW_CODE_SIGN_ENTITLEMENTS = Sources/OpenClaw.entitlements
|
||||
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
|
||||
OPENCLAW_APP_GROUP_ID = group.ai.openclawfoundation.app.shared
|
||||
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
|
||||
|
||||
@@ -67,7 +67,7 @@ Release behavior:
|
||||
- App Store release uses canonical `ai.openclawfoundation.app*` bundle IDs through a temporary generated xcconfig in `apps/ios/build/AppStoreRelease.xcconfig`.
|
||||
- App Store release uses manual `Apple Distribution` signing with profile names pinned in `apps/ios/Config/AppStoreSigning.json`.
|
||||
- Fastlane owns one-time Developer Portal setup, encrypted `match` signing sync to the repo/branch pinned in `apps/ios/Config/AppStoreSigning.json`, and release handling.
|
||||
- App Store release also switches the app to `OpenClawPushTransport=relay`, `OpenClawPushDistribution=official`, `OpenClawPushAPNsEnvironment=production`, and a production `aps-environment` entitlement.
|
||||
- App Store release also switches the app to `OpenClawPushTransport=relay`, `OpenClawPushDistribution=official`, `OpenClawPushAPNsEnvironment=production`, `OpenClawPushRelayProfile=production`, `OpenClawPushProofPolicy=appleStrict`, and the App-Attest-capable entitlement file.
|
||||
- `pnpm ios:release:upload` generates App Store screenshots and uploads release notes before archiving and uploading the IPA.
|
||||
- `pnpm ios:release` remains a compatibility alias for `pnpm ios:release:upload`; prefer the explicit upload command in new release docs and automation.
|
||||
- App Review submission is manual in App Store Connect. The release lane uploads a build and metadata, but does not submit for review.
|
||||
@@ -102,6 +102,7 @@ Release-owner secrets:
|
||||
- App Store Connect API auth uses Keychain for private key material plus non-secret `apps/ios/fastlane/.env` variables.
|
||||
- The encrypted signing repo password lives outside this repo in the release-owner vault and is exposed locally as `MATCH_PASSWORD`.
|
||||
- The share sheet requires the Apple Developer App Group in `apps/ios/Config/AppStoreSigning.json` to be associated with both the app and share-extension bundle IDs before App Store profiles are regenerated.
|
||||
- Relay registration requires the App Attest capability on the main app ID before App Store profiles are regenerated.
|
||||
- Apple Distribution private keys, certificates, provisioning profiles, and decrypted signing sync output stay under `apps/ios/build/` or Keychain and are gitignored.
|
||||
- Rotating release signing means refreshing Fastlane `match` assets and pushing a fresh encrypted sync state.
|
||||
|
||||
@@ -157,7 +158,7 @@ This should create `apps/ios/fastlane/.env` with non-secret App Store Connect va
|
||||
- `ai.openclawfoundation.app.activitywidget`
|
||||
- `ai.openclawfoundation.app.watchkitapp`
|
||||
|
||||
The main app and share extension must both be associated with the App Group pinned in `apps/ios/Config/AppStoreSigning.json`.
|
||||
The main app and share extension must both be associated with the App Group pinned in `apps/ios/Config/AppStoreSigning.json`. The main app must also have App Attest enabled.
|
||||
|
||||
Use `pnpm ios:release:signing:setup` for the initial portal setup, then `MATCH_PASSWORD=... pnpm ios:release:signing:sync:push` to publish encrypted Fastlane match assets to the shared private repo.
|
||||
|
||||
@@ -243,6 +244,7 @@ See `apps/ios/VERSIONING.md` for the detailed spec.
|
||||
|
||||
- The app calls `registerForRemoteNotifications()` at launch.
|
||||
- `apps/ios/Sources/OpenClaw.entitlements` derives `aps-environment` from the active build configuration/signing override.
|
||||
- App Attest relay builds use `apps/ios/Sources/OpenClawAppAttest.entitlements`; local/direct builds do not require App Attest provisioning.
|
||||
- APNs token registration to gateway happens only after gateway connection (`push.apns.register`).
|
||||
- Local/manual builds default to `OpenClawPushTransport=direct`, `OpenClawPushDistribution=local`, and a development `aps-environment` entitlement.
|
||||
- Your selected team/profile must support Push Notifications for the app bundle ID you are signing.
|
||||
@@ -259,7 +261,7 @@ See `apps/ios/VERSIONING.md` for the detailed spec.
|
||||
- The relay registration is bound to the gateway identity fetched from `gateway.identity.get`, so another gateway cannot reuse that stored registration.
|
||||
- The app persists the relay handle metadata locally so reconnects can republish the gateway registration without re-registering on every connect.
|
||||
- If the relay base URL changes in a later build, the app refreshes the relay registration instead of reusing the old relay origin.
|
||||
- Relay mode requires a reachable relay base URL and uses App Attest plus a StoreKit app transaction JWS during registration.
|
||||
- Production relay mode uses the `production` relay profile, production APNs, App Attest, and a StoreKit app transaction JWS during registration.
|
||||
- Gateway-side relay sending is configured through `gateway.push.apns.relay.baseUrl` in `openclaw.json`. `OPENCLAW_APNS_RELAY_BASE_URL` remains a temporary env override only.
|
||||
|
||||
## Official Build Relay Trust Model
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
OPENCLAW_CODE_SIGN_STYLE = Manual
|
||||
OPENCLAW_CODE_SIGN_IDENTITY = Apple Development
|
||||
OPENCLAW_CODE_SIGN_ENTITLEMENTS = Sources/OpenClaw.entitlements
|
||||
OPENCLAW_DEVELOPMENT_TEAM = FWJYW4S8P8
|
||||
|
||||
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
|
||||
|
||||
@@ -506,7 +506,7 @@ extension AgentProTab {
|
||||
|
||||
func skillEditorSwitchIndicator(isOn: Bool) -> some View {
|
||||
Capsule()
|
||||
.fill(isOn ? Color.accentColor : Color.secondary.opacity(0.35))
|
||||
.fill(isOn ? OpenClawBrand.accent : Color.secondary.opacity(0.35))
|
||||
.frame(width: 52, height: 32)
|
||||
.overlay(alignment: isOn ? .trailing : .leading) {
|
||||
Circle()
|
||||
|
||||
@@ -105,7 +105,7 @@ struct AgentProTab: View {
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .online: OpenClawBrand.ok
|
||||
case .ready: Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0)
|
||||
case .ready: OpenClawBrand.info
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ struct ChatProTab: View {
|
||||
}
|
||||
|
||||
private var chatUserAccent: Color {
|
||||
self.colorScheme == .light ? Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0) : OpenClawBrand.accent
|
||||
self.colorScheme == .light ? OpenClawBrand.info : OpenClawBrand.accent
|
||||
}
|
||||
|
||||
private var activeAgent: AgentSummary? {
|
||||
|
||||
@@ -1036,7 +1036,7 @@ struct IPadSkillProposalRow: View {
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
self.isSelected ? Color.red.opacity(0.08) : Color.clear,
|
||||
self.isSelected ? OpenClawBrand.danger.opacity(0.08) : Color.clear,
|
||||
in: RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,11 +47,13 @@ enum AppAppearancePreference: String, CaseIterable, Identifiable {
|
||||
}
|
||||
|
||||
enum OpenClawBrand {
|
||||
static let accent = Color(uiColor: UIColor { traits in
|
||||
static let uiAccent = UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 198 / 255.0, green: 62 / 255.0, blue: 56 / 255.0, alpha: 1)
|
||||
: UIColor(red: 183 / 255.0, green: 56 / 255.0, blue: 51 / 255.0, alpha: 1)
|
||||
})
|
||||
}
|
||||
|
||||
static let accent = Color(uiColor: Self.uiAccent)
|
||||
static let accentHot = Color(uiColor: UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 232 / 255.0, green: 92 / 255.0, blue: 86 / 255.0, alpha: 1)
|
||||
@@ -64,6 +66,7 @@ enum OpenClawBrand {
|
||||
})
|
||||
static let ok = Color(red: 34 / 255.0, green: 197 / 255.0, blue: 94 / 255.0)
|
||||
static let warn = Color(red: 245 / 255.0, green: 158 / 255.0, blue: 11 / 255.0)
|
||||
static let info = Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0)
|
||||
static let graphite = Color(uiColor: UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 20 / 255.0, green: 22 / 255.0, blue: 24 / 255.0, alpha: 1)
|
||||
|
||||
@@ -819,8 +819,11 @@ extension SettingsProTab {
|
||||
|
||||
var notificationRelayDetail: String {
|
||||
if PushBuildConfig.current.usesOpenClawHostedRelay {
|
||||
let host = PushBuildConfig.current.relayBaseURL.flatMap {
|
||||
URLComponents(url: $0, resolvingAgainstBaseURL: false)?.host
|
||||
} ?? "ios-push-relay.openclaw.ai"
|
||||
return """
|
||||
This build uses OpenClaw's hosted push relay at ios-push-relay.openclaw.ai for notification \
|
||||
This build uses OpenClaw's hosted push relay at \(host) for notification \
|
||||
delivery data.
|
||||
"""
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ extension SettingsProTab {
|
||||
self.gatewayActionButton(
|
||||
title: "Diagnose",
|
||||
icon: "cross.case",
|
||||
color: Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0),
|
||||
color: OpenClawBrand.info,
|
||||
isBusy: self.isRefreshingGateway)
|
||||
{
|
||||
Task { await self.runDiagnostics() }
|
||||
@@ -476,7 +476,7 @@ extension SettingsProTab {
|
||||
self.gatewayActionButton(
|
||||
title: "Run Diagnostics",
|
||||
icon: "cross.case",
|
||||
color: Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0),
|
||||
color: OpenClawBrand.info,
|
||||
isBusy: self.isRefreshingGateway)
|
||||
{
|
||||
Task { await self.runDiagnostics() }
|
||||
@@ -1040,7 +1040,7 @@ extension SettingsProTab {
|
||||
|
||||
func settingsSwitchIndicator(isOn: Bool) -> some View {
|
||||
Capsule()
|
||||
.fill(isOn ? Color.accentColor : Color.secondary.opacity(0.35))
|
||||
.fill(isOn ? OpenClawBrand.accent : Color.secondary.opacity(0.35))
|
||||
.frame(width: 52, height: 32)
|
||||
.overlay(alignment: isOn ? .trailing : .leading) {
|
||||
Circle()
|
||||
|
||||
@@ -90,7 +90,7 @@ private struct ExecApprovalPromptCard: View {
|
||||
if let errorText = self.normalized(self.errorText) {
|
||||
Text(errorText)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.red)
|
||||
.foregroundStyle(OpenClawBrand.danger)
|
||||
}
|
||||
|
||||
if self.isResolving {
|
||||
|
||||
@@ -86,8 +86,12 @@
|
||||
<string>$(OPENCLAW_PUSH_APNS_ENVIRONMENT)</string>
|
||||
<key>OpenClawPushDistribution</key>
|
||||
<string>$(OPENCLAW_PUSH_DISTRIBUTION)</string>
|
||||
<key>OpenClawPushProofPolicy</key>
|
||||
<string>$(OPENCLAW_PUSH_PROOF_POLICY)</string>
|
||||
<key>OpenClawPushRelayBaseURL</key>
|
||||
<string>$(OPENCLAW_PUSH_RELAY_BASE_URL)</string>
|
||||
<key>OpenClawPushRelayProfile</key>
|
||||
<string>$(OPENCLAW_PUSH_RELAY_PROFILE)</string>
|
||||
<key>OpenClawPushTransport</key>
|
||||
<string>$(OPENCLAW_PUSH_TRANSPORT)</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
|
||||
@@ -40,7 +40,7 @@ struct OnboardingIntroStep: View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(.orange)
|
||||
.foregroundStyle(OpenClawBrand.warn)
|
||||
.frame(width: 24)
|
||||
.padding(.top, 2)
|
||||
|
||||
@@ -177,7 +177,7 @@ struct OnboardingModeRow: View {
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: self.selected ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(self.selected ? Color.accentColor : Color.secondary)
|
||||
.foregroundStyle(self.selected ? OpenClawBrand.accent : Color.secondary)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
@@ -378,7 +378,7 @@ struct OnboardingWizardView: View {
|
||||
|
||||
private func onboardingSwitchIndicator(isOn: Bool) -> some View {
|
||||
Capsule()
|
||||
.fill(isOn ? Color.accentColor : Color.secondary.opacity(0.35))
|
||||
.fill(isOn ? OpenClawBrand.accent : Color.secondary.opacity(0.35))
|
||||
.frame(width: 52, height: 32)
|
||||
.overlay(alignment: isOn ? .trailing : .leading) {
|
||||
Circle()
|
||||
@@ -575,7 +575,7 @@ struct OnboardingWizardView: View {
|
||||
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 64))
|
||||
.foregroundStyle(.green)
|
||||
.foregroundStyle(OpenClawBrand.ok)
|
||||
.padding(.bottom, 20)
|
||||
|
||||
Text("Connected")
|
||||
|
||||
@@ -632,6 +632,7 @@ struct OpenClawApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootTabs()
|
||||
.tint(OpenClawBrand.accent)
|
||||
.preferredColorScheme(self.appearancePreference.colorScheme)
|
||||
.environment(self.appModel)
|
||||
.environment(self.appModel.voiceWake)
|
||||
@@ -686,6 +687,7 @@ struct OpenClawApp: App {
|
||||
.flatMap(\.windows)
|
||||
.forEach { window in
|
||||
window.overrideUserInterfaceStyle = style
|
||||
window.tintColor = OpenClawBrand.uiAccent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
14
apps/ios/Sources/OpenClawAppAttest.entitlements
Normal file
14
apps/ios/Sources/OpenClawAppAttest.entitlements
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>$(OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT)</string>
|
||||
<key>com.apple.developer.devicecheck.appattest-environment</key>
|
||||
<string>$(OPENCLAW_APP_ATTEST_ENVIRONMENT)</string>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>$(OPENCLAW_APP_GROUP_ID)</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -15,14 +15,29 @@ enum PushAPNsEnvironment: String {
|
||||
case production
|
||||
}
|
||||
|
||||
enum PushRelayProfile: String {
|
||||
case production
|
||||
case deviceSandbox
|
||||
case simulatorSandbox
|
||||
}
|
||||
|
||||
enum PushProofPolicy: String {
|
||||
case appleStrict
|
||||
case appleDevelopment
|
||||
case internalSimulator
|
||||
}
|
||||
|
||||
struct PushBuildConfig {
|
||||
let transport: PushTransportMode
|
||||
let distribution: PushDistributionMode
|
||||
let relayBaseURL: URL?
|
||||
let apnsEnvironment: PushAPNsEnvironment
|
||||
let relayProfile: PushRelayProfile
|
||||
let proofPolicy: PushProofPolicy
|
||||
|
||||
static let current = PushBuildConfig()
|
||||
static let openClawHostedRelayHost = "ios-push-relay.openclaw.ai"
|
||||
static let openClawSandboxRelayHost = "ios-push-relay-sandbox.openclaw.ai"
|
||||
|
||||
var usesOpenClawHostedRelay: Bool {
|
||||
guard self.transport == .relay, self.distribution == .official else { return false }
|
||||
@@ -32,7 +47,8 @@ struct PushBuildConfig {
|
||||
return false
|
||||
}
|
||||
return components.scheme?.lowercased() == "https"
|
||||
&& components.host?.lowercased() == Self.openClawHostedRelayHost
|
||||
&& [Self.openClawHostedRelayHost, Self.openClawSandboxRelayHost]
|
||||
.contains(components.host?.lowercased() ?? "")
|
||||
&& components.user == nil
|
||||
&& components.password == nil
|
||||
}
|
||||
@@ -50,6 +66,14 @@ struct PushBuildConfig {
|
||||
bundle: bundle,
|
||||
key: "OpenClawPushAPNsEnvironment",
|
||||
fallback: Self.defaultAPNsEnvironment)
|
||||
self.relayProfile = Self.readEnum(
|
||||
bundle: bundle,
|
||||
key: "OpenClawPushRelayProfile",
|
||||
fallback: Self.defaultRelayProfile(apnsEnvironment: self.apnsEnvironment))
|
||||
self.proofPolicy = Self.readEnum(
|
||||
bundle: bundle,
|
||||
key: "OpenClawPushProofPolicy",
|
||||
fallback: Self.defaultProofPolicy(relayProfile: self.relayProfile))
|
||||
self.relayBaseURL = Self.readURL(bundle: bundle, key: "OpenClawPushRelayBaseURL")
|
||||
}
|
||||
|
||||
@@ -77,9 +101,24 @@ struct PushBuildConfig {
|
||||
fallback: T)
|
||||
-> T where T.RawValue == String {
|
||||
guard let raw = bundle.object(forInfoDictionaryKey: key) as? String else { return fallback }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return T(rawValue: trimmed) ?? fallback
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return T(rawValue: trimmed) ?? T(rawValue: trimmed.lowercased()) ?? fallback
|
||||
}
|
||||
|
||||
private static let defaultAPNsEnvironment: PushAPNsEnvironment = .sandbox
|
||||
|
||||
private static func defaultRelayProfile(apnsEnvironment: PushAPNsEnvironment) -> PushRelayProfile {
|
||||
apnsEnvironment == .production ? .production : .deviceSandbox
|
||||
}
|
||||
|
||||
private static func defaultProofPolicy(relayProfile: PushRelayProfile) -> PushProofPolicy {
|
||||
switch relayProfile {
|
||||
case .production:
|
||||
.appleStrict
|
||||
case .deviceSandbox:
|
||||
.appleDevelopment
|
||||
case .simulatorSandbox:
|
||||
.internalSimulator
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,10 +71,10 @@ actor PushRegistrationManager {
|
||||
throw PushRelayError.relayMisconfigured(
|
||||
"Relay transport requires OpenClawPushDistribution=official")
|
||||
}
|
||||
guard self.buildConfig.apnsEnvironment == .production else {
|
||||
throw PushRelayError.relayMisconfigured(
|
||||
"Relay transport requires OpenClawPushAPNsEnvironment=production")
|
||||
}
|
||||
try Self.validateRelayContract(
|
||||
relayProfile: self.buildConfig.relayProfile,
|
||||
apnsEnvironment: self.buildConfig.apnsEnvironment,
|
||||
proofPolicy: self.buildConfig.proofPolicy)
|
||||
guard let relayClient = self.relayClient else {
|
||||
throw PushRelayError.relayBaseURLMissing
|
||||
}
|
||||
@@ -96,6 +96,9 @@ actor PushRegistrationManager {
|
||||
stored.installationId == installationId,
|
||||
stored.gatewayDeviceId == gatewayIdentity.deviceId,
|
||||
stored.relayOrigin == relayOrigin,
|
||||
stored.apnsEnvironment == self.buildConfig.apnsEnvironment.rawValue,
|
||||
stored.relayProfile == self.buildConfig.relayProfile.rawValue,
|
||||
stored.proofPolicy == self.buildConfig.proofPolicy.rawValue,
|
||||
stored.lastAPNsTokenHashHex == tokenHashHex,
|
||||
!Self.isExpired(stored.relayHandleExpiresAtMs)
|
||||
{
|
||||
@@ -112,14 +115,16 @@ actor PushRegistrationManager {
|
||||
tokenDebugSuffix: stored.tokenDebugSuffix))
|
||||
}
|
||||
|
||||
let response = try await relayClient.register(
|
||||
let response = try await relayClient.register(PushRelayRegistrationInput(
|
||||
installationId: installationId,
|
||||
bundleId: bundleId,
|
||||
appVersion: DeviceInfoHelper.appVersion(),
|
||||
environment: self.buildConfig.apnsEnvironment,
|
||||
relayProfile: self.buildConfig.relayProfile,
|
||||
proofPolicy: self.buildConfig.proofPolicy,
|
||||
distribution: self.buildConfig.distribution,
|
||||
apnsTokenHex: apnsTokenHex,
|
||||
gatewayIdentity: gatewayIdentity)
|
||||
gatewayIdentity: gatewayIdentity))
|
||||
let registrationState = PushRelayRegistrationStore.RegistrationState(
|
||||
relayHandle: response.relayHandle,
|
||||
sendGrant: response.sendGrant,
|
||||
@@ -129,7 +134,10 @@ actor PushRegistrationManager {
|
||||
tokenDebugSuffix: Self.normalizeTokenSuffix(response.tokenSuffix),
|
||||
lastAPNsTokenHashHex: tokenHashHex,
|
||||
installationId: installationId,
|
||||
lastTransport: self.buildConfig.transport.rawValue)
|
||||
lastTransport: self.buildConfig.transport.rawValue,
|
||||
apnsEnvironment: self.buildConfig.apnsEnvironment.rawValue,
|
||||
relayProfile: self.buildConfig.relayProfile.rawValue,
|
||||
proofPolicy: self.buildConfig.proofPolicy.rawValue)
|
||||
_ = PushRelayRegistrationStore.saveRegistrationState(registrationState)
|
||||
return try Self.encodePayload(
|
||||
RelayGatewayPushRegistrationPayload(
|
||||
@@ -151,6 +159,30 @@ actor PushRegistrationManager {
|
||||
return expiresAtMs <= nowMs + 60000
|
||||
}
|
||||
|
||||
private static func validateRelayContract(
|
||||
relayProfile: PushRelayProfile,
|
||||
apnsEnvironment: PushAPNsEnvironment,
|
||||
proofPolicy: PushProofPolicy)
|
||||
throws {
|
||||
switch relayProfile {
|
||||
case .production:
|
||||
guard apnsEnvironment == .production, proofPolicy == .appleStrict else {
|
||||
throw PushRelayError.relayMisconfigured(
|
||||
"production relay profile requires production APNs and appleStrict proof")
|
||||
}
|
||||
case .deviceSandbox:
|
||||
guard apnsEnvironment == .sandbox, proofPolicy == .appleDevelopment else {
|
||||
throw PushRelayError.relayMisconfigured(
|
||||
"deviceSandbox relay profile requires sandbox APNs and appleDevelopment proof")
|
||||
}
|
||||
case .simulatorSandbox:
|
||||
guard apnsEnvironment == .sandbox, proofPolicy == .internalSimulator else {
|
||||
throw PushRelayError.relayMisconfigured(
|
||||
"simulatorSandbox relay profile requires sandbox APNs and internalSimulator proof")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func sha256Hex(_ value: String) -> String {
|
||||
let digest = SHA256.hash(data: Data(value.utf8))
|
||||
return digest.map { String(format: "%02x", $0) }.joined()
|
||||
|
||||
@@ -40,6 +40,9 @@ private struct PushRelayRegisterSignedPayload: Encodable {
|
||||
var installationId: String
|
||||
var bundleId: String
|
||||
var environment: String
|
||||
var relayProfile: String
|
||||
var apnsEnvironment: String
|
||||
var proofPolicy: String
|
||||
var distribution: String
|
||||
var gateway: PushRelayGatewayIdentity
|
||||
var appVersion: String
|
||||
@@ -63,12 +66,16 @@ private struct PushRelayRegisterRequest: Encodable {
|
||||
var installationId: String
|
||||
var bundleId: String
|
||||
var environment: String
|
||||
var relayProfile: String
|
||||
var apnsEnvironment: String
|
||||
var proofPolicy: String
|
||||
var distribution: String
|
||||
var gateway: PushRelayGatewayIdentity
|
||||
var appVersion: String
|
||||
var apnsToken: String
|
||||
var appAttest: PushRelayAppAttestPayload
|
||||
var receipt: PushRelayReceiptPayload
|
||||
var appAttest: PushRelayAppAttestPayload?
|
||||
var receipt: PushRelayReceiptPayload?
|
||||
var simulatorProof: PushRelaySimulatorProofPayload?
|
||||
}
|
||||
|
||||
struct PushRelayRegisterResponse: Decodable {
|
||||
@@ -93,23 +100,34 @@ private struct PushRelayAppAttestProof {
|
||||
var signedPayloadBase64: String
|
||||
}
|
||||
|
||||
private struct PushRelaySimulatorProofPayload: Encodable {
|
||||
var signedPayloadBase64: String
|
||||
var hmacSha256Base64Url: String
|
||||
}
|
||||
|
||||
private final class PushRelayAppAttestService {
|
||||
func createProof(challenge: String, signedPayload: Data) async throws -> PushRelayAppAttestProof {
|
||||
func createProof(
|
||||
challenge: String,
|
||||
signedPayload: Data,
|
||||
scope: PushRelayRegistrationStore.AppAttestScope)
|
||||
async throws -> PushRelayAppAttestProof {
|
||||
let service = DCAppAttestService.shared
|
||||
guard service.isSupported else {
|
||||
throw PushRelayError.unsupportedAppAttest
|
||||
}
|
||||
|
||||
let keyID = try await self.loadOrCreateKeyID(using: service)
|
||||
let keyID = try await self.loadOrCreateKeyID(using: service, scope: scope)
|
||||
let attestationObject = try await self.attestKeyIfNeeded(
|
||||
service: service,
|
||||
keyID: keyID,
|
||||
challenge: challenge)
|
||||
challenge: challenge,
|
||||
scope: scope)
|
||||
let signedPayloadHash = Data(SHA256.hash(data: signedPayload))
|
||||
let assertion = try await self.generateAssertion(
|
||||
service: service,
|
||||
keyID: keyID,
|
||||
signedPayloadHash: signedPayloadHash)
|
||||
signedPayloadHash: signedPayloadHash,
|
||||
scope: scope)
|
||||
|
||||
return PushRelayAppAttestProof(
|
||||
keyId: keyID,
|
||||
@@ -119,21 +137,27 @@ private final class PushRelayAppAttestService {
|
||||
signedPayloadBase64: signedPayload.base64EncodedString())
|
||||
}
|
||||
|
||||
private func loadOrCreateKeyID(using service: DCAppAttestService) async throws -> String {
|
||||
if let existing = PushRelayRegistrationStore.loadAppAttestKeyID(), !existing.isEmpty {
|
||||
private func loadOrCreateKeyID(
|
||||
using service: DCAppAttestService,
|
||||
scope: PushRelayRegistrationStore.AppAttestScope)
|
||||
async throws -> String {
|
||||
if let existing = PushRelayRegistrationStore.loadAppAttestKeyID(scope: scope),
|
||||
!existing.isEmpty
|
||||
{
|
||||
return existing
|
||||
}
|
||||
let keyID = try await service.generateKey()
|
||||
_ = PushRelayRegistrationStore.saveAppAttestKeyID(keyID)
|
||||
_ = PushRelayRegistrationStore.saveAppAttestKeyID(keyID, scope: scope)
|
||||
return keyID
|
||||
}
|
||||
|
||||
private func attestKeyIfNeeded(
|
||||
service: DCAppAttestService,
|
||||
keyID: String,
|
||||
challenge: String)
|
||||
challenge: String,
|
||||
scope: PushRelayRegistrationStore.AppAttestScope)
|
||||
async throws -> String? {
|
||||
if PushRelayRegistrationStore.loadAttestedKeyID() == keyID {
|
||||
if PushRelayRegistrationStore.loadAttestedKeyID(scope: scope) == keyID {
|
||||
return nil
|
||||
}
|
||||
let challengeData = Data(challenge.utf8)
|
||||
@@ -142,20 +166,21 @@ private final class PushRelayAppAttestService {
|
||||
// Apple treats App Attest key attestation as a one-time operation. Save the
|
||||
// attested marker immediately so later receipt/network failures do not cause a
|
||||
// permanently broken re-attestation loop on the same key.
|
||||
_ = PushRelayRegistrationStore.saveAttestedKeyID(keyID)
|
||||
_ = PushRelayRegistrationStore.saveAttestedKeyID(keyID, scope: scope)
|
||||
return attestation.base64EncodedString()
|
||||
}
|
||||
|
||||
private func generateAssertion(
|
||||
service: DCAppAttestService,
|
||||
keyID: String,
|
||||
signedPayloadHash: Data)
|
||||
signedPayloadHash: Data,
|
||||
scope: PushRelayRegistrationStore.AppAttestScope)
|
||||
async throws -> Data {
|
||||
do {
|
||||
return try await service.generateAssertion(keyID, clientDataHash: signedPayloadHash)
|
||||
} catch {
|
||||
_ = PushRelayRegistrationStore.clearAppAttestKeyID()
|
||||
_ = PushRelayRegistrationStore.clearAttestedKeyID()
|
||||
_ = PushRelayRegistrationStore.clearAppAttestKeyID(scope: scope)
|
||||
_ = PushRelayRegistrationStore.clearAttestedKeyID(scope: scope)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -190,6 +215,47 @@ private final class PushRelayReceiptProvider {
|
||||
}
|
||||
}
|
||||
|
||||
private final class PushRelaySimulatorProofProvider {
|
||||
func createProof(signedPayload: Data) throws -> PushRelaySimulatorProofPayload {
|
||||
#if targetEnvironment(simulator)
|
||||
guard let secret = ProcessInfo.processInfo.environment["OPENCLAW_SIMULATOR_PUSH_PROOF_SECRET"]?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!secret.isEmpty
|
||||
else {
|
||||
throw PushRelayError.relayMisconfigured("Simulator push proof secret missing")
|
||||
}
|
||||
let signedPayloadBase64 = signedPayload.base64EncodedString()
|
||||
let signature = HMAC<SHA256>.authenticationCode(
|
||||
for: Data(signedPayloadBase64.utf8),
|
||||
using: SymmetricKey(data: Data(secret.utf8)))
|
||||
return PushRelaySimulatorProofPayload(
|
||||
signedPayloadBase64: signedPayloadBase64,
|
||||
hmacSha256Base64Url: Self.base64URL(Data(signature)))
|
||||
#else
|
||||
throw PushRelayError.relayMisconfigured("Simulator proof is only available in iOS Simulator")
|
||||
#endif
|
||||
}
|
||||
|
||||
private static func base64URL(_ data: Data) -> String {
|
||||
data.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
}
|
||||
|
||||
struct PushRelayRegistrationInput {
|
||||
var installationId: String
|
||||
var bundleId: String
|
||||
var appVersion: String
|
||||
var environment: PushAPNsEnvironment
|
||||
var relayProfile: PushRelayProfile
|
||||
var proofPolicy: PushProofPolicy
|
||||
var distribution: PushDistributionMode
|
||||
var apnsTokenHex: String
|
||||
var gatewayIdentity: PushRelayGatewayIdentity
|
||||
}
|
||||
|
||||
/// The client is constructed once and used behind PushRegistrationManager actor isolation.
|
||||
final class PushRelayClient: @unchecked Sendable {
|
||||
private let baseURL: URL
|
||||
@@ -198,6 +264,7 @@ final class PushRelayClient: @unchecked Sendable {
|
||||
private let jsonEncoder = JSONEncoder()
|
||||
private let appAttest = PushRelayAppAttestService()
|
||||
private let receiptProvider = PushRelayReceiptProvider()
|
||||
private let simulatorProofProvider = PushRelaySimulatorProofProvider()
|
||||
|
||||
init(baseURL: URL, session: URLSession = .shared) {
|
||||
self.baseURL = baseURL
|
||||
@@ -208,46 +275,57 @@ final class PushRelayClient: @unchecked Sendable {
|
||||
Self.normalizeBaseURLString(self.baseURL)
|
||||
}
|
||||
|
||||
func register(
|
||||
installationId: String,
|
||||
bundleId: String,
|
||||
appVersion: String,
|
||||
environment: PushAPNsEnvironment,
|
||||
distribution: PushDistributionMode,
|
||||
apnsTokenHex: String,
|
||||
gatewayIdentity: PushRelayGatewayIdentity)
|
||||
async throws -> PushRelayRegisterResponse {
|
||||
func register(_ input: PushRelayRegistrationInput) async throws -> PushRelayRegisterResponse {
|
||||
let challenge = try await self.fetchChallenge()
|
||||
let signedPayload = PushRelayRegisterSignedPayload(
|
||||
challengeId: challenge.challengeId,
|
||||
installationId: installationId,
|
||||
bundleId: bundleId,
|
||||
environment: environment.rawValue,
|
||||
distribution: distribution.rawValue,
|
||||
gateway: gatewayIdentity,
|
||||
appVersion: appVersion,
|
||||
apnsToken: apnsTokenHex)
|
||||
installationId: input.installationId,
|
||||
bundleId: input.bundleId,
|
||||
environment: input.environment.rawValue,
|
||||
relayProfile: input.relayProfile.rawValue,
|
||||
apnsEnvironment: input.environment.rawValue,
|
||||
proofPolicy: input.proofPolicy.rawValue,
|
||||
distribution: input.distribution.rawValue,
|
||||
gateway: input.gatewayIdentity,
|
||||
appVersion: input.appVersion,
|
||||
apnsToken: input.apnsTokenHex)
|
||||
let signedPayloadData = try self.jsonEncoder.encode(signedPayload)
|
||||
let appAttest = try await self.appAttest.createProof(
|
||||
let appAttestScope = PushRelayRegistrationStore.AppAttestScope(
|
||||
relayOrigin: self.normalizedBaseURLString,
|
||||
apnsEnvironment: input.environment.rawValue,
|
||||
relayProfile: input.relayProfile.rawValue,
|
||||
proofPolicy: input.proofPolicy.rawValue)
|
||||
let appAttest = try await self.createAppAttestProofIfNeeded(
|
||||
proofPolicy: input.proofPolicy,
|
||||
challenge: challenge.challenge,
|
||||
signedPayload: signedPayloadData)
|
||||
let receiptBase64 = try await self.receiptProvider.loadReceiptBase64()
|
||||
signedPayloadData: signedPayloadData,
|
||||
scope: appAttestScope)
|
||||
let receipt = try await self.createReceiptIfNeeded(proofPolicy: input.proofPolicy)
|
||||
let simulatorProof = try self.createSimulatorProofIfNeeded(
|
||||
proofPolicy: input.proofPolicy,
|
||||
signedPayloadData: signedPayloadData)
|
||||
let requestBody = PushRelayRegisterRequest(
|
||||
challengeId: signedPayload.challengeId,
|
||||
installationId: signedPayload.installationId,
|
||||
bundleId: signedPayload.bundleId,
|
||||
environment: signedPayload.environment,
|
||||
relayProfile: signedPayload.relayProfile,
|
||||
apnsEnvironment: signedPayload.apnsEnvironment,
|
||||
proofPolicy: signedPayload.proofPolicy,
|
||||
distribution: signedPayload.distribution,
|
||||
gateway: signedPayload.gateway,
|
||||
appVersion: signedPayload.appVersion,
|
||||
apnsToken: signedPayload.apnsToken,
|
||||
appAttest: PushRelayAppAttestPayload(
|
||||
keyId: appAttest.keyId,
|
||||
attestationObject: appAttest.attestationObject,
|
||||
assertion: appAttest.assertion,
|
||||
clientDataHash: appAttest.clientDataHash,
|
||||
signedPayloadBase64: appAttest.signedPayloadBase64),
|
||||
receipt: PushRelayReceiptPayload(base64: receiptBase64))
|
||||
appAttest: appAttest.map {
|
||||
PushRelayAppAttestPayload(
|
||||
keyId: $0.keyId,
|
||||
attestationObject: $0.attestationObject,
|
||||
assertion: $0.assertion,
|
||||
clientDataHash: $0.clientDataHash,
|
||||
signedPayloadBase64: $0.signedPayloadBase64)
|
||||
},
|
||||
receipt: receipt,
|
||||
simulatorProof: simulatorProof)
|
||||
|
||||
let endpoint = self.baseURL.appending(path: "v1/push/register")
|
||||
var request = URLRequest(url: endpoint)
|
||||
@@ -262,8 +340,8 @@ final class PushRelayClient: @unchecked Sendable {
|
||||
if status == 401 {
|
||||
// If the relay rejects registration, drop local App Attest state so the next
|
||||
// attempt re-attests instead of getting stuck without an attestation object.
|
||||
_ = PushRelayRegistrationStore.clearAppAttestKeyID()
|
||||
_ = PushRelayRegistrationStore.clearAttestedKeyID()
|
||||
_ = PushRelayRegistrationStore.clearAppAttestKeyID(scope: appAttestScope)
|
||||
_ = PushRelayRegistrationStore.clearAttestedKeyID(scope: appAttestScope)
|
||||
}
|
||||
throw PushRelayError.requestFailed(
|
||||
status: status,
|
||||
@@ -272,6 +350,43 @@ final class PushRelayClient: @unchecked Sendable {
|
||||
return try self.decode(PushRelayRegisterResponse.self, from: data)
|
||||
}
|
||||
|
||||
private func createAppAttestProofIfNeeded(
|
||||
proofPolicy: PushProofPolicy,
|
||||
challenge: String,
|
||||
signedPayloadData: Data,
|
||||
scope: PushRelayRegistrationStore.AppAttestScope)
|
||||
async throws -> PushRelayAppAttestProof? {
|
||||
guard proofPolicy != .internalSimulator else { return nil }
|
||||
return try await self.appAttest.createProof(
|
||||
challenge: challenge,
|
||||
signedPayload: signedPayloadData,
|
||||
scope: scope)
|
||||
}
|
||||
|
||||
private func createReceiptIfNeeded(
|
||||
proofPolicy: PushProofPolicy)
|
||||
async throws -> PushRelayReceiptPayload? {
|
||||
switch proofPolicy {
|
||||
case .appleStrict:
|
||||
return try await PushRelayReceiptPayload(base64: self.receiptProvider.loadReceiptBase64())
|
||||
case .appleDevelopment:
|
||||
guard let receiptBase64 = try? await self.receiptProvider.loadReceiptBase64() else {
|
||||
return nil
|
||||
}
|
||||
return PushRelayReceiptPayload(base64: receiptBase64)
|
||||
case .internalSimulator:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func createSimulatorProofIfNeeded(
|
||||
proofPolicy: PushProofPolicy,
|
||||
signedPayloadData: Data)
|
||||
throws -> PushRelaySimulatorProofPayload? {
|
||||
guard proofPolicy == .internalSimulator else { return nil }
|
||||
return try self.simulatorProofProvider.createProof(signedPayload: signedPayloadData)
|
||||
}
|
||||
|
||||
private func fetchChallenge() async throws -> PushRelayChallengeResponse {
|
||||
let endpoint = self.baseURL.appending(path: "v1/push/challenge")
|
||||
var request = URLRequest(url: endpoint)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
private struct StoredPushRelayRegistrationState: Codable {
|
||||
@@ -10,6 +11,9 @@ private struct StoredPushRelayRegistrationState: Codable {
|
||||
var lastAPNsTokenHashHex: String
|
||||
var installationId: String
|
||||
var lastTransport: String
|
||||
var apnsEnvironment: String?
|
||||
var relayProfile: String?
|
||||
var proofPolicy: String?
|
||||
}
|
||||
|
||||
enum PushRelayRegistrationStore {
|
||||
@@ -18,6 +22,13 @@ enum PushRelayRegistrationStore {
|
||||
private static let appAttestKeyIDAccount = "app-attest-key-id"
|
||||
private static let appAttestedKeyIDAccount = "app-attested-key-id"
|
||||
|
||||
struct AppAttestScope {
|
||||
var relayOrigin: String
|
||||
var apnsEnvironment: String
|
||||
var relayProfile: String
|
||||
var proofPolicy: String
|
||||
}
|
||||
|
||||
struct RegistrationState: Codable {
|
||||
var relayHandle: String
|
||||
var sendGrant: String
|
||||
@@ -28,6 +39,9 @@ enum PushRelayRegistrationStore {
|
||||
var lastAPNsTokenHashHex: String
|
||||
var installationId: String
|
||||
var lastTransport: String
|
||||
var apnsEnvironment: String
|
||||
var relayProfile: String
|
||||
var proofPolicy: String
|
||||
}
|
||||
|
||||
static func loadRegistrationState() -> RegistrationState? {
|
||||
@@ -48,7 +62,10 @@ enum PushRelayRegistrationStore {
|
||||
tokenDebugSuffix: decoded.tokenDebugSuffix,
|
||||
lastAPNsTokenHashHex: decoded.lastAPNsTokenHashHex,
|
||||
installationId: decoded.installationId,
|
||||
lastTransport: decoded.lastTransport)
|
||||
lastTransport: decoded.lastTransport,
|
||||
apnsEnvironment: decoded.apnsEnvironment ?? "production",
|
||||
relayProfile: decoded.relayProfile ?? "production",
|
||||
proofPolicy: decoded.proofPolicy ?? "appleStrict")
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
@@ -62,7 +79,10 @@ enum PushRelayRegistrationStore {
|
||||
tokenDebugSuffix: state.tokenDebugSuffix,
|
||||
lastAPNsTokenHashHex: state.lastAPNsTokenHashHex,
|
||||
installationId: state.installationId,
|
||||
lastTransport: state.lastTransport)
|
||||
lastTransport: state.lastTransport,
|
||||
apnsEnvironment: state.apnsEnvironment,
|
||||
relayProfile: state.relayProfile,
|
||||
proofPolicy: state.proofPolicy)
|
||||
guard let data = try? JSONEncoder().encode(stored),
|
||||
let raw = String(data: data, encoding: .utf8)
|
||||
else {
|
||||
@@ -71,37 +91,66 @@ enum PushRelayRegistrationStore {
|
||||
return KeychainStore.saveString(raw, service: self.service, account: self.registrationStateAccount)
|
||||
}
|
||||
|
||||
static func loadAppAttestKeyID() -> String? {
|
||||
let value = KeychainStore.loadString(service: self.service, account: self.appAttestKeyIDAccount)?
|
||||
static func loadAppAttestKeyID(scope: AppAttestScope) -> String? {
|
||||
let value = KeychainStore.loadString(
|
||||
service: self.service,
|
||||
account: self.scopedAccount(self.appAttestKeyIDAccount, scope: scope))?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if value?.isEmpty == false { return value }
|
||||
return nil
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func saveAppAttestKeyID(_ keyID: String) -> Bool {
|
||||
KeychainStore.saveString(keyID, service: self.service, account: self.appAttestKeyIDAccount)
|
||||
static func saveAppAttestKeyID(_ keyID: String, scope: AppAttestScope) -> Bool {
|
||||
KeychainStore.saveString(
|
||||
keyID,
|
||||
service: self.service,
|
||||
account: self.scopedAccount(self.appAttestKeyIDAccount, scope: scope))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func clearAppAttestKeyID() -> Bool {
|
||||
KeychainStore.delete(service: self.service, account: self.appAttestKeyIDAccount)
|
||||
static func clearAppAttestKeyID(scope: AppAttestScope) -> Bool {
|
||||
KeychainStore.delete(
|
||||
service: self.service,
|
||||
account: self.scopedAccount(self.appAttestKeyIDAccount, scope: scope))
|
||||
}
|
||||
|
||||
static func loadAttestedKeyID() -> String? {
|
||||
let value = KeychainStore.loadString(service: self.service, account: self.appAttestedKeyIDAccount)?
|
||||
static func loadAttestedKeyID(scope: AppAttestScope) -> String? {
|
||||
let value = KeychainStore.loadString(
|
||||
service: self.service,
|
||||
account: self.scopedAccount(self.appAttestedKeyIDAccount, scope: scope))?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if value?.isEmpty == false { return value }
|
||||
return nil
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func saveAttestedKeyID(_ keyID: String) -> Bool {
|
||||
KeychainStore.saveString(keyID, service: self.service, account: self.appAttestedKeyIDAccount)
|
||||
static func saveAttestedKeyID(_ keyID: String, scope: AppAttestScope) -> Bool {
|
||||
KeychainStore.saveString(
|
||||
keyID,
|
||||
service: self.service,
|
||||
account: self.scopedAccount(self.appAttestedKeyIDAccount, scope: scope))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func clearAttestedKeyID() -> Bool {
|
||||
KeychainStore.delete(service: self.service, account: self.appAttestedKeyIDAccount)
|
||||
static func clearAttestedKeyID(scope: AppAttestScope) -> Bool {
|
||||
KeychainStore.delete(
|
||||
service: self.service,
|
||||
account: self.scopedAccount(self.appAttestedKeyIDAccount, scope: scope))
|
||||
}
|
||||
|
||||
private static func scopedAccount(_ baseAccount: String, scope: AppAttestScope) -> String {
|
||||
let raw = [
|
||||
scope.relayOrigin,
|
||||
scope.apnsEnvironment,
|
||||
scope.relayProfile,
|
||||
scope.proofPolicy,
|
||||
].joined(separator: "\n")
|
||||
let digest = SHA256.hash(data: Data(raw.utf8))
|
||||
.map { String(format: "%02x", $0) }
|
||||
.joined()
|
||||
// A relay sees an App Attest key as attested only after receiving that
|
||||
// key's attestation object, so keep key state isolated per relay context.
|
||||
return "\(baseAccount)-\(digest)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ struct TalkPermissionPromptView: View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: self.iconSystemName)
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(self.requestIsPending ? Color.orange : Color.accentColor)
|
||||
.foregroundStyle(self.requestIsPending ? OpenClawBrand.warn : OpenClawBrand.accent)
|
||||
.frame(width: 28, height: 28)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
@@ -51,7 +51,7 @@ struct TalkPermissionPromptView: View {
|
||||
if let failureMessage = self.state.failureMessage {
|
||||
Label(failureMessage, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.red)
|
||||
.foregroundStyle(OpenClawBrand.danger)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ struct TalkPermissionPromptView: View {
|
||||
.overlay {
|
||||
if self.style == .card || self.style == .sheet {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.stroke(Color.accentColor.opacity(0.20), lineWidth: 1)
|
||||
.stroke(OpenClawBrand.accent.opacity(0.20), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
.task(id: self.pollTaskKey) {
|
||||
|
||||
@@ -495,6 +495,9 @@ def produce_services_for_target(target)
|
||||
if target.fetch("capabilities").include?("APP_GROUPS")
|
||||
services[:app_group] = "on"
|
||||
end
|
||||
if target.fetch("capabilities").include?("APP_ATTEST")
|
||||
services[:app_attest] = "on"
|
||||
end
|
||||
services
|
||||
end
|
||||
|
||||
@@ -605,6 +608,15 @@ def validate_match_profile_capabilities!(target)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
if capabilities.include?("APP_ATTEST")
|
||||
app_attest_environments = profile_plist_array_values(profile_path, "Entitlements:com.apple.developer.devicecheck.appattest-environment")
|
||||
unless app_attest_environments.include?("production")
|
||||
UI.user_error!(
|
||||
"Provisioning profile #{target.fetch("profileName")} for #{target.fetch("bundleId")} is missing production App Attest entitlement; actual environments: #{app_attest_environments.empty? ? "missing" : app_attest_environments.join(", ")}."
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def sync_app_store_signing!(readonly:)
|
||||
|
||||
@@ -65,7 +65,7 @@ pnpm ios:release:signing:check
|
||||
pnpm ios:release:signing:setup
|
||||
```
|
||||
|
||||
`signing:setup` uses Fastlane `produce` and `modify_services` to create Developer Portal bundle IDs and enable required services before running `match`. The main app and share extension also require the shared App Group from `apps/ios/Config/AppStoreSigning.json`; associate that group with both bundle IDs in the Apple Developer Portal before regenerating profiles. If Fastlane does not already have a valid Apple Developer Portal session, run `fastlane spaceauth` for a release-owner Apple ID and export the resulting `FASTLANE_SESSION`.
|
||||
`signing:setup` uses Fastlane `produce` and `modify_services` to create Developer Portal bundle IDs and enable required services before running `match`. The main app also requires App Attest, and the main app and share extension both require the shared App Group from `apps/ios/Config/AppStoreSigning.json`; associate that group with both bundle IDs in the Apple Developer Portal before regenerating profiles. If Fastlane does not already have a valid Apple Developer Portal session, run `fastlane spaceauth` for a release-owner Apple ID and export the resulting `FASTLANE_SESSION`.
|
||||
|
||||
Shared encrypted signing storage:
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ targets:
|
||||
settings:
|
||||
base:
|
||||
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
|
||||
CODE_SIGN_ENTITLEMENTS: Sources/OpenClaw.entitlements
|
||||
CODE_SIGN_ENTITLEMENTS: "$(OPENCLAW_CODE_SIGN_ENTITLEMENTS)"
|
||||
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
|
||||
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_APP_BUNDLE_ID)"
|
||||
@@ -120,17 +120,23 @@ targets:
|
||||
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
|
||||
configs:
|
||||
Debug:
|
||||
OPENCLAW_CODE_SIGN_ENTITLEMENTS: Sources/OpenClaw.entitlements
|
||||
OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT: development
|
||||
OPENCLAW_PUSH_TRANSPORT: direct
|
||||
OPENCLAW_PUSH_DISTRIBUTION: local
|
||||
OPENCLAW_PUSH_RELAY_BASE_URL: ""
|
||||
OPENCLAW_PUSH_APNS_ENVIRONMENT: sandbox
|
||||
OPENCLAW_PUSH_RELAY_PROFILE: deviceSandbox
|
||||
OPENCLAW_PUSH_PROOF_POLICY: appleDevelopment
|
||||
Release:
|
||||
OPENCLAW_CODE_SIGN_ENTITLEMENTS: Sources/OpenClaw.entitlements
|
||||
OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT: production
|
||||
OPENCLAW_PUSH_TRANSPORT: direct
|
||||
OPENCLAW_PUSH_DISTRIBUTION: local
|
||||
OPENCLAW_PUSH_RELAY_BASE_URL: ""
|
||||
OPENCLAW_PUSH_APNS_ENVIRONMENT: production
|
||||
OPENCLAW_PUSH_RELAY_PROFILE: production
|
||||
OPENCLAW_PUSH_PROOF_POLICY: appleStrict
|
||||
info:
|
||||
path: Sources/Info.plist
|
||||
properties:
|
||||
@@ -176,6 +182,8 @@ targets:
|
||||
OpenClawPushDistribution: "$(OPENCLAW_PUSH_DISTRIBUTION)"
|
||||
OpenClawPushRelayBaseURL: "$(OPENCLAW_PUSH_RELAY_BASE_URL)"
|
||||
OpenClawPushAPNsEnvironment: "$(OPENCLAW_PUSH_APNS_ENVIRONMENT)"
|
||||
OpenClawPushRelayProfile: "$(OPENCLAW_PUSH_RELAY_PROFILE)"
|
||||
OpenClawPushProofPolicy: "$(OPENCLAW_PUSH_PROOF_POLICY)"
|
||||
UISupportedInterfaceOrientations:
|
||||
- UIInterfaceOrientationPortrait
|
||||
- UIInterfaceOrientationPortraitUpsideDown
|
||||
|
||||
@@ -680,83 +680,6 @@ struct GeneralSettings: View {
|
||||
case .missingNode, .missingGateway, .incompatible, .error: .orange
|
||||
}
|
||||
}
|
||||
|
||||
private var healthCard: some View {
|
||||
let snapshot = self.healthStore.snapshot
|
||||
return VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(self.healthStore.state.tint)
|
||||
.frame(width: 10, height: 10)
|
||||
Text(self.healthStore.summaryLine)
|
||||
.font(.callout.weight(.semibold))
|
||||
}
|
||||
|
||||
if let snap = snapshot {
|
||||
let linkId = snap.channelOrder?.first(where: {
|
||||
if let summary = snap.channels[$0] { return summary.linked != nil }
|
||||
return false
|
||||
}) ?? snap.channels.keys.first(where: {
|
||||
if let summary = snap.channels[$0] { return summary.linked != nil }
|
||||
return false
|
||||
})
|
||||
let linkLabel =
|
||||
linkId.flatMap { snap.channelLabels?[$0] } ??
|
||||
linkId?.capitalized ??
|
||||
"Link channel"
|
||||
let linkAge = linkId.flatMap { snap.channels[$0]?.authAgeMs }
|
||||
Text("\(linkLabel) auth age: \(healthAgeString(linkAge))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Session store: \(snap.sessions.path) (\(snap.sessions.count) entries)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if let recent = snap.sessions.recent.first {
|
||||
let lastActivity = recent.updatedAt != nil
|
||||
? relativeAge(from: Date(timeIntervalSince1970: (recent.updatedAt ?? 0) / 1000))
|
||||
: "unknown"
|
||||
Text("Last activity: \(recent.key) \(lastActivity)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text("Last check: \(relativeAge(from: self.healthStore.lastSuccess))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if let error = self.healthStore.lastError {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
} else {
|
||||
Text("Health check pending…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
Task { await self.healthStore.refresh(onDemand: true) }
|
||||
} label: {
|
||||
if self.healthStore.isRefreshing {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Label("Run Health Check", systemImage: "arrow.clockwise")
|
||||
}
|
||||
}
|
||||
.disabled(self.healthStore.isRefreshing)
|
||||
|
||||
Divider().frame(height: 18)
|
||||
|
||||
Button {
|
||||
self.revealLogs()
|
||||
} label: {
|
||||
Label("Reveal Logs", systemImage: "doc.text.magnifyingglass")
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.gray.opacity(0.08))
|
||||
.cornerRadius(10)
|
||||
}
|
||||
}
|
||||
|
||||
private enum RemoteStatus: Equatable {
|
||||
@@ -839,11 +762,6 @@ extension GeneralSettings {
|
||||
}
|
||||
}
|
||||
|
||||
private func healthAgeString(_ ms: Double?) -> String {
|
||||
guard let ms else { return "unknown" }
|
||||
return msToAge(ms)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct GeneralSettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
|
||||
@@ -221,16 +221,6 @@ final class InstancesStore {
|
||||
}
|
||||
}
|
||||
|
||||
private func decodeAndApplyPresenceData(_ data: Data) {
|
||||
do {
|
||||
let decoded = try JSONDecoder().decode([PresenceEntry].self, from: data)
|
||||
self.applyPresence(decoded)
|
||||
} catch {
|
||||
self.logger.error("presence decode from event failed: \(error.localizedDescription, privacy: .public)")
|
||||
self.lastError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func handlePresenceEventPayload(_ payload: OpenClawProtocol.AnyCodable) {
|
||||
do {
|
||||
let wrapper = try GatewayPayloadDecoding.decode(payload, as: PresenceEventPayload.self)
|
||||
|
||||
@@ -16,7 +16,6 @@ struct OpenClawApp: App {
|
||||
private let gatewayManager = GatewayProcessManager.shared
|
||||
private let controlChannel = ControlChannel.shared
|
||||
private let activityStore = WorkActivityStore.shared
|
||||
private let connectivityCoordinator = GatewayConnectivityCoordinator.shared
|
||||
@State private var statusItem: NSStatusItem?
|
||||
@State private var isMenuPresented = false
|
||||
@State private var isPanelVisible = false
|
||||
@@ -34,6 +33,7 @@ struct OpenClawApp: App {
|
||||
|
||||
init() {
|
||||
OpenClawLogging.bootstrapIfNeeded()
|
||||
GatewayConnectivityCoordinator.shared.start()
|
||||
|
||||
Self.applyAttachOnlyOverrideIfNeeded()
|
||||
_state = State(initialValue: AppStateStore.shared)
|
||||
|
||||
@@ -1045,16 +1045,6 @@ extension MenuSessionsInjector {
|
||||
return item
|
||||
}
|
||||
|
||||
private func formatVersionLabel(_ version: String) -> String {
|
||||
let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return version }
|
||||
if trimmed.hasPrefix("v") { return trimmed }
|
||||
if let first = trimmed.unicodeScalars.first, CharacterSet.decimalDigits.contains(first) {
|
||||
return "v\(trimmed)"
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
@objc
|
||||
private func patchThinking(_ sender: NSMenuItem) {
|
||||
guard let dict = sender.representedObject as? [String: Any],
|
||||
|
||||
@@ -217,18 +217,6 @@ extension String? {
|
||||
}
|
||||
}
|
||||
|
||||
extension [String] {
|
||||
fileprivate func dedupedPreserveOrder() -> [String] {
|
||||
var seen = Set<String>()
|
||||
var result: [String] = []
|
||||
for item in self where !seen.contains(item) {
|
||||
seen.insert(item)
|
||||
result.append(item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
enum SessionLoadError: LocalizedError {
|
||||
case gatewayUnavailable(String)
|
||||
case decodeFailed(String)
|
||||
|
||||
@@ -23,7 +23,6 @@ struct VoiceWakeSettings: View {
|
||||
@State private var meterStartupTask: Task<Void, Never>?
|
||||
@State private var availableLocales: [Locale] = []
|
||||
@State private var triggerEntries: [TriggerEntry] = []
|
||||
private let fieldLabelWidth: CGFloat = 140
|
||||
private let controlWidth: CGFloat = 240
|
||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||
|
||||
|
||||
@@ -368,31 +368,6 @@ final class VoiceWakeTester {
|
||||
}
|
||||
}
|
||||
|
||||
private func holdUntilSilence(onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) {
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let detectedAt = Date()
|
||||
let hardStop = detectedAt.addingTimeInterval(6) // cap overall listen after trigger
|
||||
|
||||
while !self.isStopping {
|
||||
let now = Date()
|
||||
if now >= hardStop { break }
|
||||
if let last = self.lastHeard, now.timeIntervalSince(last) >= silenceWindow {
|
||||
break
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||
}
|
||||
if !self.isStopping {
|
||||
self.stop()
|
||||
await MainActor.run { AppStateStore.shared.stopVoiceEars() }
|
||||
if let detectedText {
|
||||
self.logger.info("voice wake hold finished; len=\(detectedText.count)")
|
||||
Task { @MainActor in onUpdate(.detected(detectedText)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleSilenceCheck(
|
||||
triggers: [String],
|
||||
onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void)
|
||||
|
||||
@@ -283,7 +283,7 @@ struct OpenClawChatComposer: View {
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 5)
|
||||
.background(Color.accentColor.opacity(0.08))
|
||||
.background(OpenClawChatTheme.accent.opacity(0.08))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
@@ -550,7 +550,7 @@ struct OpenClawChatComposer: View {
|
||||
.frame(width: self.sendButtonSize, height: self.sendButtonSize)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: self.sendButtonCornerRadius, style: .continuous)
|
||||
.fill(Color.red))
|
||||
.fill(OpenClawChatTheme.danger))
|
||||
.contentShape(RoundedRectangle(cornerRadius: self.sendButtonCornerRadius, style: .continuous))
|
||||
.accessibilityLabel("Stop response")
|
||||
.disabled(self.viewModel.isAborting)
|
||||
|
||||
@@ -57,7 +57,7 @@ private struct ChatMarkdownStyle: ViewModifier {
|
||||
}
|
||||
|
||||
private var inlineStyle: InlineStyle {
|
||||
let linkColor: Color = self.context == .user ? self.textColor : .accentColor
|
||||
let linkColor: Color = self.context == .user ? self.textColor : OpenClawChatTheme.accent
|
||||
let codeScale: CGFloat = self.variant == .compact ? 0.85 : 0.9
|
||||
return InlineStyle()
|
||||
.code(.monospaced, .fontScale(codeScale))
|
||||
|
||||
@@ -25,7 +25,7 @@ struct ChatAgentAvatar: View {
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
(self.tint ?? Color.accentColor).opacity(0.95),
|
||||
(self.tint ?? OpenClawChatTheme.accent).opacity(0.95),
|
||||
Color(red: 38 / 255.0, green: 40 / 255.0, blue: 43 / 255.0),
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
@@ -33,7 +33,7 @@ struct ChatAgentAvatar: View {
|
||||
.overlay(
|
||||
Circle()
|
||||
.strokeBorder(Color.white.opacity(0.18), lineWidth: 1))
|
||||
.shadow(color: (self.tint ?? Color.accentColor).opacity(0.18), radius: 8, y: 4)
|
||||
.shadow(color: (self.tint ?? OpenClawChatTheme.accent).opacity(0.18), radius: 8, y: 4)
|
||||
.accessibilityLabel(self.name.map { "\($0) avatar" } ?? "Agent avatar")
|
||||
}
|
||||
|
||||
|
||||
@@ -152,6 +152,18 @@ enum OpenClawChatTheme {
|
||||
#endif
|
||||
}
|
||||
|
||||
static var accent: Color {
|
||||
self.userBubble
|
||||
}
|
||||
|
||||
static var danger: Color {
|
||||
#if os(macOS)
|
||||
Color(nsColor: .systemRed)
|
||||
#else
|
||||
Color(uiColor: .systemRed)
|
||||
#endif
|
||||
}
|
||||
|
||||
static var assistantBubble: Color {
|
||||
#if os(macOS)
|
||||
Color(nsColor: self.assistantBubbleDynamicNSColor)
|
||||
|
||||
@@ -235,7 +235,7 @@ private struct OpenClawChatPreviewTransport: OpenClawChatTransport {
|
||||
showsSessionSwitcher: false,
|
||||
style: .onboarding,
|
||||
markdownVariant: .standard,
|
||||
userAccent: .blue)
|
||||
userAccent: OpenClawChatTheme.accent)
|
||||
}
|
||||
|
||||
private struct OpenClawChatPreview: View {
|
||||
@@ -250,7 +250,7 @@ private struct OpenClawChatPreview: View {
|
||||
showsSessionSwitcher: true,
|
||||
style: .standard,
|
||||
markdownVariant: .standard,
|
||||
userAccent: .blue,
|
||||
userAccent: OpenClawChatTheme.accent,
|
||||
showsAssistantTrace: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,7 +372,7 @@ public struct OpenClawChatView: View {
|
||||
systemImage: "bubble.left.and.bubble.right.fill",
|
||||
title: self.emptyStateTitle,
|
||||
message: self.emptyStateMessage,
|
||||
tint: .accentColor,
|
||||
tint: OpenClawChatTheme.accent,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
05ce13ad6d2ef72af943a61a023e26f58d01e37a04f76e279a933df9b6aed05b plugin-sdk-api-baseline.json
|
||||
628a6ac85acd5ed71236b07d5760e211b9c0698ea529d5b3101c20579926b0ea plugin-sdk-api-baseline.jsonl
|
||||
8d38dc64627e6bfcebc25215d499a30841953799133dea16ffce902cf301273f plugin-sdk-api-baseline.json
|
||||
d7927d51588fd006d743fc56cc22d779141b487f82655d88054d2be12f3093ff plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -83,7 +83,7 @@ Scope logic lives in `scripts/ci-changed-scope.mjs` and is covered by unit tests
|
||||
- **CI workflow edits** validate the Node CI graph plus workflow linting, but do not force Windows, Android, or macOS native builds by themselves; those platform lanes stay scoped to platform source changes.
|
||||
- **Workflow Sanity** runs `actionlint`, `zizmor` over all workflow YAML files, the composite-action interpolation guard, and the conflict-marker guard. The PR-scoped `security-fast` job also runs `zizmor` over changed workflow files so workflow security findings fail early in the main CI graph.
|
||||
- **Docs on `main` pushes** are checked by the standalone `Docs` workflow with the same ClawHub docs mirror used by CI, so mixed code+docs pushes do not also queue the CI `check-docs` shard. Pull requests and manual CI still run `check-docs` from CI when docs changed.
|
||||
- **TUI PTY** is a focused workflow for TUI changes. It runs `node scripts/run-vitest.mjs run --config test/vitest/vitest.tui-pty.config.ts` on Linux Node 24 for `src/tui/**`, the watch harness, package script, lockfile, and workflow edits. The required lane uses a deterministic `TuiBackend` fixture; the slower `tui --local` smoke is opt-in with `OPENCLAW_TUI_PTY_INCLUDE_LOCAL=1` and mocks only the external model endpoint.
|
||||
- **TUI PTY** runs in the `checks-node-core-runtime-tui-pty` Linux Node shard for TUI changes. The shard runs `test/vitest/vitest.tui-pty.config.ts` with `OPENCLAW_TUI_PTY_INCLUDE_LOCAL=1`, so it covers both the deterministic `TuiBackend` fixture lane and the slower `tui --local` smoke that mocks only the external model endpoint.
|
||||
- **CI routing-only edits, selected cheap core-test fixture edits, and narrow plugin contract helper/test-routing edits** use a fast Node-only manifest path: `preflight`, security, and a single `checks-fast-core` task. That path skips build artifacts, Node 22 compatibility, channel contracts, full core shards, bundled-plugin shards, and additional guard matrices when the change is limited to the routing or helper surfaces the fast task exercises directly.
|
||||
- **Windows Node checks** are scoped to Windows-specific process/path wrappers, npm/pnpm/UI runner helpers, package manager config, and the CI workflow surfaces that execute that lane; unrelated source, plugin, install-smoke, and test-only changes stay on the Linux Node lanes.
|
||||
|
||||
@@ -155,7 +155,7 @@ pnpm check:timed # same gate with per-stage timings
|
||||
pnpm build:strict-smoke
|
||||
pnpm check:architecture
|
||||
pnpm test:gateway:watch-regression
|
||||
node scripts/run-vitest.mjs run --config test/vitest/vitest.tui-pty.config.ts
|
||||
OPENCLAW_TUI_PTY_INCLUDE_LOCAL=1 node scripts/run-vitest.mjs run --config test/vitest/vitest.tui-pty.config.ts
|
||||
pnpm test # vitest tests
|
||||
pnpm test:changed # cheap smart changed Vitest targets
|
||||
pnpm test:channels
|
||||
|
||||
@@ -181,12 +181,20 @@ Fetch usage-cost summaries from session logs.
|
||||
```bash
|
||||
openclaw gateway usage-cost
|
||||
openclaw gateway usage-cost --days 7
|
||||
openclaw gateway usage-cost --agent work --json
|
||||
openclaw gateway usage-cost --all-agents
|
||||
openclaw gateway usage-cost --json
|
||||
```
|
||||
|
||||
<ParamField path="--days <days>" type="number" default="30">
|
||||
Number of days to include.
|
||||
</ParamField>
|
||||
<ParamField path="--agent <id>" type="string">
|
||||
Scope the cost summary to one configured agent id.
|
||||
</ParamField>
|
||||
<ParamField path="--all-agents" type="boolean">
|
||||
Aggregate the cost summary across all configured agents. Cannot be combined with `--agent`.
|
||||
</ParamField>
|
||||
|
||||
### `gateway stability`
|
||||
|
||||
|
||||
@@ -167,7 +167,7 @@ surfaces, while Codex native hooks remain a separate lower-level Codex mechanism
|
||||
- Agent runtime: `agents.defaults.timeoutSeconds` default 172800s (48 hours); enforced in `runEmbeddedAgent` abort timer.
|
||||
- Cron runtime: isolated agent-turn `timeoutSeconds` is owned by cron. The scheduler starts that timer when execution begins, aborts the underlying run at the configured deadline, then runs bounded cleanup before recording the timeout so a stale child session cannot keep the lane stuck.
|
||||
- Session liveness diagnostics: with diagnostics enabled, `diagnostics.stuckSessionWarnMs` classifies long `processing` sessions that have no observed reply, tool, status, block, or ACP progress. Active embedded runs, model calls, and tool calls report as `session.long_running`; owned silent model calls also stay `session.long_running` until `diagnostics.stuckSessionAbortMs` so slow or non-streaming providers are not reported as stalled too early. Active work with no recent progress reports as `session.stalled`; owned model calls switch to `session.stalled` at or after the abort threshold, and ownerless stale model/tool activity is not hidden as long-running. `session.stuck` is reserved for recoverable stale session bookkeeping, including idle queued sessions with stale ownerless model/tool activity. Stale session bookkeeping releases the affected session lane immediately after recovery gates pass; stalled embedded runs are abort-drained only after `diagnostics.stuckSessionAbortMs` (default: at least 5 minutes and 3x the warning threshold) so queued work can resume without cutting off merely slow runs. Recovery emits structured requested/completed outcomes, and diagnostic state is marked idle only if the same processing generation is still current. Repeated `session.stuck` diagnostics back off while the session remains unchanged.
|
||||
- Model idle timeout: OpenClaw aborts a model request when no response chunks arrive before the idle window. `models.providers.<id>.timeoutSeconds` extends this idle watchdog for slow local/self-hosted providers, but it is still bounded by any lower `agents.defaults.timeoutSeconds` or run-specific timeout because those control the whole agent run. Otherwise OpenClaw uses `agents.defaults.timeoutSeconds` when configured, capped at 120s by default. Cron-triggered runs with no explicit model or agent timeout disable the idle watchdog and rely on the cron outer timeout.
|
||||
- Model idle timeout: OpenClaw aborts a model request when no response chunks arrive before the idle window. `models.providers.<id>.timeoutSeconds` extends this idle watchdog for slow local/self-hosted providers, but it is still bounded by any lower `agents.defaults.timeoutSeconds` or run-specific timeout because those control the whole agent run. Otherwise OpenClaw uses `agents.defaults.timeoutSeconds` when configured, capped at 120s by default. Cron-triggered cloud model runs with no explicit model or agent timeout use the same default idle watchdog; cron-triggered local or self-hosted model runs disable the implicit watchdog unless an explicit timeout is configured, so slow local providers should set `models.providers.<id>.timeoutSeconds`.
|
||||
- Provider HTTP request timeout: `models.providers.<id>.timeoutSeconds` applies to that provider's model HTTP fetches, including connect, headers, body, SDK request timeout, total guarded-fetch abort handling, and model stream idle watchdog. Use this for slow local/self-hosted providers such as Ollama before raising the whole agent runtime timeout, and keep the agent/runtime timeout at least as high when the model request needs to run longer.
|
||||
|
||||
## Where things can end early
|
||||
|
||||
@@ -73,9 +73,10 @@ pnpm openclaw qa run \
|
||||
--output-dir .artifacts/qa-e2e/smoke-ci-profile-dispatch
|
||||
```
|
||||
|
||||
Use `smoke-ci` for deterministic no-live-service proof and `release` for the
|
||||
Stable/LTS proof lane. When a command also needs an OpenClaw root profile, put
|
||||
the root profile before the QA command:
|
||||
Use `smoke-ci` for deterministic profile proof with mock model providers and
|
||||
Crabline fake provider servers. Use `release` for Stable/LTS proof against live
|
||||
channels. When a command also needs an OpenClaw root profile, put the root
|
||||
profile before the QA command:
|
||||
|
||||
```bash
|
||||
pnpm openclaw --profile work qa run --qa-profile smoke-ci
|
||||
@@ -197,7 +198,10 @@ witness video when `MANTIS_DISCORD_VIEWER_CHROME_PROFILE_DIR` or
|
||||
environment. That viewer profile is only for visual capture; the pass/fail
|
||||
decision still comes from the Discord REST oracle.
|
||||
|
||||
CI uses the same command surface in `.github/workflows/qa-live-transports-convex.yml`. Scheduled and default manual runs execute the fast Matrix profile with live frontier credentials, `--fast`, and `OPENCLAW_QA_MATRIX_NO_REPLY_WINDOW_MS=3000`. Manual `matrix_profile=all` fans out into the five profile shards so the exhaustive catalog can run in parallel while keeping one artifact directory per shard.
|
||||
CI uses the same command surface in `.github/workflows/qa-live-transports-convex.yml`.
|
||||
Scheduled and default manual runs execute the fast Matrix profile with live
|
||||
frontier credentials, `--fast`, and `OPENCLAW_QA_MATRIX_NO_REPLY_WINDOW_MS=3000`.
|
||||
Manual `matrix_profile=all` fans out into the five profile shards.
|
||||
|
||||
For transport-real Telegram, Discord, Slack, and WhatsApp smoke lanes:
|
||||
|
||||
@@ -857,7 +861,10 @@ provider names.
|
||||
|
||||
## Transport adapters
|
||||
|
||||
`qa-lab` owns a generic transport seam for YAML QA scenarios. `qa-channel` is the first adapter on that seam, but the design target is wider: future real or synthetic channels should plug into the same suite runner instead of adding a transport-specific QA runner.
|
||||
`qa-lab` owns a generic transport seam for YAML QA scenarios. `qa-channel` is
|
||||
the synthetic default. `crabline` starts local provider-shaped servers and runs
|
||||
OpenClaw's normal channel plugins against them. `live` is reserved for real
|
||||
provider credentials and external channels.
|
||||
|
||||
At the architecture level, the split is:
|
||||
|
||||
@@ -867,10 +874,10 @@ At the architecture level, the split is:
|
||||
|
||||
### Adding a channel
|
||||
|
||||
Adding a channel to the YAML QA system requires exactly two things:
|
||||
|
||||
1. A transport adapter for the channel.
|
||||
2. A scenario pack that exercises the channel contract.
|
||||
Adding a channel to the YAML QA system requires the channel implementation plus
|
||||
a scenario pack that exercises the channel contract. For smoke CI coverage, add
|
||||
the matching Crabline fake provider server and expose it through the `crabline`
|
||||
driver.
|
||||
|
||||
Do not add a new top-level QA command root when the shared `qa-lab` host can own the flow.
|
||||
|
||||
|
||||
@@ -15,8 +15,7 @@ When `agents.defaults.typingMode` is **unset**, OpenClaw keeps the legacy behavi
|
||||
|
||||
- **Direct chats**: typing starts immediately once the model loop begins.
|
||||
- **Group chats with a mention**: typing starts immediately.
|
||||
- **Group chats without a mention**: typing starts when the admitted run has
|
||||
user-visible activity, such as harness execution activity or message text.
|
||||
- **Group chats without a mention**: typing starts only when message text begins streaming.
|
||||
- **Heartbeat runs**: typing starts when the heartbeat run begins if the
|
||||
resolved heartbeat target is a typing-capable chat and typing is not disabled.
|
||||
|
||||
@@ -27,14 +26,13 @@ Set `agents.defaults.typingMode` to one of:
|
||||
- `never` - no typing indicator, ever.
|
||||
- `instant` - start typing **as soon as the model loop begins**, even if the run
|
||||
later returns only the silent reply token.
|
||||
- `thinking` - start typing on the **first reasoning delta** or on active
|
||||
harness execution after the turn is accepted.
|
||||
- `message` - start typing on the **first user-visible reply activity**, such as
|
||||
active harness execution or a non-silent text delta. Silent reply tokens such
|
||||
as `NO_REPLY` do not count as text activity.
|
||||
- `thinking` - start typing on the **first reasoning delta** (requires
|
||||
`reasoningLevel: "stream"` for the run).
|
||||
- `message` - start typing on the **first non-silent text delta** (ignores
|
||||
the `NO_REPLY` silent token).
|
||||
|
||||
Order of "how early it fires":
|
||||
`never` → `message`/`thinking` → `instant`
|
||||
`never` → `message` → `thinking` → `instant`
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -64,10 +62,11 @@ Override mode or cadence per session:
|
||||
|
||||
## Notes
|
||||
|
||||
- `message` mode does not start from silent reply tokens, but active execution
|
||||
can still show typing before any assistant text is available.
|
||||
- `thinking` still reacts to streamed reasoning (`reasoningLevel: "stream"`),
|
||||
and it can also start from active execution before reasoning deltas arrive.
|
||||
- `message` mode won't show typing for silent-only replies when the whole
|
||||
payload is the exact silent token (for example `NO_REPLY` / `no_reply`,
|
||||
matched case-insensitively).
|
||||
- `thinking` only fires if the run streams reasoning (`reasoningLevel: "stream"`).
|
||||
If the model doesn't emit reasoning deltas, typing won't start.
|
||||
- Heartbeat typing is a liveness signal for the resolved delivery target. It
|
||||
starts at heartbeat run start instead of following `message` or `thinking`
|
||||
stream timing. Set `typingMode: "never"` to disable it.
|
||||
|
||||
@@ -162,6 +162,7 @@ Rules of thumb:
|
||||
- **Reset** (`/new`, `/reset`) creates a new `sessionId` for that `sessionKey`.
|
||||
- **Daily reset** (default 4:00 AM local time on the gateway host) creates a new `sessionId` on the next message after the reset boundary.
|
||||
- **Idle expiry** (`session.reset.idleMinutes` or legacy `session.idleMinutes`) creates a new `sessionId` when a message arrives after the idle window. When daily + idle are both configured, whichever expires first wins.
|
||||
- **Control UI reconnect resume** can preserve the currently visible session for one reconnect send when the Gateway receives the matching `sessionId` from an operator UI client. Ordinary stale sends still create a new `sessionId`.
|
||||
- **System events** (heartbeat, cron wakeups, exec notifications, gateway bookkeeping) may mutate the session row but do not extend daily/idle reset freshness. Reset rollover discards queued system-event notices for the previous session before the fresh prompt is built.
|
||||
- **Parent fork policy** uses OpenClaw's active branch when creating a thread or subagent fork. If that branch is too large, OpenClaw starts the child with isolated context instead of failing or inheriting unusable history. The sizing policy is automatic; legacy `session.parentForkMaxTokens` config is removed by `openclaw doctor --fix`.
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import path from "node:path";
|
||||
import type { MemoryEmbeddingProbeResult } from "openclaw/plugin-sdk/memory-core-host-engine-storage";
|
||||
import {
|
||||
resolveMemoryDreamingConfig,
|
||||
resolveMemoryLightDreamingConfig,
|
||||
resolveMemoryRemDreamingConfig,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-status";
|
||||
import { buildAgentSessionKey } from "openclaw/plugin-sdk/routing";
|
||||
@@ -223,12 +224,23 @@ async function createHistoricalRemHarnessWorkspace(params: {
|
||||
|
||||
function formatDreamingSummary(cfg: OpenClawConfig): string {
|
||||
const pluginConfig = resolveMemoryPluginConfig(cfg);
|
||||
const dreaming = resolveShortTermPromotionDreamingConfig({ pluginConfig, cfg });
|
||||
if (!dreaming.enabled) {
|
||||
return "off";
|
||||
}
|
||||
const timezone = dreaming.timezone ? ` (${dreaming.timezone})` : "";
|
||||
return `${dreaming.cron}${timezone} · limit=${dreaming.limit} · minScore=${dreaming.minScore} · minRecallCount=${dreaming.minRecallCount} · minUniqueQueries=${dreaming.minUniqueQueries} · recencyHalfLifeDays=${dreaming.recencyHalfLifeDays} · maxAgeDays=${dreaming.maxAgeDays ?? "none"} · maxPromotedSnippetTokens=${dreaming.maxPromotedSnippetTokens}`;
|
||||
const light = resolveMemoryLightDreamingConfig({ pluginConfig, cfg });
|
||||
const deep = resolveShortTermPromotionDreamingConfig({ pluginConfig, cfg });
|
||||
const rem = resolveMemoryRemDreamingConfig({ pluginConfig, cfg });
|
||||
const timezone = deep.timezone ?? light.timezone ?? rem.timezone;
|
||||
const formatCron = (cron: string) => (timezone ? `${cron} (${timezone})` : cron);
|
||||
const lightSummary = light.enabled
|
||||
? `light=${formatCron(light.cron)} · limit=${light.limit} · lookbackDays=${light.lookbackDays}`
|
||||
: null;
|
||||
const remSummary = rem.enabled
|
||||
? `rem=${formatCron(rem.cron)} · limit=${rem.limit} · lookbackDays=${rem.lookbackDays} · minPatternStrength=${rem.minPatternStrength}`
|
||||
: null;
|
||||
const hasLighterPhase = light.enabled || rem.enabled;
|
||||
const deepLabel = hasLighterPhase ? "deep=" : "";
|
||||
const deepDetails = `${formatCron(deep.cron)} · limit=${deep.limit} · minScore=${deep.minScore} · minRecallCount=${deep.minRecallCount} · minUniqueQueries=${deep.minUniqueQueries} · recencyHalfLifeDays=${deep.recencyHalfLifeDays} · maxAgeDays=${deep.maxAgeDays ?? "none"} · maxPromotedSnippetTokens=${deep.maxPromotedSnippetTokens}`;
|
||||
const deepSummary = deep.enabled ? `${deepLabel}${deepDetails}` : null;
|
||||
const phases = [lightSummary, remSummary, deepSummary].filter(Boolean);
|
||||
return phases.length > 0 ? phases.join(" · ") : "off";
|
||||
}
|
||||
|
||||
function formatAuditCounts(audit: ShortTermAuditSummary): string {
|
||||
|
||||
@@ -746,6 +746,206 @@ describe("memory cli", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reports light-only dreaming as active during status", async () => {
|
||||
getRuntimeConfig.mockReturnValue({
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-core": {
|
||||
config: {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
frequency: "5 * * * *",
|
||||
timezone: "UTC",
|
||||
phases: {
|
||||
light: {
|
||||
enabled: true,
|
||||
limit: 4,
|
||||
lookbackDays: 2,
|
||||
},
|
||||
deep: {
|
||||
enabled: false,
|
||||
},
|
||||
rem: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const close = vi.fn(async () => {});
|
||||
mockManager({
|
||||
probeVectorAvailability: vi.fn(async () => true),
|
||||
status: () => makeMemoryStatus(),
|
||||
close,
|
||||
});
|
||||
|
||||
const log = spyRuntimeLogs(defaultRuntime);
|
||||
await runMemoryCli(["status"]);
|
||||
|
||||
expectLogged(log, "Dreaming: light=5 * * * * (UTC) · limit=4 · lookbackDays=2");
|
||||
expect(close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports rem-only dreaming as active during status", async () => {
|
||||
getRuntimeConfig.mockReturnValue({
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-core": {
|
||||
config: {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
frequency: "0 6 * * 0",
|
||||
timezone: "UTC",
|
||||
phases: {
|
||||
light: {
|
||||
enabled: false,
|
||||
},
|
||||
deep: {
|
||||
enabled: false,
|
||||
},
|
||||
rem: {
|
||||
enabled: true,
|
||||
limit: 3,
|
||||
lookbackDays: 9,
|
||||
minPatternStrength: 0.81,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const close = vi.fn(async () => {});
|
||||
mockManager({
|
||||
probeVectorAvailability: vi.fn(async () => true),
|
||||
status: () => makeMemoryStatus(),
|
||||
close,
|
||||
});
|
||||
|
||||
const log = spyRuntimeLogs(defaultRuntime);
|
||||
await runMemoryCli(["status"]);
|
||||
|
||||
expectLogged(
|
||||
log,
|
||||
"Dreaming: rem=0 6 * * 0 (UTC) · limit=3 · lookbackDays=9 · minPatternStrength=0.81",
|
||||
);
|
||||
expect(close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("labels deep dreaming when multiple phases are active during status", async () => {
|
||||
getRuntimeConfig.mockReturnValue({
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-core": {
|
||||
config: {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
frequency: "15 2 * * *",
|
||||
timezone: "UTC",
|
||||
phases: {
|
||||
light: {
|
||||
enabled: true,
|
||||
limit: 5,
|
||||
lookbackDays: 1,
|
||||
},
|
||||
deep: {
|
||||
enabled: true,
|
||||
limit: 7,
|
||||
minScore: 0.72,
|
||||
minRecallCount: 4,
|
||||
minUniqueQueries: 2,
|
||||
recencyHalfLifeDays: 10,
|
||||
maxAgeDays: 45,
|
||||
maxPromotedSnippetTokens: 512,
|
||||
},
|
||||
rem: {
|
||||
enabled: true,
|
||||
limit: 2,
|
||||
lookbackDays: 14,
|
||||
minPatternStrength: 0.67,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const close = vi.fn(async () => {});
|
||||
mockManager({
|
||||
probeVectorAvailability: vi.fn(async () => true),
|
||||
status: () => makeMemoryStatus(),
|
||||
close,
|
||||
});
|
||||
|
||||
const log = spyRuntimeLogs(defaultRuntime);
|
||||
await runMemoryCli(["status"]);
|
||||
|
||||
expectLogged(log, "Dreaming: light=15 2 * * * (UTC) · limit=5 · lookbackDays=1");
|
||||
expectLogged(log, "rem=15 2 * * * (UTC) · limit=2 · lookbackDays=14 · minPatternStrength=0.67");
|
||||
expectLogged(log, "deep=15 2 * * * (UTC) · limit=7 · minScore=0.72");
|
||||
expectLogged(log, "minRecallCount=4");
|
||||
expectLogged(log, "maxPromotedSnippetTokens=512");
|
||||
expect(close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves deep dreaming diagnostics during status", async () => {
|
||||
getRuntimeConfig.mockReturnValue({
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-core": {
|
||||
config: {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
frequency: "0 4 * * *",
|
||||
timezone: "UTC",
|
||||
phases: {
|
||||
light: {
|
||||
enabled: false,
|
||||
},
|
||||
deep: {
|
||||
enabled: true,
|
||||
limit: 6,
|
||||
minScore: 0.88,
|
||||
minRecallCount: 5,
|
||||
minUniqueQueries: 3,
|
||||
recencyHalfLifeDays: 12,
|
||||
maxAgeDays: 30,
|
||||
maxPromotedSnippetTokens: 640,
|
||||
},
|
||||
rem: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const close = vi.fn(async () => {});
|
||||
mockManager({
|
||||
probeVectorAvailability: vi.fn(async () => true),
|
||||
status: () => makeMemoryStatus(),
|
||||
close,
|
||||
});
|
||||
|
||||
const log = spyRuntimeLogs(defaultRuntime);
|
||||
await runMemoryCli(["status"]);
|
||||
|
||||
expectLogged(log, "Dreaming: 0 4 * * * (UTC) · limit=6 · minScore=0.88");
|
||||
expectLogged(log, "minRecallCount=5");
|
||||
expectLogged(log, "minUniqueQueries=3");
|
||||
expectLogged(log, "recencyHalfLifeDays=12");
|
||||
expectLogged(log, "maxAgeDays=30");
|
||||
expectLogged(log, "maxPromotedSnippetTokens=640");
|
||||
expect(close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("repairs invalid recall metadata and stale locks with status --fix", async () => {
|
||||
await withTempWorkspace(async (workspaceDir) => {
|
||||
await shortTermTesting.writeRawRecallStore(workspaceDir, {
|
||||
|
||||
195
extensions/memory-wiki/src/ingest-human-notes.test.ts
Normal file
195
extensions/memory-wiki/src/ingest-human-notes.test.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ingestMemoryWikiSource } from "./ingest.js";
|
||||
import { createMemoryWikiTestHarness } from "./test-helpers.js";
|
||||
|
||||
const { createTempDir, createVault } = createMemoryWikiTestHarness();
|
||||
|
||||
describe("ingestMemoryWikiSource human notes", () => {
|
||||
it("preserves user notes when the same source is re-ingested", async () => {
|
||||
const rootDir = await createTempDir("memory-wiki-reingest-");
|
||||
const inputPath = path.join(rootDir, "roadmap.txt");
|
||||
const { config } = await createVault({ rootDir: path.join(rootDir, "vault") });
|
||||
|
||||
await fs.writeFile(inputPath, "v1 content\n", "utf8");
|
||||
await ingestMemoryWikiSource({
|
||||
config,
|
||||
inputPath,
|
||||
nowMs: Date.UTC(2026, 3, 5, 12, 0, 0),
|
||||
});
|
||||
|
||||
const pagePath = path.join(config.vault.path, "sources", "roadmap.md");
|
||||
const userNote = "KEY INSIGHT: covers $1 of the Q2 roadmap";
|
||||
const edited = (await fs.readFile(pagePath, "utf8")).replace(
|
||||
"<!-- openclaw:human:start -->\n<!-- openclaw:human:end -->",
|
||||
`<!-- openclaw:human:start -->\n${userNote}\n<!-- openclaw:human:end -->`,
|
||||
);
|
||||
await fs.writeFile(pagePath, edited, "utf8");
|
||||
|
||||
await fs.writeFile(inputPath, "v2 content updated\n", "utf8");
|
||||
await ingestMemoryWikiSource({
|
||||
config,
|
||||
inputPath,
|
||||
nowMs: Date.UTC(2026, 3, 6, 12, 0, 0),
|
||||
});
|
||||
|
||||
const after = await fs.readFile(pagePath, "utf8");
|
||||
expect(after).toContain("v2 content updated");
|
||||
expect(after).toContain(userNote);
|
||||
});
|
||||
|
||||
it("preserves notes without corrupting source content that contains human markers", async () => {
|
||||
const rootDir = await createTempDir("memory-wiki-markers-");
|
||||
const inputPath = path.join(rootDir, "notes.txt");
|
||||
const { config } = await createVault({ rootDir: path.join(rootDir, "vault") });
|
||||
|
||||
await fs.writeFile(inputPath, "first body\n", "utf8");
|
||||
await ingestMemoryWikiSource({
|
||||
config,
|
||||
inputPath,
|
||||
nowMs: Date.UTC(2026, 3, 5, 12, 0, 0),
|
||||
});
|
||||
|
||||
const pagePath = path.join(config.vault.path, "sources", "notes.md");
|
||||
const userNote = "MY PRIVATE NOTE";
|
||||
const edited = (await fs.readFile(pagePath, "utf8")).replace(
|
||||
"<!-- openclaw:human:start -->\n<!-- openclaw:human:end -->",
|
||||
`<!-- openclaw:human:start -->\n${userNote}\n<!-- openclaw:human:end -->`,
|
||||
);
|
||||
await fs.writeFile(pagePath, edited, "utf8");
|
||||
|
||||
const sourceWithMarkers = [
|
||||
"second body",
|
||||
"<!-- openclaw:human:start -->",
|
||||
"INJECTED FROM SOURCE",
|
||||
"<!-- openclaw:human:end -->",
|
||||
"",
|
||||
].join("\n");
|
||||
await fs.writeFile(inputPath, sourceWithMarkers, "utf8");
|
||||
await ingestMemoryWikiSource({
|
||||
config,
|
||||
inputPath,
|
||||
nowMs: Date.UTC(2026, 3, 6, 12, 0, 0),
|
||||
});
|
||||
|
||||
const after = await fs.readFile(pagePath, "utf8");
|
||||
const notesBlock = after.slice(after.indexOf("## Notes"));
|
||||
expect(after).toContain("INJECTED FROM SOURCE");
|
||||
expect(notesBlock).toContain(userNote);
|
||||
expect(notesBlock).not.toContain("INJECTED FROM SOURCE");
|
||||
});
|
||||
|
||||
it("preserves CRLF notes without copying marker comments from existing source content", async () => {
|
||||
const rootDir = await createTempDir("memory-wiki-crlf-markers-");
|
||||
const inputPath = path.join(rootDir, "windows-notes.txt");
|
||||
const { config } = await createVault({ rootDir: path.join(rootDir, "vault") });
|
||||
|
||||
const sourceWithMarkers = [
|
||||
"first body",
|
||||
"<!-- openclaw:human:start -->",
|
||||
"OLD SOURCE MARKER PAYLOAD",
|
||||
"<!-- openclaw:human:end -->",
|
||||
"",
|
||||
].join("\n");
|
||||
await fs.writeFile(inputPath, sourceWithMarkers, "utf8");
|
||||
await ingestMemoryWikiSource({
|
||||
config,
|
||||
inputPath,
|
||||
nowMs: Date.UTC(2026, 3, 5, 12, 0, 0),
|
||||
});
|
||||
|
||||
const pagePath = path.join(config.vault.path, "sources", "windows-notes.md");
|
||||
const userNote = "CRLF USER NOTE";
|
||||
const edited = (await fs.readFile(pagePath, "utf8")).replace(
|
||||
"<!-- openclaw:human:start -->\n<!-- openclaw:human:end -->",
|
||||
`<!-- openclaw:human:start -->\n${userNote}\n<!-- openclaw:human:end -->`,
|
||||
);
|
||||
await fs.writeFile(pagePath, edited.replace(/\n/g, "\r\n"), "utf8");
|
||||
|
||||
await fs.writeFile(inputPath, "second body without marker comments\n", "utf8");
|
||||
await ingestMemoryWikiSource({
|
||||
config,
|
||||
inputPath,
|
||||
nowMs: Date.UTC(2026, 3, 6, 12, 0, 0),
|
||||
});
|
||||
|
||||
const after = await fs.readFile(pagePath, "utf8");
|
||||
const notesBlock = after.slice(after.indexOf("## Notes"));
|
||||
expect(after).toContain("second body without marker comments");
|
||||
expect(notesBlock).toContain(userNote);
|
||||
expect(notesBlock).not.toContain("OLD SOURCE MARKER PAYLOAD");
|
||||
});
|
||||
|
||||
it("preserves the whole note when the note text itself contains a marker comment", async () => {
|
||||
const rootDir = await createTempDir("memory-wiki-innermarker-");
|
||||
const inputPath = path.join(rootDir, "diary.txt");
|
||||
const { config } = await createVault({ rootDir: path.join(rootDir, "vault") });
|
||||
|
||||
await fs.writeFile(inputPath, "first body\n", "utf8");
|
||||
await ingestMemoryWikiSource({
|
||||
config,
|
||||
inputPath,
|
||||
nowMs: Date.UTC(2026, 3, 5, 12, 0, 0),
|
||||
});
|
||||
|
||||
const pagePath = path.join(config.vault.path, "sources", "diary.md");
|
||||
const noteWithMarker = [
|
||||
"EARLY NOTE before any quoted marker",
|
||||
"<!-- openclaw:human:start -->",
|
||||
"LATE NOTE after a pasted marker",
|
||||
].join("\n");
|
||||
const edited = (await fs.readFile(pagePath, "utf8")).replace(
|
||||
"<!-- openclaw:human:start -->\n<!-- openclaw:human:end -->",
|
||||
`<!-- openclaw:human:start -->\n${noteWithMarker}\n<!-- openclaw:human:end -->`,
|
||||
);
|
||||
await fs.writeFile(pagePath, edited, "utf8");
|
||||
|
||||
await fs.writeFile(inputPath, "second body\n", "utf8");
|
||||
await ingestMemoryWikiSource({
|
||||
config,
|
||||
inputPath,
|
||||
nowMs: Date.UTC(2026, 3, 6, 12, 0, 0),
|
||||
});
|
||||
|
||||
const after = await fs.readFile(pagePath, "utf8");
|
||||
expect(after).toContain("second body");
|
||||
expect(after).toContain("EARLY NOTE before any quoted marker");
|
||||
expect(after).toContain("LATE NOTE after a pasted marker");
|
||||
});
|
||||
|
||||
it("preserves the note when the note text contains a Markdown heading", async () => {
|
||||
const rootDir = await createTempDir("memory-wiki-heading-");
|
||||
const inputPath = path.join(rootDir, "log.txt");
|
||||
const { config } = await createVault({ rootDir: path.join(rootDir, "vault") });
|
||||
|
||||
await fs.writeFile(inputPath, "first body\n", "utf8");
|
||||
await ingestMemoryWikiSource({
|
||||
config,
|
||||
inputPath,
|
||||
nowMs: Date.UTC(2026, 3, 5, 12, 0, 0),
|
||||
});
|
||||
|
||||
const pagePath = path.join(config.vault.path, "sources", "log.md");
|
||||
const noteWithHeading = ["NOTE TOP", "## Notes", "NOTE BOTTOM under a pasted heading"].join(
|
||||
"\n",
|
||||
);
|
||||
const edited = (await fs.readFile(pagePath, "utf8")).replace(
|
||||
"<!-- openclaw:human:start -->\n<!-- openclaw:human:end -->",
|
||||
`<!-- openclaw:human:start -->\n${noteWithHeading}\n<!-- openclaw:human:end -->`,
|
||||
);
|
||||
await fs.writeFile(pagePath, edited, "utf8");
|
||||
|
||||
await fs.writeFile(inputPath, "second body\n", "utf8");
|
||||
await ingestMemoryWikiSource({
|
||||
config,
|
||||
inputPath,
|
||||
nowMs: Date.UTC(2026, 3, 6, 12, 0, 0),
|
||||
});
|
||||
|
||||
const after = await fs.readFile(pagePath, "utf8");
|
||||
expect(after).toContain("second body");
|
||||
expect(after).toContain("NOTE TOP");
|
||||
expect(after).toContain("NOTE BOTTOM under a pasted heading");
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,12 @@ import { pathExists } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { compileMemoryWikiVault } from "./compile.js";
|
||||
import type { ResolvedMemoryWikiConfig } from "./config.js";
|
||||
import { appendMemoryWikiLog } from "./log.js";
|
||||
import { renderMarkdownFence, renderWikiMarkdown, slugifyWikiSegment } from "./markdown.js";
|
||||
import {
|
||||
preserveHumanNotesBlock,
|
||||
renderMarkdownFence,
|
||||
renderWikiMarkdown,
|
||||
slugifyWikiSegment,
|
||||
} from "./markdown.js";
|
||||
import { resolveMemoryWikiTimestamp } from "./time.js";
|
||||
import { initializeMemoryWikiVault } from "./vault.js";
|
||||
|
||||
@@ -82,7 +87,12 @@ export async function ingestMemoryWikiSource(params: {
|
||||
].join("\n"),
|
||||
});
|
||||
|
||||
await fs.writeFile(pagePath, markdown, "utf8");
|
||||
const existing = created ? "" : await fs.readFile(pagePath, "utf8").catch(() => "");
|
||||
await fs.writeFile(
|
||||
pagePath,
|
||||
existing ? preserveHumanNotesBlock(markdown, existing) : markdown,
|
||||
"utf8",
|
||||
);
|
||||
await appendMemoryWikiLog(params.config.vault.path, {
|
||||
type: "ingest",
|
||||
timestamp,
|
||||
|
||||
@@ -446,6 +446,52 @@ function hasHumanNotesBlock(markdown: string): boolean {
|
||||
return markdown.includes(HUMAN_START_MARKER) && markdown.includes(HUMAN_END_MARKER);
|
||||
}
|
||||
|
||||
const SOURCE_CONTENT_HEADING = /(?:^|\r?\n)## Content\r?\n/u;
|
||||
|
||||
function afterSourceContentFence(page: string): number {
|
||||
const heading = SOURCE_CONTENT_HEADING.exec(page);
|
||||
if (!heading) {
|
||||
return 0;
|
||||
}
|
||||
const fenceLineStart = heading.index + heading[0].length;
|
||||
const fence = /^`+/.exec(page.slice(fenceLineStart))?.[0];
|
||||
if (!fence) {
|
||||
return fenceLineStart;
|
||||
}
|
||||
const closingFence = new RegExp(`\\r?\\n${fence}(?=\\r?\\n|$)`, "u");
|
||||
const close = closingFence.exec(page.slice(fenceLineStart + fence.length));
|
||||
if (!close) {
|
||||
return fenceLineStart;
|
||||
}
|
||||
return fenceLineStart + fence.length + close.index + close[0].length;
|
||||
}
|
||||
|
||||
function findNotesHumanBlock(page: string): { start: number; end: number } | null {
|
||||
const searchFrom = afterSourceContentFence(page);
|
||||
const start = page.indexOf(HUMAN_START_MARKER, searchFrom);
|
||||
if (start === -1) {
|
||||
return null;
|
||||
}
|
||||
const endMarker = page.lastIndexOf(HUMAN_END_MARKER);
|
||||
if (endMarker < start) {
|
||||
return null;
|
||||
}
|
||||
return { start, end: endMarker + HUMAN_END_MARKER.length };
|
||||
}
|
||||
|
||||
export function preserveHumanNotesBlock(rendered: string, existing: string): string {
|
||||
const existingBlock = findNotesHumanBlock(existing);
|
||||
const renderedBlock = findNotesHumanBlock(rendered);
|
||||
if (!existingBlock || !renderedBlock) {
|
||||
return rendered;
|
||||
}
|
||||
return (
|
||||
rendered.slice(0, renderedBlock.start) +
|
||||
existing.slice(existingBlock.start, existingBlock.end) +
|
||||
rendered.slice(renderedBlock.end)
|
||||
);
|
||||
}
|
||||
|
||||
function detectGeneratedSourceBody(markdown: string): GeneratedSourceBody | undefined {
|
||||
const lines = normalizeMarkdownLines(markdown);
|
||||
const normalized = lines.join("\n");
|
||||
|
||||
@@ -3,8 +3,33 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderMarkdownFence, renderWikiMarkdown } from "./markdown.js";
|
||||
import { writeImportedSourcePage } from "./source-page-shared.js";
|
||||
|
||||
function buildSourcePage(raw: string, updatedAt: string): string {
|
||||
return renderWikiMarkdown({
|
||||
frontmatter: {
|
||||
pageType: "source",
|
||||
id: "source.imported",
|
||||
title: "imported",
|
||||
sourceType: "memory-unsafe-local",
|
||||
status: "active",
|
||||
updatedAt,
|
||||
},
|
||||
body: [
|
||||
"# imported",
|
||||
"",
|
||||
"## Content",
|
||||
renderMarkdownFence(raw, "text"),
|
||||
"",
|
||||
"## Notes",
|
||||
"<!-- openclaw:human:start -->",
|
||||
"<!-- openclaw:human:end -->",
|
||||
"",
|
||||
].join("\n"),
|
||||
});
|
||||
}
|
||||
|
||||
describe("writeImportedSourcePage", () => {
|
||||
let suiteRoot: string;
|
||||
|
||||
@@ -46,4 +71,113 @@ describe("writeImportedSourcePage", () => {
|
||||
expect(result).toEqual({ pagePath: "pages/source.md", changed: true, created: true });
|
||||
expect(state.entries["unsafe:source"]?.sourceUpdatedAtMs).toBe(8_700_000_000_000_000);
|
||||
});
|
||||
|
||||
it("preserves the human Notes block when an imported source page is updated", async () => {
|
||||
const sourcePath = path.join(suiteRoot, "imported.txt");
|
||||
const pagePath = "sources/imported.md";
|
||||
const state: Parameters<typeof writeImportedSourcePage>[0]["state"] = {
|
||||
entries: {},
|
||||
version: 1,
|
||||
};
|
||||
|
||||
await fs.writeFile(sourcePath, "first body", "utf8");
|
||||
await writeImportedSourcePage({
|
||||
vaultRoot: suiteRoot,
|
||||
syncKey: "bridge:imported",
|
||||
sourcePath,
|
||||
sourceUpdatedAtMs: Date.UTC(2026, 4, 1),
|
||||
sourceSize: 10,
|
||||
renderFingerprint: "fp-1",
|
||||
pagePath,
|
||||
group: "bridge",
|
||||
state,
|
||||
buildRendered: buildSourcePage,
|
||||
});
|
||||
|
||||
const absPage = path.join(suiteRoot, pagePath);
|
||||
const userNote = "IMPORTED PAGE NOTE";
|
||||
const edited = (await fs.readFile(absPage, "utf8")).replace(
|
||||
"<!-- openclaw:human:start -->\n<!-- openclaw:human:end -->",
|
||||
`<!-- openclaw:human:start -->\n${userNote}\n<!-- openclaw:human:end -->`,
|
||||
);
|
||||
await fs.writeFile(absPage, edited, "utf8");
|
||||
|
||||
await fs.writeFile(sourcePath, "second body changed", "utf8");
|
||||
const result = await writeImportedSourcePage({
|
||||
vaultRoot: suiteRoot,
|
||||
syncKey: "bridge:imported",
|
||||
sourcePath,
|
||||
sourceUpdatedAtMs: Date.UTC(2026, 4, 2),
|
||||
sourceSize: 19,
|
||||
renderFingerprint: "fp-2",
|
||||
pagePath,
|
||||
group: "bridge",
|
||||
state,
|
||||
buildRendered: buildSourcePage,
|
||||
});
|
||||
|
||||
const after = await fs.readFile(absPage, "utf8");
|
||||
expect(result.changed).toBe(true);
|
||||
expect(after).toContain("second body changed");
|
||||
expect(after).toContain(userNote);
|
||||
});
|
||||
|
||||
it("preserves CRLF human notes without copying marker comments from existing imported content", async () => {
|
||||
const sourcePath = path.join(suiteRoot, "imported-crlf.txt");
|
||||
const pagePath = "sources/imported-crlf.md";
|
||||
const state: Parameters<typeof writeImportedSourcePage>[0]["state"] = {
|
||||
entries: {},
|
||||
version: 1,
|
||||
};
|
||||
|
||||
const sourceWithMarkers = [
|
||||
"first imported body",
|
||||
"<!-- openclaw:human:start -->",
|
||||
"OLD IMPORTED SOURCE MARKER PAYLOAD",
|
||||
"<!-- openclaw:human:end -->",
|
||||
"",
|
||||
].join("\n");
|
||||
await fs.writeFile(sourcePath, sourceWithMarkers, "utf8");
|
||||
await writeImportedSourcePage({
|
||||
vaultRoot: suiteRoot,
|
||||
syncKey: "bridge:imported-crlf",
|
||||
sourcePath,
|
||||
sourceUpdatedAtMs: Date.UTC(2026, 4, 1),
|
||||
sourceSize: sourceWithMarkers.length,
|
||||
renderFingerprint: "fp-1",
|
||||
pagePath,
|
||||
group: "bridge",
|
||||
state,
|
||||
buildRendered: buildSourcePage,
|
||||
});
|
||||
|
||||
const absPage = path.join(suiteRoot, pagePath);
|
||||
const userNote = "CRLF IMPORTED PAGE NOTE";
|
||||
const edited = (await fs.readFile(absPage, "utf8")).replace(
|
||||
"<!-- openclaw:human:start -->\n<!-- openclaw:human:end -->",
|
||||
`<!-- openclaw:human:start -->\n${userNote}\n<!-- openclaw:human:end -->`,
|
||||
);
|
||||
await fs.writeFile(absPage, edited.replace(/\n/g, "\r\n"), "utf8");
|
||||
|
||||
await fs.writeFile(sourcePath, "second imported body without marker comments", "utf8");
|
||||
const result = await writeImportedSourcePage({
|
||||
vaultRoot: suiteRoot,
|
||||
syncKey: "bridge:imported-crlf",
|
||||
sourcePath,
|
||||
sourceUpdatedAtMs: Date.UTC(2026, 4, 2),
|
||||
sourceSize: 44,
|
||||
renderFingerprint: "fp-2",
|
||||
pagePath,
|
||||
group: "bridge",
|
||||
state,
|
||||
buildRendered: buildSourcePage,
|
||||
});
|
||||
|
||||
const after = await fs.readFile(absPage, "utf8");
|
||||
const notesBlock = after.slice(after.indexOf("## Notes"));
|
||||
expect(result.changed).toBe(true);
|
||||
expect(after).toContain("second imported body without marker comments");
|
||||
expect(notesBlock).toContain(userNote);
|
||||
expect(notesBlock).not.toContain("OLD IMPORTED SOURCE MARKER PAYLOAD");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { FsSafeError, root as fsRoot } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { preserveHumanNotesBlock } from "./markdown.js";
|
||||
import {
|
||||
setImportedSourceEntry,
|
||||
shouldSkipImportedSourceWrite,
|
||||
@@ -52,11 +53,12 @@ export async function writeImportedSourcePage(params: {
|
||||
const raw = await fs.readFile(params.sourcePath, "utf8");
|
||||
const rendered = params.buildRendered(raw, updatedAt);
|
||||
const existing = pageStat ? await vault.readText(params.pagePath).catch(() => "") : "";
|
||||
if (existing !== rendered) {
|
||||
const nextRendered = existing ? preserveHumanNotesBlock(rendered, existing) : rendered;
|
||||
if (existing !== nextRendered) {
|
||||
await writeGuardedVaultPage({
|
||||
vault,
|
||||
pagePath: params.pagePath,
|
||||
content: rendered,
|
||||
content: nextRendered,
|
||||
pageStat,
|
||||
pageLabel: "imported source page",
|
||||
});
|
||||
@@ -74,5 +76,5 @@ export async function writeImportedSourcePage(params: {
|
||||
renderFingerprint: params.renderFingerprint,
|
||||
},
|
||||
});
|
||||
return { pagePath: params.pagePath, changed: existing !== rendered, created };
|
||||
return { pagePath: params.pagePath, changed: existing !== nextRendered, created };
|
||||
}
|
||||
|
||||
@@ -408,7 +408,7 @@ describe("buildMinimaxSpeechProvider", () => {
|
||||
return JSON.parse(init.body) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
it("makes correct API call and decodes hex response", async () => {
|
||||
it("requests non-streaming hex audio and decodes the hex response", async () => {
|
||||
const hexAudio = Buffer.from("fake-audio-data").toString("hex");
|
||||
const mockFetch = vi.mocked(globalThis.fetch);
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
@@ -437,6 +437,8 @@ describe("buildMinimaxSpeechProvider", () => {
|
||||
const body = firstFetchBody();
|
||||
expect(body.model).toBe("speech-2.8-hd");
|
||||
expect(body.text).toBe("Hello world");
|
||||
expect(body.stream).toBe(false);
|
||||
expect(body.output_format).toBe("hex");
|
||||
expect((body.voice_setting as Record<string, unknown>).voice_id).toBe(
|
||||
"English_expressive_narrator",
|
||||
);
|
||||
|
||||
@@ -83,6 +83,8 @@ export async function minimaxTTS(params: {
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
text,
|
||||
stream: false,
|
||||
output_format: "hex",
|
||||
voice_setting: {
|
||||
voice_id: voiceId,
|
||||
speed,
|
||||
|
||||
716
extensions/opencode-go/stream-termination.test.ts
Normal file
716
extensions/opencode-go/stream-termination.test.ts
Normal file
@@ -0,0 +1,716 @@
|
||||
// Opencode Go stream termination wrapper tests cover provider-owned raw SSE
|
||||
// boundary behavior for stalled OpenAI-compatible streams.
|
||||
import type {
|
||||
AssistantMessageEvent,
|
||||
AssistantMessageEventStreamContract,
|
||||
} from "openclaw/plugin-sdk/llm";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createOpencodeGoStalledStreamWrapper } from "./stream-termination.js";
|
||||
|
||||
type AnyEvent = AssistantMessageEvent;
|
||||
type StreamLike = AssistantMessageEventStreamContract;
|
||||
|
||||
interface FakeStreamController {
|
||||
emit(event: AnyEvent): void;
|
||||
end(): void;
|
||||
}
|
||||
|
||||
function createFakeBaseStream(): {
|
||||
stream: StreamLike;
|
||||
controller: FakeStreamController;
|
||||
getReturnCalls: () => number;
|
||||
} {
|
||||
const queued: IteratorResult<AnyEvent>[] = [];
|
||||
const waiters: ((result: IteratorResult<AnyEvent>) => void)[] = [];
|
||||
let finished = false;
|
||||
let returnCalls = 0;
|
||||
|
||||
const iterator: AsyncIterator<AnyEvent> = {
|
||||
next(): Promise<IteratorResult<AnyEvent>> {
|
||||
if (queued.length > 0) {
|
||||
return Promise.resolve(queued.shift()!);
|
||||
}
|
||||
if (finished) {
|
||||
return Promise.resolve({ value: undefined, done: true });
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
waiters.push(resolve);
|
||||
});
|
||||
},
|
||||
return(): Promise<IteratorResult<AnyEvent>> {
|
||||
returnCalls += 1;
|
||||
finished = true;
|
||||
while (waiters.length > 0) {
|
||||
waiters.shift()!({ value: undefined, done: true });
|
||||
}
|
||||
return Promise.resolve({ value: undefined, done: true });
|
||||
},
|
||||
};
|
||||
|
||||
const stream: StreamLike = {
|
||||
[Symbol.asyncIterator]() {
|
||||
return iterator;
|
||||
},
|
||||
push() {
|
||||
// unused: the wrapper pushes its own events into a separate stream.
|
||||
},
|
||||
end() {
|
||||
// unused: the wrapper ends its own stream.
|
||||
},
|
||||
result() {
|
||||
return Promise.reject(new Error("fake base stream result not used"));
|
||||
},
|
||||
};
|
||||
|
||||
const controller: FakeStreamController = {
|
||||
emit(event: AnyEvent) {
|
||||
const waiter = waiters.shift();
|
||||
if (waiter) {
|
||||
waiter({ value: event, done: false });
|
||||
} else {
|
||||
queued.push({ value: event, done: false });
|
||||
}
|
||||
},
|
||||
end() {
|
||||
finished = true;
|
||||
while (waiters.length > 0) {
|
||||
waiters.shift()!({ value: undefined, done: true });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return { stream, controller, getReturnCalls: () => returnCalls };
|
||||
}
|
||||
|
||||
function disableAbortSignalAny(): PropertyDescriptor | undefined {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(AbortSignal, "any");
|
||||
Object.defineProperty(AbortSignal, "any", {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
function restoreAbortSignalAny(descriptor: PropertyDescriptor | undefined): void {
|
||||
if (descriptor) {
|
||||
Object.defineProperty(AbortSignal, "any", descriptor);
|
||||
} else {
|
||||
Reflect.deleteProperty(AbortSignal, "any");
|
||||
}
|
||||
}
|
||||
|
||||
describe("createOpencodeGoStalledStreamWrapper", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("aborts underlying stream when progress stalls after first delta (raw SSE boundary)", async () => {
|
||||
// Arrange: a fake base stream that emits a start + one text_delta, then stalls.
|
||||
const { stream: baseStream, controller } = createFakeBaseStream();
|
||||
void baseStream;
|
||||
let abortCalled = false;
|
||||
const capturedSignals: AbortSignal[] = [];
|
||||
|
||||
const underlying = vi.fn((_model, _context, options) => {
|
||||
if (options?.signal) {
|
||||
capturedSignals.push(options.signal);
|
||||
options.signal.addEventListener("abort", () => {
|
||||
abortCalled = true;
|
||||
});
|
||||
}
|
||||
return baseStream;
|
||||
});
|
||||
|
||||
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
|
||||
provider: "opencode-go",
|
||||
idleTimeoutMs: 5_000,
|
||||
});
|
||||
|
||||
const downstream = await Promise.resolve(
|
||||
wrapper({ provider: "opencode-go", id: "deepseek-v4-flash" } as any, {} as any, {} as any),
|
||||
);
|
||||
expect(downstream).toBeDefined();
|
||||
if (!downstream) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Drain wrapper events in the background.
|
||||
const received: AnyEvent[] = [];
|
||||
const consumer = (async () => {
|
||||
for await (const event of downstream) {
|
||||
received.push(event);
|
||||
}
|
||||
})();
|
||||
|
||||
// Emit a start + one text delta — that proves the provider side has produced tokens.
|
||||
const partial = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "hi" }],
|
||||
stopReason: undefined,
|
||||
};
|
||||
controller.emit({ type: "start", partial } as any);
|
||||
controller.emit({
|
||||
type: "text_delta",
|
||||
contentIndex: 0,
|
||||
delta: "hi",
|
||||
partial,
|
||||
} as any);
|
||||
|
||||
// Advance wall clock beyond idleTimeoutMs without any new progress.
|
||||
await vi.advanceTimersByTimeAsync(6_000);
|
||||
|
||||
// Assert: wrapper called abort on its injected AbortController (forwarded as options.signal).
|
||||
expect(capturedSignals).toHaveLength(1);
|
||||
expect(abortCalled).toBe(true);
|
||||
|
||||
// And it pushed a terminal error event to the downstream consumer.
|
||||
const terminal = received.find(
|
||||
(event) => event.type === "error" && (event as any).reason === "error",
|
||||
);
|
||||
expect(terminal).toBeDefined();
|
||||
expect((terminal as any)?.error).toMatchObject({
|
||||
stopReason: "error",
|
||||
errorMessage: "opencode-go stream timed out after provider-owned SSE boundary stalled",
|
||||
});
|
||||
|
||||
// Cleanup: end base stream so consumer promise resolves.
|
||||
controller.end();
|
||||
await consumer;
|
||||
});
|
||||
|
||||
it("uses a longer first-event timeout than the inter-event idle timeout", async () => {
|
||||
const { stream: baseStream } = createFakeBaseStream();
|
||||
let abortCalled = false;
|
||||
|
||||
const underlying = vi.fn((_model, _context, options) => {
|
||||
if (options?.signal) {
|
||||
options.signal.addEventListener("abort", () => {
|
||||
abortCalled = true;
|
||||
});
|
||||
}
|
||||
return baseStream;
|
||||
});
|
||||
|
||||
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
|
||||
provider: "opencode-go",
|
||||
idleTimeoutMs: 5_000,
|
||||
firstEventTimeoutMs: 10_000,
|
||||
});
|
||||
|
||||
const downstream = await Promise.resolve(
|
||||
wrapper({ provider: "opencode-go", id: "deepseek-v4-flash" } as any, {} as any, {} as any),
|
||||
);
|
||||
expect(downstream).toBeDefined();
|
||||
if (!downstream) {
|
||||
return;
|
||||
}
|
||||
|
||||
const consumer = (async () => {
|
||||
for await (const event of downstream) {
|
||||
void event;
|
||||
}
|
||||
})();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(6_000);
|
||||
expect(abortCalled).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(abortCalled).toBe(true);
|
||||
await consumer;
|
||||
});
|
||||
|
||||
it("keeps the first-event window after an openai-completions synthetic start", async () => {
|
||||
const { stream: baseStream, controller } = createFakeBaseStream();
|
||||
let abortCalled = false;
|
||||
|
||||
const underlying = vi.fn((_model, _context, options) => {
|
||||
if (options?.signal) {
|
||||
options.signal.addEventListener("abort", () => {
|
||||
abortCalled = true;
|
||||
});
|
||||
}
|
||||
return baseStream;
|
||||
});
|
||||
|
||||
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
|
||||
provider: "opencode-go",
|
||||
idleTimeoutMs: 5_000,
|
||||
firstEventTimeoutMs: 10_000,
|
||||
});
|
||||
|
||||
const downstream = await Promise.resolve(
|
||||
wrapper({ provider: "opencode-go", id: "deepseek-v4-flash" } as any, {} as any, {} as any),
|
||||
);
|
||||
expect(downstream).toBeDefined();
|
||||
if (!downstream) {
|
||||
return;
|
||||
}
|
||||
|
||||
const received: AnyEvent[] = [];
|
||||
const consumer = (async () => {
|
||||
for await (const event of downstream) {
|
||||
received.push(event);
|
||||
}
|
||||
})();
|
||||
|
||||
const partial = {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
stopReason: undefined,
|
||||
};
|
||||
controller.emit({ type: "start", partial } as any);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(6_000);
|
||||
expect(abortCalled).toBe(false);
|
||||
|
||||
controller.emit({
|
||||
type: "text_delta",
|
||||
contentIndex: 0,
|
||||
delta: "hello",
|
||||
partial: {
|
||||
...partial,
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
},
|
||||
} as any);
|
||||
controller.emit({
|
||||
type: "done",
|
||||
reason: "stop",
|
||||
message: {
|
||||
...partial,
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
stopReason: "stop",
|
||||
},
|
||||
} as any);
|
||||
await consumer;
|
||||
|
||||
expect(abortCalled).toBe(false);
|
||||
expect(received.some((event) => event.type === "text_delta")).toBe(true);
|
||||
expect(received.some((event) => event.type === "done")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the first-event window after synthetic block-start events until a provider delta", async () => {
|
||||
const { stream: baseStream, controller } = createFakeBaseStream();
|
||||
let abortCalled = false;
|
||||
|
||||
const underlying = vi.fn((_model, _context, options) => {
|
||||
if (options?.signal) {
|
||||
options.signal.addEventListener("abort", () => {
|
||||
abortCalled = true;
|
||||
});
|
||||
}
|
||||
return baseStream;
|
||||
});
|
||||
|
||||
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
|
||||
provider: "opencode-go",
|
||||
idleTimeoutMs: 5_000,
|
||||
firstEventTimeoutMs: 10_000,
|
||||
});
|
||||
|
||||
const downstream = await Promise.resolve(
|
||||
wrapper({ provider: "opencode-go", id: "deepseek-v4-flash" } as any, {} as any, {} as any),
|
||||
);
|
||||
expect(downstream).toBeDefined();
|
||||
if (!downstream) {
|
||||
return;
|
||||
}
|
||||
|
||||
const received: AnyEvent[] = [];
|
||||
const consumer = (async () => {
|
||||
for await (const event of downstream) {
|
||||
received.push(event);
|
||||
}
|
||||
})();
|
||||
|
||||
const partial = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "" }],
|
||||
stopReason: undefined,
|
||||
};
|
||||
controller.emit({ type: "start", partial } as any);
|
||||
controller.emit({ type: "text_start", contentIndex: 0, partial } as any);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(6_000);
|
||||
expect(abortCalled).toBe(false);
|
||||
|
||||
const message = {
|
||||
...partial,
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
stopReason: "stop",
|
||||
};
|
||||
controller.emit({
|
||||
type: "text_delta",
|
||||
contentIndex: 0,
|
||||
delta: "hello",
|
||||
partial: message,
|
||||
} as any);
|
||||
controller.emit({ type: "done", reason: "stop", message } as any);
|
||||
await consumer;
|
||||
|
||||
expect(abortCalled).toBe(false);
|
||||
expect(received.some((event) => event.type === "text_delta")).toBe(true);
|
||||
expect(received.some((event) => event.type === "done")).toBe(true);
|
||||
});
|
||||
|
||||
it("honors explicit opencode-go provider request timeout above the wrapper idle default", async () => {
|
||||
const { stream: baseStream, controller } = createFakeBaseStream();
|
||||
let abortCalled = false;
|
||||
|
||||
const underlying = vi.fn((_model, _context, options) => {
|
||||
if (options?.signal) {
|
||||
options.signal.addEventListener("abort", () => {
|
||||
abortCalled = true;
|
||||
});
|
||||
}
|
||||
return baseStream;
|
||||
});
|
||||
|
||||
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
|
||||
provider: "opencode-go",
|
||||
idleTimeoutMs: 5_000,
|
||||
firstEventTimeoutMs: 5_000,
|
||||
});
|
||||
|
||||
const downstream = await Promise.resolve(
|
||||
wrapper(
|
||||
{ provider: "opencode-go", id: "deepseek-v4-flash", requestTimeoutMs: 10_000 } as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
),
|
||||
);
|
||||
expect(downstream).toBeDefined();
|
||||
if (!downstream) {
|
||||
return;
|
||||
}
|
||||
|
||||
const consumer = (async () => {
|
||||
for await (const event of downstream) {
|
||||
void event;
|
||||
}
|
||||
})();
|
||||
|
||||
const partial = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "slow" }],
|
||||
stopReason: undefined,
|
||||
};
|
||||
controller.emit({ type: "start", partial } as any);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(6_000);
|
||||
expect(abortCalled).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(abortCalled).toBe(true);
|
||||
await consumer;
|
||||
});
|
||||
|
||||
it("honors explicit opencode-go provider request timeout below wrapper defaults", async () => {
|
||||
const { stream: baseStream } = createFakeBaseStream();
|
||||
let abortCalled = false;
|
||||
|
||||
const underlying = vi.fn((_model, _context, options) => {
|
||||
if (options?.signal) {
|
||||
options.signal.addEventListener("abort", () => {
|
||||
abortCalled = true;
|
||||
});
|
||||
}
|
||||
return baseStream;
|
||||
});
|
||||
|
||||
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
|
||||
provider: "opencode-go",
|
||||
idleTimeoutMs: 5_000,
|
||||
firstEventTimeoutMs: 10_000,
|
||||
});
|
||||
|
||||
const downstream = await Promise.resolve(
|
||||
wrapper(
|
||||
{ provider: "opencode-go", id: "deepseek-v4-flash", requestTimeoutMs: 2_000 } as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
),
|
||||
);
|
||||
expect(downstream).toBeDefined();
|
||||
if (!downstream) {
|
||||
return;
|
||||
}
|
||||
|
||||
const consumer = (async () => {
|
||||
for await (const event of downstream) {
|
||||
void event;
|
||||
}
|
||||
})();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_500);
|
||||
expect(abortCalled).toBe(true);
|
||||
await consumer;
|
||||
});
|
||||
|
||||
it("aborts and releases the underlying stream when no first event arrives", async () => {
|
||||
const { stream: baseStream, getReturnCalls } = createFakeBaseStream();
|
||||
let abortCalled = false;
|
||||
const capturedSignals: AbortSignal[] = [];
|
||||
|
||||
const underlying = vi.fn((_model, _context, options) => {
|
||||
if (options?.signal) {
|
||||
capturedSignals.push(options.signal);
|
||||
options.signal.addEventListener("abort", () => {
|
||||
abortCalled = true;
|
||||
});
|
||||
}
|
||||
return baseStream;
|
||||
});
|
||||
|
||||
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
|
||||
provider: "opencode-go",
|
||||
idleTimeoutMs: 5_000,
|
||||
});
|
||||
|
||||
const downstream = await Promise.resolve(
|
||||
wrapper({ provider: "opencode-go", id: "deepseek-v4-flash" } as any, {} as any, {} as any),
|
||||
);
|
||||
expect(downstream).toBeDefined();
|
||||
if (!downstream) {
|
||||
return;
|
||||
}
|
||||
|
||||
const received: AnyEvent[] = [];
|
||||
const consumer = (async () => {
|
||||
for await (const event of downstream) {
|
||||
received.push(event);
|
||||
}
|
||||
})();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(6_000);
|
||||
|
||||
expect(capturedSignals).toHaveLength(1);
|
||||
expect(abortCalled).toBe(true);
|
||||
expect(getReturnCalls()).toBe(1);
|
||||
expect(
|
||||
received.some((event) => event.type === "error" && (event as any).reason === "error"),
|
||||
).toBe(true);
|
||||
|
||||
await consumer;
|
||||
});
|
||||
|
||||
it("aborts stream creation when the upstream stream promise never resolves", async () => {
|
||||
let abortCalled = false;
|
||||
|
||||
const underlying = vi.fn((_model, _context, options) => {
|
||||
if (options?.signal) {
|
||||
options.signal.addEventListener("abort", () => {
|
||||
abortCalled = true;
|
||||
});
|
||||
}
|
||||
return new Promise<StreamLike>(() => {
|
||||
// keep pending
|
||||
});
|
||||
});
|
||||
|
||||
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
|
||||
provider: "opencode-go",
|
||||
idleTimeoutMs: 5_000,
|
||||
});
|
||||
|
||||
const downstream = await Promise.resolve(
|
||||
wrapper({ provider: "opencode-go", id: "deepseek-v4-flash" } as any, {} as any, {} as any),
|
||||
);
|
||||
expect(downstream).toBeDefined();
|
||||
if (!downstream) {
|
||||
return;
|
||||
}
|
||||
|
||||
const received: AnyEvent[] = [];
|
||||
const consumer = (async () => {
|
||||
for await (const event of downstream) {
|
||||
received.push(event);
|
||||
}
|
||||
})();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(6_000);
|
||||
|
||||
expect(abortCalled).toBe(true);
|
||||
expect(
|
||||
received.some((event) => event.type === "error" && (event as any).reason === "error"),
|
||||
).toBe(true);
|
||||
await consumer;
|
||||
});
|
||||
|
||||
it("aborts through the fallback combined signal when no first event arrives", async () => {
|
||||
const abortSignalAnyDescriptor = disableAbortSignalAny();
|
||||
const { stream: baseStream } = createFakeBaseStream();
|
||||
let abortCalled = false;
|
||||
|
||||
try {
|
||||
const underlying = vi.fn((_model, _context, options) => {
|
||||
if (options?.signal) {
|
||||
options.signal.addEventListener("abort", () => {
|
||||
abortCalled = true;
|
||||
});
|
||||
}
|
||||
return baseStream;
|
||||
});
|
||||
|
||||
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
|
||||
provider: "opencode-go",
|
||||
idleTimeoutMs: 5_000,
|
||||
});
|
||||
|
||||
const downstream = await Promise.resolve(
|
||||
wrapper(
|
||||
{ provider: "opencode-go", id: "deepseek-v4-flash" } as any,
|
||||
{} as any,
|
||||
{ signal: new AbortController().signal } as any,
|
||||
),
|
||||
);
|
||||
expect(downstream).toBeDefined();
|
||||
if (!downstream) {
|
||||
return;
|
||||
}
|
||||
|
||||
const consumer = (async () => {
|
||||
for await (const event of downstream) {
|
||||
void event;
|
||||
}
|
||||
})();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(6_000);
|
||||
|
||||
expect(abortCalled).toBe(true);
|
||||
await consumer;
|
||||
} finally {
|
||||
restoreAbortSignalAny(abortSignalAnyDescriptor);
|
||||
}
|
||||
});
|
||||
|
||||
it("cleans up fallback AbortSignal listeners after natural completion", async () => {
|
||||
const abortSignalAnyDescriptor = disableAbortSignalAny();
|
||||
const sourceController = new AbortController();
|
||||
const addEventListener = vi.spyOn(sourceController.signal, "addEventListener");
|
||||
const removeEventListener = vi.spyOn(sourceController.signal, "removeEventListener");
|
||||
const { stream: baseStream, controller } = createFakeBaseStream();
|
||||
|
||||
try {
|
||||
const wrapper = createOpencodeGoStalledStreamWrapper(vi.fn(() => baseStream) as any, {
|
||||
provider: "opencode-go",
|
||||
idleTimeoutMs: 5_000,
|
||||
});
|
||||
|
||||
const downstream = await Promise.resolve(
|
||||
wrapper(
|
||||
{ provider: "opencode-go", id: "deepseek-v4-flash" } as any,
|
||||
{} as any,
|
||||
{ signal: sourceController.signal } as any,
|
||||
),
|
||||
);
|
||||
expect(downstream).toBeDefined();
|
||||
if (!downstream) {
|
||||
return;
|
||||
}
|
||||
|
||||
const received: AnyEvent[] = [];
|
||||
const consumer = (async () => {
|
||||
for await (const event of downstream) {
|
||||
received.push(event);
|
||||
}
|
||||
})();
|
||||
|
||||
const partial = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "done" }],
|
||||
stopReason: "stop",
|
||||
};
|
||||
controller.emit({ type: "start", partial } as any);
|
||||
controller.emit({ type: "done", reason: "stop", message: partial } as any);
|
||||
await consumer;
|
||||
|
||||
expect(received.some((event) => event.type === "done")).toBe(true);
|
||||
expect(addEventListener).toHaveBeenCalledWith("abort", expect.any(Function), { once: true });
|
||||
expect(removeEventListener).toHaveBeenCalledWith("abort", expect.any(Function));
|
||||
} finally {
|
||||
restoreAbortSignalAny(abortSignalAnyDescriptor);
|
||||
addEventListener.mockRestore();
|
||||
removeEventListener.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves normal delayed usage-only completion without aborting", async () => {
|
||||
// Arrange: a fake base stream that streams a normal completion, including
|
||||
// a long quiet gap before the final usage-only delta — but well within the
|
||||
// idle timeout. The wrapper must not abort.
|
||||
const { stream: baseStream, controller } = createFakeBaseStream();
|
||||
void baseStream;
|
||||
let abortCalled = false;
|
||||
const capturedSignals: AbortSignal[] = [];
|
||||
|
||||
const underlying = vi.fn((_model, _context, options) => {
|
||||
if (options?.signal) {
|
||||
capturedSignals.push(options.signal);
|
||||
options.signal.addEventListener("abort", () => {
|
||||
abortCalled = true;
|
||||
});
|
||||
}
|
||||
return baseStream;
|
||||
});
|
||||
|
||||
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
|
||||
provider: "opencode-go",
|
||||
idleTimeoutMs: 5_000,
|
||||
});
|
||||
|
||||
const downstream = await Promise.resolve(
|
||||
wrapper({ provider: "opencode-go", id: "deepseek-v4-flash" } as any, {} as any, {} as any),
|
||||
);
|
||||
expect(downstream).toBeDefined();
|
||||
if (!downstream) {
|
||||
return;
|
||||
}
|
||||
|
||||
const received: AnyEvent[] = [];
|
||||
const consumer = (async () => {
|
||||
for await (const event of downstream) {
|
||||
received.push(event);
|
||||
}
|
||||
})();
|
||||
|
||||
const partial = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
stopReason: "stop",
|
||||
};
|
||||
controller.emit({ type: "start", partial } as any);
|
||||
controller.emit({
|
||||
type: "text_delta",
|
||||
contentIndex: 0,
|
||||
delta: "hello",
|
||||
partial,
|
||||
} as any);
|
||||
|
||||
// Simulate a delayed final chunk after a short (sub-timeout) quiet gap.
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
|
||||
// Final completion event arrives before idle timeout fires.
|
||||
controller.emit({
|
||||
type: "done",
|
||||
reason: "stop",
|
||||
message: partial,
|
||||
} as any);
|
||||
|
||||
// Advance well past the idle timeout — wrapper should NOT have fired.
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
|
||||
expect(abortCalled).toBe(false);
|
||||
|
||||
// Downstream must contain all forwarded events including the done event.
|
||||
const doneEvent = received.find((event) => event.type === "done");
|
||||
expect(doneEvent).toBeDefined();
|
||||
|
||||
// Cleanup
|
||||
controller.end();
|
||||
await consumer;
|
||||
});
|
||||
});
|
||||
371
extensions/opencode-go/stream-termination.ts
Normal file
371
extensions/opencode-go/stream-termination.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
// Opencode Go stream termination wrapper aborts stalled OpenAI-compatible
|
||||
// SSE streams at the provider-owned raw boundary, before the shared runtime
|
||||
// stuck-session recovery kicks in.
|
||||
import type { AssistantMessage, AssistantMessageEvent } from "openclaw/plugin-sdk/llm";
|
||||
import { createAssistantMessageEventStream } from "openclaw/plugin-sdk/llm";
|
||||
import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
|
||||
type ProviderStreamFn = NonNullable<ProviderWrapStreamFnContext["streamFn"]>;
|
||||
|
||||
export interface OpencodeGoStalledStreamWrapperOptions {
|
||||
/**
|
||||
* Provider id this wrapper applies to. Calls whose model.provider does not
|
||||
* match are forwarded untouched so the wrapper stays provider-scoped.
|
||||
*/
|
||||
provider: string;
|
||||
/**
|
||||
* Maximum idle window between two stream events before the wrapper treats
|
||||
* the underlying SSE as stalled and aborts it. Must be > 0.
|
||||
*/
|
||||
idleTimeoutMs: number;
|
||||
/**
|
||||
* Maximum window for stream creation and first event delivery. Must be > 0.
|
||||
*/
|
||||
firstEventTimeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default idle window used in production. Matches the runtime's shared
|
||||
* `DEFAULT_LLM_IDLE_TIMEOUT_MS` (120s) so non-cron interactive runs see
|
||||
* no behavior change versus the existing watchdog, while cron runs — for
|
||||
* which the runtime disables its idle watchdog entirely
|
||||
* (`resolveLlmIdleTimeoutMs` returns 0 when `trigger === "cron"` and no
|
||||
* explicit timeout is set) — finally get a provider-owned termination
|
||||
* well before the ~622s stuck-session recovery kicks in.
|
||||
*/
|
||||
export const OPENCODE_GO_STREAM_IDLE_TIMEOUT_MS_DEFAULT = 120_000;
|
||||
|
||||
export const OPENCODE_GO_STREAM_FIRST_EVENT_TIMEOUT_MS_DEFAULT = 300_000;
|
||||
|
||||
function isOpencodeGoModel(model: unknown, providerId: string): boolean {
|
||||
return Boolean(model) && typeof model === "object"
|
||||
? (model as { provider?: unknown }).provider === providerId
|
||||
: false;
|
||||
}
|
||||
|
||||
function validTimeoutMs(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function resolveTimeoutMs(model: unknown, fallbackMs: number): number {
|
||||
return validTimeoutMs((model as { requestTimeoutMs?: unknown })?.requestTimeoutMs) ?? fallbackMs;
|
||||
}
|
||||
|
||||
function isProviderProgressEvent(event: AssistantMessageEvent): boolean {
|
||||
return (
|
||||
event.type === "text_delta" ||
|
||||
event.type === "thinking_delta" ||
|
||||
event.type === "toolcall_delta"
|
||||
);
|
||||
}
|
||||
|
||||
function combineAbortSignals(signals: (AbortSignal | undefined)[]): {
|
||||
signal: AbortSignal;
|
||||
cleanup(): void;
|
||||
} {
|
||||
const present = signals.filter((signal): signal is AbortSignal => Boolean(signal));
|
||||
if (present.length === 0) {
|
||||
return { signal: new AbortController().signal, cleanup: () => undefined };
|
||||
}
|
||||
if (present.length === 1) {
|
||||
return { signal: present[0], cleanup: () => undefined };
|
||||
}
|
||||
const anyFn = (
|
||||
AbortSignal as unknown as {
|
||||
any?: (signals: AbortSignal[]) => AbortSignal;
|
||||
}
|
||||
).any;
|
||||
if (typeof anyFn === "function") {
|
||||
return { signal: anyFn(present), cleanup: () => undefined };
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const alreadyAborted = present.find((signal) => signal.aborted);
|
||||
if (alreadyAborted) {
|
||||
controller.abort((alreadyAborted as { reason?: unknown }).reason);
|
||||
return { signal: controller.signal, cleanup: () => undefined };
|
||||
}
|
||||
const unsubscribe: Array<() => void> = [];
|
||||
for (const signal of present) {
|
||||
const onAbort = () => controller.abort((signal as { reason?: unknown }).reason);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
unsubscribe.push(() => signal.removeEventListener("abort", onAbort));
|
||||
}
|
||||
return {
|
||||
signal: controller.signal,
|
||||
cleanup() {
|
||||
for (const remove of unsubscribe) {
|
||||
remove();
|
||||
}
|
||||
unsubscribe.length = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const STALLED_STREAM_ERROR_MESSAGE =
|
||||
"opencode-go stream timed out after provider-owned SSE boundary stalled";
|
||||
|
||||
function buildStalledErrorEvent(partial: AssistantMessage | undefined): AssistantMessageEvent {
|
||||
if (partial) {
|
||||
return {
|
||||
type: "error",
|
||||
reason: "error",
|
||||
error: {
|
||||
...partial,
|
||||
stopReason: "error",
|
||||
errorMessage: STALLED_STREAM_ERROR_MESSAGE,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "error",
|
||||
reason: "error",
|
||||
error: synthesizeMinimalAssistantMessage(STALLED_STREAM_ERROR_MESSAGE, "error"),
|
||||
};
|
||||
}
|
||||
|
||||
function buildUnterminatedErrorEvent(partial: AssistantMessage | undefined): AssistantMessageEvent {
|
||||
if (partial) {
|
||||
return {
|
||||
type: "error",
|
||||
reason: "error",
|
||||
error: {
|
||||
...partial,
|
||||
stopReason: "error",
|
||||
errorMessage: "opencode-go stream ended without a terminal event",
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "error",
|
||||
reason: "error",
|
||||
error: synthesizeMinimalAssistantMessage(
|
||||
"opencode-go stream ended without a terminal event",
|
||||
"error",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function buildCaughtErrorEvent(
|
||||
partial: AssistantMessage | undefined,
|
||||
error: unknown,
|
||||
): AssistantMessageEvent {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (partial) {
|
||||
return {
|
||||
type: "error",
|
||||
reason: "error",
|
||||
error: {
|
||||
...partial,
|
||||
stopReason: "error",
|
||||
errorMessage: message,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "error",
|
||||
reason: "error",
|
||||
error: synthesizeMinimalAssistantMessage(message, "error"),
|
||||
};
|
||||
}
|
||||
|
||||
function synthesizeMinimalAssistantMessage(
|
||||
errorMessage: string,
|
||||
stopReason: AssistantMessage["stopReason"],
|
||||
): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
api: "openai-completions",
|
||||
provider: "opencode-go",
|
||||
model: "",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason,
|
||||
errorMessage,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps an opencode-go provider stream function so that an SSE socket that
|
||||
* fails to deliver a first event or stops producing progress is aborted at the
|
||||
* provider-owned raw boundary via the injected AbortSignal, instead of waiting
|
||||
* for the much later shared runtime stuck-session recovery.
|
||||
*
|
||||
* Behavior:
|
||||
* - Provider-scoped: only applies when `model.provider === options.provider`.
|
||||
* - Idle-based: the timer covers stream creation, first event delivery, and
|
||||
* every gap after provider progress begins; if no event arrives within
|
||||
* `idleTimeoutMs`, the wrapper calls `controller.abort()` on the AbortSignal
|
||||
* injected into the underlying call (so the OpenAI SDK request is genuinely
|
||||
* interrupted, not just the iterator) and pushes a terminal `error` event
|
||||
* downstream.
|
||||
* - Terminal-safe: when the underlying stream emits `done` or `error`, the
|
||||
* wrapper forwards the event, clears all timers, and ends the stream.
|
||||
*
|
||||
* The wrapper never shortens the natural end of a normal completion, because
|
||||
* provider progress refreshes the idle timer and a terminal event cancels it entirely.
|
||||
*/
|
||||
export function createOpencodeGoStalledStreamWrapper(
|
||||
underlying: ProviderStreamFn,
|
||||
options: OpencodeGoStalledStreamWrapperOptions,
|
||||
): ProviderStreamFn {
|
||||
if (!options || options.idleTimeoutMs <= 0) {
|
||||
throw new Error("createOpencodeGoStalledStreamWrapper requires idleTimeoutMs > 0");
|
||||
}
|
||||
if (options.firstEventTimeoutMs !== undefined && options.firstEventTimeoutMs <= 0) {
|
||||
throw new Error("createOpencodeGoStalledStreamWrapper requires firstEventTimeoutMs > 0");
|
||||
}
|
||||
const providerId = options.provider;
|
||||
const idleTimeoutMsDefault = options.idleTimeoutMs;
|
||||
const firstEventTimeoutMsDefault = options.firstEventTimeoutMs ?? options.idleTimeoutMs;
|
||||
|
||||
return (model, context, callOptions) => {
|
||||
if (!isOpencodeGoModel(model, providerId)) {
|
||||
return underlying(model, context, callOptions);
|
||||
}
|
||||
|
||||
const output = createAssistantMessageEventStream();
|
||||
const idleTimeoutMs = resolveTimeoutMs(model, idleTimeoutMsDefault);
|
||||
const firstEventTimeoutMs = resolveTimeoutMs(model, firstEventTimeoutMsDefault);
|
||||
const controller = new AbortController();
|
||||
const combinedSignal = combineAbortSignals([
|
||||
(callOptions as { signal?: AbortSignal } | undefined)?.signal,
|
||||
controller.signal,
|
||||
]);
|
||||
const wrappedOptions = {
|
||||
...callOptions,
|
||||
signal: combinedSignal.signal,
|
||||
};
|
||||
let idleTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let lastSeenPartial: AssistantMessage | undefined;
|
||||
let settled = false;
|
||||
let baseIterator: AsyncIterator<AssistantMessageEvent> | undefined;
|
||||
|
||||
const clearIdleTimer = () => {
|
||||
if (idleTimer !== undefined) {
|
||||
clearTimeout(idleTimer);
|
||||
idleTimer = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
clearIdleTimer();
|
||||
combinedSignal.cleanup();
|
||||
};
|
||||
|
||||
const releaseBaseStream = () => {
|
||||
if (baseIterator?.return) {
|
||||
void Promise.resolve(baseIterator.return()).catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const finishWith = (event: AssistantMessageEvent) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
cleanup();
|
||||
output.push(event);
|
||||
output.end(
|
||||
event.type === "done" ? (event as { message: AssistantMessage }).message : undefined,
|
||||
);
|
||||
};
|
||||
|
||||
const abortStalledStream = () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearIdleTimer();
|
||||
controller.abort(new Error("opencode-go stream stalled"));
|
||||
combinedSignal.cleanup();
|
||||
releaseBaseStream();
|
||||
output.push(buildStalledErrorEvent(lastSeenPartial));
|
||||
output.end();
|
||||
};
|
||||
|
||||
const armTimer = (timeoutMs: number) => {
|
||||
clearIdleTimer();
|
||||
idleTimer = setTimeout(abortStalledStream, timeoutMs);
|
||||
idleTimer.unref?.();
|
||||
};
|
||||
|
||||
const armFirstEventTimer = () => armTimer(firstEventTimeoutMs);
|
||||
|
||||
const armIdleTimer = () => armTimer(idleTimeoutMs);
|
||||
|
||||
const trackPartial = (event: AssistantMessageEvent) => {
|
||||
const partial =
|
||||
(event as { partial?: AssistantMessage; message?: AssistantMessage }).partial ??
|
||||
(event as { message?: AssistantMessage }).message;
|
||||
if (partial) {
|
||||
lastSeenPartial = partial;
|
||||
}
|
||||
};
|
||||
|
||||
const releaseResolvedStream = (baseStream: AsyncIterable<AssistantMessageEvent>) => {
|
||||
const iterator = baseStream[Symbol.asyncIterator]();
|
||||
if (iterator.return) {
|
||||
void Promise.resolve(iterator.return()).catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
armFirstEventTimer();
|
||||
let baseStreamResult: ReturnType<ProviderStreamFn>;
|
||||
try {
|
||||
baseStreamResult = underlying(model, context, wrappedOptions);
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
throw error;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const baseStream = await Promise.resolve(
|
||||
baseStreamResult as Awaited<ReturnType<ProviderStreamFn>>,
|
||||
);
|
||||
if (settled) {
|
||||
releaseResolvedStream(baseStream as AsyncIterable<AssistantMessageEvent>);
|
||||
return;
|
||||
}
|
||||
baseIterator = (baseStream as AsyncIterable<AssistantMessageEvent>)[Symbol.asyncIterator]();
|
||||
for (;;) {
|
||||
const result = await baseIterator.next();
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
if (result.done) {
|
||||
finishWith(buildUnterminatedErrorEvent(lastSeenPartial));
|
||||
return;
|
||||
}
|
||||
const event = result.value;
|
||||
if (event.type === "done" || event.type === "error") {
|
||||
trackPartial(event);
|
||||
finishWith(event);
|
||||
return;
|
||||
}
|
||||
trackPartial(event);
|
||||
output.push(event);
|
||||
if (isProviderProgressEvent(event)) {
|
||||
armIdleTimer();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!settled) {
|
||||
finishWith(buildCaughtErrorEvent(lastSeenPartial, error));
|
||||
}
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
})();
|
||||
|
||||
return output;
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,11 @@ import {
|
||||
} from "openclaw/plugin-sdk/provider-stream-shared";
|
||||
import { isOpencodeGoKimiNoReasoningModelId } from "./provider-catalog.js";
|
||||
import { stripOpencodeGoKimiReasoningPayload } from "./reasoning-sanitizer.js";
|
||||
import {
|
||||
createOpencodeGoStalledStreamWrapper,
|
||||
OPENCODE_GO_STREAM_FIRST_EVENT_TIMEOUT_MS_DEFAULT,
|
||||
OPENCODE_GO_STREAM_IDLE_TIMEOUT_MS_DEFAULT,
|
||||
} from "./stream-termination.js";
|
||||
|
||||
function isOpencodeGoDeepSeekV4ModelId(modelId: unknown): boolean {
|
||||
return modelId === "deepseek-v4-flash" || modelId === "deepseek-v4-pro";
|
||||
@@ -46,6 +51,18 @@ export function createOpencodeGoWrapper(
|
||||
baseStreamFn: ProviderWrapStreamFnContext["streamFn"],
|
||||
thinkingLevel: ProviderWrapStreamFnContext["thinkingLevel"],
|
||||
): ProviderWrapStreamFnContext["streamFn"] {
|
||||
if (!baseStreamFn) {
|
||||
return undefined;
|
||||
}
|
||||
const kimiWrapped = createOpencodeGoKimiNoReasoningWrapper(baseStreamFn) ?? baseStreamFn;
|
||||
return createOpencodeGoDeepSeekV4Wrapper(kimiWrapped, thinkingLevel) ?? kimiWrapped;
|
||||
const deepSeekWrapped =
|
||||
createOpencodeGoDeepSeekV4Wrapper(kimiWrapped, thinkingLevel) ?? kimiWrapped;
|
||||
// Outermost layer: provider-owned stalled SSE termination so the underlying
|
||||
// OpenAI SDK request is aborted at the raw opencode-go boundary instead of
|
||||
// waiting for the shared runtime stuck-session recovery.
|
||||
return createOpencodeGoStalledStreamWrapper(deepSeekWrapped, {
|
||||
provider: "opencode-go",
|
||||
idleTimeoutMs: OPENCODE_GO_STREAM_IDLE_TIMEOUT_MS_DEFAULT,
|
||||
firstEventTimeoutMs: OPENCODE_GO_STREAM_FIRST_EVENT_TIMEOUT_MS_DEFAULT,
|
||||
});
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user