mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-24 00:04:25 +08:00
Compare commits
21 Commits
fix/gatewa
...
codex/refa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7641ec8ba7 | ||
|
|
28cbeaaa80 | ||
|
|
e773981088 | ||
|
|
9d4d07175d | ||
|
|
cdb7e64994 | ||
|
|
7daba184b5 | ||
|
|
3d1935dcc1 | ||
|
|
b832153d0a | ||
|
|
9bd2b4b34b | ||
|
|
02abd3adb5 | ||
|
|
0dda56e0f6 | ||
|
|
365d78605d | ||
|
|
a2c64b08ff | ||
|
|
90decc657d | ||
|
|
e670de672d | ||
|
|
c4aba64b58 | ||
|
|
3a024f6a8d | ||
|
|
c866b087eb | ||
|
|
620cca1d7f | ||
|
|
353012b8a8 | ||
|
|
a37c6c935a |
@@ -15,7 +15,7 @@ committed `inventory/` report tree.
|
||||
This skill owns the operational workflow for:
|
||||
|
||||
- `taxonomy.yaml`
|
||||
- `qa/maturity-scores.yaml`
|
||||
- `docs/maturity-scores.yaml`
|
||||
- `docs/concepts/qa-e2e-automation.md`
|
||||
- `qa/scenarios/index.yaml`
|
||||
|
||||
@@ -37,35 +37,28 @@ out of this repo. If a score needs private evidence, use the redacted
|
||||
coverage IDs. Do not promote generic IDs into standalone feature names.
|
||||
- Avoid duplicate coverage-ID bundles under different feature names in one
|
||||
category.
|
||||
- `qa/maturity-scores.yaml` is the committed aggregate source for Quality,
|
||||
Completeness, and LTS review state.
|
||||
- `extensions/qa-lab/src/scorecard-taxonomy.ts` exports
|
||||
`qaMaturityScoresSchema` and `readValidatedQaMaturityScoreSources`; use those
|
||||
QA Lab utilities to validate score output.
|
||||
- Generated public docs are `docs/maturity/scorecard.md` and
|
||||
`docs/maturity/taxonomy.md`; both come from `pnpm maturity:render`. Do not
|
||||
hand-edit generated Markdown to change score results.
|
||||
- `qa-evidence.json` artifacts provide per-run QA scorecard evidence. Release
|
||||
profile artifacts are the source of truth for Coverage. They can enrich
|
||||
generated artifact docs, but they are not committed as inventory.
|
||||
- `docs/maturity-scores.yaml` is the aggregate score source committed in this
|
||||
repo. It is the only committed score data; do not add generated inventory
|
||||
directories.
|
||||
- There is no committed maturity-doc renderer or `pnpm maturity:*` script in
|
||||
this repo. Do not invent generated scorecard files; update the source YAML
|
||||
and current docs directly.
|
||||
- `qa-evidence.json` artifacts provide per-run QA scorecard evidence. They can
|
||||
enrich generated artifact docs, but they are not committed as inventory.
|
||||
|
||||
## Commands
|
||||
|
||||
Run from the openclaw repo root.
|
||||
|
||||
Validate taxonomy YAML structure and the maturity score schema after source
|
||||
edits:
|
||||
Validate YAML structure after source edits:
|
||||
|
||||
```bash
|
||||
node --import tsx --input-type=module <<'NODE'
|
||||
import fs from "node:fs";
|
||||
import YAML from "yaml";
|
||||
import { readValidatedQaMaturityScoreSources } from "./extensions/qa-lab/src/scorecard-taxonomy.ts";
|
||||
|
||||
for (const file of ["taxonomy.yaml", "qa/scenarios/index.yaml"]) {
|
||||
node <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const YAML = require("yaml");
|
||||
for (const file of ["taxonomy.yaml", "docs/maturity-scores.yaml", "qa/scenarios/index.yaml"]) {
|
||||
YAML.parse(fs.readFileSync(file, "utf8"));
|
||||
}
|
||||
readValidatedQaMaturityScoreSources();
|
||||
NODE
|
||||
```
|
||||
|
||||
@@ -90,17 +83,17 @@ When asked to score or refresh a surface:
|
||||
`.agents/skills/claw-score/references/completeness/`.
|
||||
3. Gather public repo evidence from docs, source, tests, and QA scenario
|
||||
metadata.
|
||||
4. Prefer existing release profile `qa-evidence.json` artifacts for executed
|
||||
proof.
|
||||
5. Update `qa/maturity-scores.yaml` only for Quality, Completeness, and LTS
|
||||
review state backed by public or redacted artifact evidence.
|
||||
6. Run the schema validation command from this skill.
|
||||
4. Prefer existing `qa-evidence.json` artifacts for executed proof. Do not use
|
||||
discrawl or unredacted private archives.
|
||||
5. Update `docs/maturity-scores.yaml` only when the score change is backed by
|
||||
public or redacted artifact evidence.
|
||||
6. Run the YAML validation command from this skill.
|
||||
7. Run `pnpm check:docs` if docs prose changed, and focused QA coverage checks
|
||||
if coverage IDs or profile membership changed.
|
||||
|
||||
For subjective score changes, make the smallest defensible edit and leave the
|
||||
evidence path in the PR or task summary. Keep manual prose in current docs and
|
||||
keep score data in `qa/maturity-scores.yaml`.
|
||||
keep score data in `docs/maturity-scores.yaml`.
|
||||
|
||||
## Default Completeness Process
|
||||
|
||||
@@ -159,16 +152,15 @@ Default Completeness bands:
|
||||
|
||||
## Score Semantics
|
||||
|
||||
- Coverage: deterministic release validation coverage derived from the release
|
||||
profile `qa-evidence.json.scorecard` feature fulfillment data.
|
||||
- Coverage: public or redacted proof that the feature is exercised by docs,
|
||||
tests, QA scenarios, live lanes, or release evidence.
|
||||
- Quality: reliability, maintainability, operator safety, and regression
|
||||
confidence for the category.
|
||||
- Completeness: how much of the intended operator-visible workflow exists for
|
||||
the category. Use the default completeness process plus any surface-specific
|
||||
variation before changing this score.
|
||||
- LTS: derived from Quality, release-evidence Coverage, and
|
||||
`human_lts_override`; do not hand-edit generated Markdown to change LTS
|
||||
status.
|
||||
- LTS: derived from score thresholds and `human_lts_override`; do not hand-edit
|
||||
generated Markdown to change LTS status.
|
||||
|
||||
Bands:
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { execFileSync } from "node:child_process";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
const repo = "openclaw/openclaw";
|
||||
const commitAssociationQueryBatchSize = 20;
|
||||
const excludedHandles = new Set(["openclaw", "clawsweeper", "claude", "codex", "steipete"]);
|
||||
const nonEditorialTypes = new Set([
|
||||
"build",
|
||||
@@ -619,25 +618,13 @@ function graphql(query) {
|
||||
let lastError;
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
try {
|
||||
const response = githubApi(["graphql", "-f", `query=${query}`]);
|
||||
if (response?.data && typeof response.data === "object") {
|
||||
return response.data;
|
||||
}
|
||||
const errors = Array.isArray(response?.errors)
|
||||
? response.errors.map((error) => error?.message).filter(Boolean)
|
||||
: [];
|
||||
const detail = [...errors, response?.message].filter(Boolean).join("\n");
|
||||
throw new Error(
|
||||
detail
|
||||
? `GitHub GraphQL response did not include data:\n${detail}`
|
||||
: "GitHub GraphQL response did not include data.",
|
||||
);
|
||||
return githubApi(["graphql", "-f", `query=${query}`]).data;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const message = [error?.message, error?.stdout, error?.stderr].filter(Boolean).join("\n");
|
||||
// Historical ranges batch hundreds of objects; only retry transient transport failures.
|
||||
if (
|
||||
!/(?:operation timed out|ECONNRESET|ETIMEDOUT|EAI_AGAIN|TLS handshake timeout|stream error: .*CANCEL|unexpected end of JSON input|upstream connect error|connection termination|connection reset by peer|error connecting to api\.github\.com|Unexpected token '<'|something went wrong|temporarily unavailable|internal server error|rate limit)/i.test(
|
||||
!/(?:operation timed out|ECONNRESET|ETIMEDOUT|EAI_AGAIN|TLS handshake timeout|stream error: .*CANCEL|unexpected end of JSON input|upstream connect error|connection termination|error connecting to api\.github\.com|Unexpected token '<')/i.test(
|
||||
message,
|
||||
)
|
||||
) {
|
||||
@@ -670,8 +657,8 @@ function resolveAssociatedPullRequests(commitHashes, targetTimestamp) {
|
||||
pending.push({ commitHash, cursor: connection.pageInfo.endCursor });
|
||||
}
|
||||
}
|
||||
for (let index = 0; index < commitHashes.length; index += commitAssociationQueryBatchSize) {
|
||||
const chunk = commitHashes.slice(index, index + commitAssociationQueryBatchSize);
|
||||
for (let index = 0; index < commitHashes.length; index += 40) {
|
||||
const chunk = commitHashes.slice(index, index + 40);
|
||||
const fields = chunk
|
||||
.map(
|
||||
(hash, offset) =>
|
||||
|
||||
@@ -4,14 +4,6 @@ set -euo pipefail
|
||||
repo="openclaw/openclaw"
|
||||
months="12"
|
||||
include_global="0"
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo_root="$(git -C "$script_dir/../../../.." rev-parse --show-toplevel 2>/dev/null || true)"
|
||||
if [ -z "$repo_root" ]; then
|
||||
repo_root="$(cd "$script_dir/../../../.." && pwd)"
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
source "$repo_root/scripts/lib/plain-gh.sh"
|
||||
|
||||
usage() {
|
||||
printf 'Usage: %s [--repo owner/repo] [--months N] [--global] <github-login> [login...]\n' "$0"
|
||||
@@ -26,10 +18,6 @@ need() {
|
||||
command -v "$1" >/dev/null 2>&1 || die "missing required command: $1"
|
||||
}
|
||||
|
||||
gh() {
|
||||
gh_plain "$@"
|
||||
}
|
||||
|
||||
date_utc_relative_months() {
|
||||
local count="$1"
|
||||
if date -u -v-"${count}"m +%Y-%m-%dT00:00:00Z >/dev/null 2>&1; then
|
||||
@@ -143,8 +131,7 @@ done
|
||||
exit 2
|
||||
}
|
||||
|
||||
OPENCLAW_GH_BIN="$(resolve_plain_gh_bin)" || die "missing required command: gh"
|
||||
export OPENCLAW_GH_BIN
|
||||
need gh
|
||||
need jq
|
||||
|
||||
since_ts=$(date_utc_relative_months "$months")
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
* Usage: node secret-scanning.mjs <command> [options]
|
||||
*/
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { spawnPlainGh } from "../../../../scripts/lib/plain-gh.mjs";
|
||||
|
||||
const REPO = "openclaw/openclaw";
|
||||
const REPO_URL = `https://github.com/${REPO}`;
|
||||
@@ -29,7 +29,7 @@ function tmpFile(purpose) {
|
||||
}
|
||||
|
||||
function gh(args, { json = true, allowFailure = false } = {}) {
|
||||
const proc = spawnPlainGh(args, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
|
||||
const proc = spawnSync("gh", args, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
|
||||
if (proc.status !== 0 && !allowFailure) {
|
||||
fail(`gh ${args.slice(0, 3).join(" ")} failed:\n${(proc.stderr || proc.stdout || "").trim()}`);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import process from "node:process";
|
||||
import { plainGhEnv, resolvePlainGhBin } from "../../../../scripts/lib/plain-gh.mjs";
|
||||
|
||||
const runId = process.argv[2];
|
||||
const repo = process.env.OPENCLAW_RELEASE_REPO || "openclaw/openclaw";
|
||||
@@ -16,9 +15,8 @@ if (!runId) {
|
||||
}
|
||||
|
||||
function gh(args) {
|
||||
return execFileSync(resolvePlainGhBin(), args, {
|
||||
return execFileSync("gh", args, {
|
||||
encoding: "utf8",
|
||||
env: plainGhEnv(),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
@@ -34,15 +32,14 @@ function githubRestJson(pathSuffix) {
|
||||
"-lc",
|
||||
[
|
||||
"set -euo pipefail",
|
||||
'token="$("$OPENCLAW_PLAIN_GH_BIN" auth token)"',
|
||||
'token="$(gh auth token)"',
|
||||
'curl -fsS -H "Authorization: Bearer ${token}" -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" "${OPENCLAW_GITHUB_REST_URL}"',
|
||||
].join("\n"),
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...plainGhEnv(),
|
||||
OPENCLAW_PLAIN_GH_BIN: resolvePlainGhBin(),
|
||||
...process.env,
|
||||
OPENCLAW_GITHUB_REST_URL: `https://api.github.com/repos/${repo}/${pathSuffix}`,
|
||||
},
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
|
||||
76
.github/ISSUE_TEMPLATE/docs_bug_report.yml
vendored
76
.github/ISSUE_TEMPLATE/docs_bug_report.yml
vendored
@@ -1,76 +0,0 @@
|
||||
name: Docs bug report
|
||||
description: Report documentation defects (incorrect, missing, outdated, or contradictory docs).
|
||||
title: "[Docs Bug]: "
|
||||
labels:
|
||||
- bug
|
||||
- docs
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Report a documentation defect with concrete evidence from current docs behavior/content.
|
||||
Please only report one documentation defect per submission.
|
||||
- type: textarea
|
||||
id: summary
|
||||
attributes:
|
||||
label: Summary
|
||||
description: One-sentence statement of what is wrong in the docs.
|
||||
placeholder: The WhatsApp config example defines duplicate top-level keys in one JSON5 block.
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: doc_paths
|
||||
attributes:
|
||||
label: Affected docs path(s) or URL(s)
|
||||
description: Repo-relative docs file path(s) or published docs URL(s).
|
||||
placeholder: docs/gateway/config-channels.md or https://docs.openclaw.ai/gateway/config-channels
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: Steps to reproduce / verify
|
||||
description: Minimal steps to observe the docs defect in the current docs.
|
||||
placeholder: |
|
||||
1. Open docs/gateway/config-channels.md
|
||||
2. Go to the WhatsApp example block
|
||||
3. Observe duplicate top-level key definitions
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected docs behavior/content
|
||||
description: What the docs should say/show instead.
|
||||
placeholder: The example should use a single merged top-level object with no duplicate keys.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual docs behavior/content
|
||||
description: What the docs currently say/show.
|
||||
placeholder: The snippet defines the same top-level key twice in one object.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: impact
|
||||
attributes:
|
||||
label: Impact
|
||||
description: Who is affected and practical consequence.
|
||||
placeholder: Users who copy-paste the snippet can end up with ambiguous config behavior.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: evidence
|
||||
attributes:
|
||||
label: Evidence
|
||||
description: Links/snippets/screenshots proving the docs defect.
|
||||
placeholder: Include exact file links and line ranges.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: additional_information
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Optional context, related issues/PRs, or constraints.
|
||||
@@ -22,7 +22,7 @@ paths:
|
||||
- src/plugins/memory-*.ts
|
||||
- src/gateway/server-startup-memory.ts
|
||||
- src/commands/doctor-memory-search.ts
|
||||
- src/commands/doctor/cron/dreaming-payload-migration.ts
|
||||
- src/commands/doctor-cron-dreaming-payload-migration.ts
|
||||
|
||||
paths-ignore:
|
||||
- "**/node_modules"
|
||||
|
||||
@@ -19,6 +19,7 @@ paths:
|
||||
- src/plugins/bundled-compat.ts
|
||||
- src/plugins/bundled-dir.ts
|
||||
- src/plugins/bundled-plugin-metadata.ts
|
||||
- src/plugins/bundled-public-surface-runtime-root.ts
|
||||
- src/plugins/plugin-sdk-dist-alias.ts
|
||||
- src/plugins/captured-registration.ts
|
||||
- src/plugins/config-activation-shared.ts
|
||||
@@ -45,6 +46,7 @@ paths:
|
||||
- src/plugins/runtime-state.ts
|
||||
- src/plugins/runtime.ts
|
||||
- src/plugins/sdk-alias.ts
|
||||
- src/plugins/source-loader.ts
|
||||
- src/plugins/types.ts
|
||||
- src/plugins/validation-diagnostics.ts
|
||||
- src/plugins/web-provider-public-artifacts*.ts
|
||||
|
||||
@@ -51,6 +51,7 @@ paths:
|
||||
- src/plugins/runtime
|
||||
- src/plugins/runtime-state.ts
|
||||
- src/plugins/runtime.ts
|
||||
- src/plugins/source-loader.ts
|
||||
- src/plugins/update.ts
|
||||
- src/plugins/validation-diagnostics.ts
|
||||
- src/plugin-sdk/*entry*.ts
|
||||
|
||||
17
.github/labeler.yml
vendored
17
.github/labeler.yml
vendored
@@ -41,6 +41,12 @@
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/google-meet/**"
|
||||
- "docs/plugins/google-meet.md"
|
||||
"plugin: meeting-notes":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/meeting-notes/**"
|
||||
- "docs/plugins/meeting-notes.md"
|
||||
- "src/meeting-notes/**"
|
||||
"plugin: workboard":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@@ -103,11 +109,6 @@
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/qqbot/**"
|
||||
- "docs/channels/qqbot.md"
|
||||
"channel: raft":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/raft/**"
|
||||
- "docs/channels/raft.md"
|
||||
"channel: qa-channel":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@@ -251,12 +252,12 @@
|
||||
- "src/agents/sandbox*.ts"
|
||||
- "src/commands/sandbox*.ts"
|
||||
- "src/cli/sandbox-cli.ts"
|
||||
- "src/docker-setup.e2e.test.ts"
|
||||
- "src/docker-setup.test.ts"
|
||||
- "src/config/**/*sandbox*"
|
||||
- "docs/cli/sandbox.md"
|
||||
- "docs/gateway/sandbox*.md"
|
||||
- "docs/install/docker.md"
|
||||
- "docs/tools/multi-agent-sandbox-tools.md"
|
||||
- "docs/multi-agent-sandbox-tools.md"
|
||||
|
||||
"agents":
|
||||
- changed-files:
|
||||
@@ -269,7 +270,7 @@
|
||||
- ".github/workflows/opengrep-*.yml"
|
||||
- ".semgrepignore"
|
||||
- "docs/cli/security.md"
|
||||
- "docs/gateway/security/**"
|
||||
- "docs/gateway/security.md"
|
||||
- "security/**"
|
||||
|
||||
"extensions: admin-http-rpc":
|
||||
|
||||
@@ -261,6 +261,6 @@ jobs:
|
||||
|
||||
- name: Run Testbox
|
||||
uses: useblacksmith/run-testbox@3f60ff9ceb2c10c3feefa87dc0c6490cffae059d
|
||||
if: always()
|
||||
if: success()
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
2
.github/workflows/ci-check-arm-testbox.yml
vendored
2
.github/workflows/ci-check-arm-testbox.yml
vendored
@@ -179,6 +179,6 @@ jobs:
|
||||
|
||||
- name: Run Testbox
|
||||
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
|
||||
if: always()
|
||||
if: success()
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
4
.github/workflows/ci-check-testbox.yml
vendored
4
.github/workflows/ci-check-testbox.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
contents: read
|
||||
name: "check"
|
||||
runs-on: blacksmith-32vcpu-ubuntu-2404
|
||||
timeout-minutes: ${{ fromJSON(inputs.timeout_minutes || '120') }}
|
||||
timeout-minutes: ${{ fromJSON(inputs.timeout_minutes || '30') }}
|
||||
steps:
|
||||
- name: Begin Testbox
|
||||
uses: useblacksmith/begin-testbox@233448af4bfdc6fca509a7f0974411ac6d8a8043
|
||||
@@ -168,6 +168,6 @@ jobs:
|
||||
|
||||
- name: Run Testbox
|
||||
uses: useblacksmith/run-testbox@3f60ff9ceb2c10c3feefa87dc0c6490cffae059d
|
||||
if: always()
|
||||
if: success()
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
145
.github/workflows/ci.yml
vendored
145
.github/workflows/ci.yml
vendored
@@ -41,32 +41,11 @@ env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
# Keep the canonical main queue quiet long enough for a follow-up push to
|
||||
# cancel this run before it registers the Blacksmith matrix.
|
||||
runner-admission:
|
||||
permissions:
|
||||
contents: read
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 3
|
||||
env:
|
||||
OPENCLAW_MAIN_CI_DEBOUNCE_SECONDS: "90"
|
||||
steps:
|
||||
- name: Debounce canonical main pushes
|
||||
if: github.event_name == 'push' && github.repository == 'openclaw/openclaw' && github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Waiting ${OPENCLAW_MAIN_CI_DEBOUNCE_SECONDS}s for a superseding main push before Blacksmith admission"
|
||||
sleep "${OPENCLAW_MAIN_CI_DEBOUNCE_SECONDS}"
|
||||
- name: Admit non-main CI runs immediately
|
||||
if: github.event_name != 'push' || github.repository != 'openclaw/openclaw' || github.ref != 'refs/heads/main'
|
||||
run: echo "No canonical main debounce required"
|
||||
|
||||
# Preflight: establish routing truth and job matrices once, then let real
|
||||
# work fan out from a single source of truth.
|
||||
preflight:
|
||||
permissions:
|
||||
contents: read
|
||||
needs: [runner-admission]
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 20
|
||||
@@ -218,7 +197,7 @@ jobs:
|
||||
node --input-type=module <<'EOF'
|
||||
import { appendFileSync } from "node:fs";
|
||||
import {
|
||||
createNodeTestShardBundles,
|
||||
createNodeTestShards,
|
||||
} from "./scripts/lib/ci-node-test-plan.mjs";
|
||||
import {
|
||||
createChannelContractTestShards,
|
||||
@@ -293,23 +272,18 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
const compactPullRequest = isCanonicalRepository && eventName === "pull_request";
|
||||
const nodeTestShards = runNodeFull
|
||||
? createNodeTestShardBundles({
|
||||
? createNodeTestShards({
|
||||
includeReleaseOnlyPluginShards: false,
|
||||
compact: compactPullRequest,
|
||||
}).map((shard) => ({
|
||||
check_name: shard.checkName,
|
||||
runtime: "node",
|
||||
task: "test-shard",
|
||||
shard_name: shard.shardName,
|
||||
groups: shard.groups,
|
||||
configs: shard.configs,
|
||||
env: shard.env,
|
||||
includePatterns: shard.includePatterns,
|
||||
requires_dist: shard.requiresDist,
|
||||
runner: shard.runner,
|
||||
timeout_minutes: shard.timeoutMinutes,
|
||||
}))
|
||||
: [];
|
||||
const nodeTestNonDistShards = nodeTestShards.filter((shard) => !shard.requires_dist);
|
||||
@@ -346,14 +320,7 @@ jobs:
|
||||
run_checks_windows: runWindows,
|
||||
checks_windows_matrix: createMatrix(
|
||||
runWindows
|
||||
? [
|
||||
{
|
||||
check_name: "checks-windows-node-test",
|
||||
runtime: "node",
|
||||
task: "test",
|
||||
runner: "blacksmith-8vcpu-windows-2025",
|
||||
},
|
||||
]
|
||||
? [{ check_name: "checks-windows-node-test", runtime: "node", task: "test" }]
|
||||
: [],
|
||||
),
|
||||
run_macos_node: runMacos,
|
||||
@@ -387,7 +354,6 @@ jobs:
|
||||
security-fast:
|
||||
permissions:
|
||||
contents: read
|
||||
needs: [runner-admission]
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 20
|
||||
@@ -592,7 +558,7 @@ jobs:
|
||||
contents: read
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_build_artifacts == 'true'
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-32vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 20
|
||||
outputs:
|
||||
channels-result: ${{ steps.built_artifact_checks.outputs['channels-result'] }}
|
||||
@@ -853,7 +819,6 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 8
|
||||
matrix: ${{ fromJson(needs.preflight.outputs.checks_fast_core_matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -943,7 +908,6 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 8
|
||||
matrix: ${{ fromJson(needs.preflight.outputs.plugin_contracts_matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -1024,7 +988,6 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 8
|
||||
matrix: ${{ fromJson(needs.preflight.outputs.channel_contracts_matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -1173,13 +1136,10 @@ jobs:
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_checks_node_core_nondist == 'true'
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (matrix.runner || 'blacksmith-4vcpu-ubuntu-2404') || 'ubuntu-24.04') }}
|
||||
timeout-minutes: ${{ matrix.timeout_minutes || 60 }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (matrix.runner || 'blacksmith-8vcpu-ubuntu-2404') || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
# The canonical main path waits for the admission debounce above, so
|
||||
# modestly widen this large matrix without recreating registration bursts.
|
||||
max-parallel: 16
|
||||
matrix: ${{ fromJson(needs.preflight.outputs.checks_node_core_nondist_matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -1238,9 +1198,7 @@ jobs:
|
||||
- name: Run Node test shard
|
||||
env:
|
||||
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"
|
||||
@@ -1254,55 +1212,28 @@ jobs:
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const groups = JSON.parse(process.env.OPENCLAW_NODE_TEST_GROUPS_JSON ?? "null");
|
||||
const plans = Array.isArray(groups) && groups.length > 0
|
||||
? 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",
|
||||
),
|
||||
shard_name: process.env.OPENCLAW_VITEST_SHARD_NAME,
|
||||
}];
|
||||
for (const plan of plans) {
|
||||
const configs = plan.configs;
|
||||
if (!Array.isArray(configs) || configs.length === 0) {
|
||||
console.error("Missing node test shard configs");
|
||||
process.exit(1);
|
||||
}
|
||||
const childEnv = {
|
||||
...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 ?? ".",
|
||||
`node-test-include-${process.env.GITHUB_JOB ?? "local"}-${Date.now()}.json`,
|
||||
);
|
||||
writeFileSync(includeFile, JSON.stringify(plan.includePatterns), "utf8");
|
||||
childEnv.OPENCLAW_VITEST_INCLUDE_FILE = includeFile;
|
||||
} else {
|
||||
delete childEnv.OPENCLAW_VITEST_INCLUDE_FILE;
|
||||
}
|
||||
const result = spawnSync(
|
||||
"pnpm",
|
||||
["exec", "node", "scripts/test-projects.mjs", ...configs],
|
||||
{
|
||||
env: childEnv,
|
||||
stdio: "inherit",
|
||||
},
|
||||
const configs = JSON.parse(process.env.OPENCLAW_NODE_TEST_CONFIGS_JSON ?? "[]");
|
||||
if (!Array.isArray(configs) || configs.length === 0) {
|
||||
console.error("Missing node test shard configs");
|
||||
process.exit(1);
|
||||
}
|
||||
const includePatterns = JSON.parse(process.env.OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON ?? "null");
|
||||
const childEnv = { ...process.env };
|
||||
if (Array.isArray(includePatterns) && includePatterns.length > 0) {
|
||||
const includeFile = join(
|
||||
process.env.RUNNER_TEMP ?? ".",
|
||||
`node-test-include-${process.env.GITHUB_JOB ?? "local"}-${Date.now()}.json`,
|
||||
);
|
||||
if ((result.status ?? 1) !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
writeFileSync(includeFile, JSON.stringify(includePatterns), "utf8");
|
||||
childEnv.OPENCLAW_VITEST_INCLUDE_FILE = includeFile;
|
||||
}
|
||||
|
||||
const result = spawnSync("pnpm", ["exec", "node", "scripts/test-projects.mjs", ...configs], {
|
||||
env: childEnv,
|
||||
stdio: "inherit",
|
||||
});
|
||||
if ((result.status ?? 1) !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
EOF
|
||||
|
||||
@@ -1317,7 +1248,6 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 8
|
||||
matrix:
|
||||
include:
|
||||
- check_name: check-guards
|
||||
@@ -1334,7 +1264,7 @@ jobs:
|
||||
runner: blacksmith-16vcpu-ubuntu-2404
|
||||
- check_name: check-dependencies
|
||||
task: dependencies
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
runner: blacksmith-8vcpu-ubuntu-2404
|
||||
- check_name: check-test-types
|
||||
task: test-types
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
@@ -1455,39 +1385,30 @@ jobs:
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight]
|
||||
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check_additional == 'true' }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (matrix.runner || 'blacksmith-4vcpu-ubuntu-2404') || 'ubuntu-24.04') }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 8
|
||||
matrix:
|
||||
include:
|
||||
- check_name: check-additional-boundaries-a
|
||||
group: boundaries
|
||||
boundary_shard: 1/4
|
||||
runner: blacksmith-8vcpu-ubuntu-2404
|
||||
- check_name: check-additional-boundaries-bcd
|
||||
group: boundaries
|
||||
boundary_shard: 2/4,3/4,4/4
|
||||
runner: blacksmith-8vcpu-ubuntu-2404
|
||||
- check_name: check-session-accessor-boundary
|
||||
group: session-accessor-boundary
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
- check_name: check-session-transcript-reader-boundary
|
||||
group: session-transcript-reader-boundary
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
- check_name: check-additional-extension-channels
|
||||
group: extension-channels
|
||||
runner: blacksmith-8vcpu-ubuntu-2404
|
||||
- check_name: check-additional-extension-bundled
|
||||
group: extension-bundled
|
||||
runner: blacksmith-8vcpu-ubuntu-2404
|
||||
- check_name: check-additional-extension-package-boundary
|
||||
group: extension-package-boundary
|
||||
runner: blacksmith-8vcpu-ubuntu-2404
|
||||
- check_name: check-additional-runtime-topology-architecture
|
||||
group: runtime-topology-architecture
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
shell: bash
|
||||
@@ -1830,7 +1751,7 @@ jobs:
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_checks_windows == 'true'
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'windows-2025' || (github.repository == 'openclaw/openclaw' && (matrix.runner || 'blacksmith-8vcpu-windows-2025') || 'windows-2025') }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'windows-2025' || (github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-windows-2025' || 'windows-2025') }}
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
@@ -1842,7 +1763,6 @@ jobs:
|
||||
shell: bash
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 2
|
||||
matrix: ${{ fromJson(needs.preflight.outputs.checks_windows_matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -2172,7 +2092,6 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 2
|
||||
matrix: ${{ fromJson(needs.preflight.outputs.android_matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -2235,7 +2154,7 @@ jobs:
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: ~/.android-sdk
|
||||
key: ${{ runner.os }}-android-sdk-v1-cmdline-14742923-platform-36-build-tools-36.0.0
|
||||
key: ${{ runner.os }}-android-sdk-v1-cmdline-14742923-platform-37.0-build-tools-36.0.0
|
||||
restore-keys: |
|
||||
${{ runner.os }}-android-sdk-v1-
|
||||
|
||||
@@ -2265,7 +2184,7 @@ jobs:
|
||||
yes | sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" --licenses >/dev/null
|
||||
sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" --install \
|
||||
"platform-tools" \
|
||||
"platforms;android-36" \
|
||||
"platforms;android-37.0" \
|
||||
"build-tools;36.0.0"
|
||||
|
||||
- name: Run Android ${{ matrix.task }}
|
||||
|
||||
30
.github/workflows/clawsweeper-dispatch.yml
vendored
30
.github/workflows/clawsweeper-dispatch.yml
vendored
@@ -81,27 +81,9 @@ jobs:
|
||||
repositories: clawsweeper
|
||||
permission-contents: write
|
||||
|
||||
- name: Pre-filter ClawSweeper comment
|
||||
id: comment_filter
|
||||
if: ${{ github.event_name == 'issue_comment' }}
|
||||
env:
|
||||
COMMENT_BODY: ${{ github.event.comment.body }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if grep -Eiq '(^|[[:space:]])@(clawsweeper|openclaw-clawsweeper)\b(\[bot\])?|(^|[[:space:]])/(clawsweeper|review|autoclose|auto([[:space:]]+|-)?merge)\b' <<< "$COMMENT_BODY"; then
|
||||
echo "is_command=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "is_command=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Create target comment token
|
||||
id: target_token
|
||||
if: >-
|
||||
${{
|
||||
github.event_name == 'issue_comment' &&
|
||||
steps.comment_filter.outputs.is_command == 'true' &&
|
||||
env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true'
|
||||
}}
|
||||
if: ${{ github.event_name == 'issue_comment' && env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true' }}
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
with:
|
||||
client-id: ${{ env.CLAWSWEEPER_APP_CLIENT_ID }}
|
||||
@@ -231,11 +213,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Acknowledge and dispatch ClawSweeper comment
|
||||
if: >-
|
||||
${{
|
||||
github.event_name == 'issue_comment' &&
|
||||
steps.comment_filter.outputs.is_command == 'true'
|
||||
}}
|
||||
if: ${{ github.event_name == 'issue_comment' }}
|
||||
env:
|
||||
DISPATCH_TOKEN: ${{ steps.token.outputs.token }}
|
||||
TARGET_TOKEN: ${{ steps.target_token.outputs.token }}
|
||||
@@ -254,6 +232,10 @@ jobs:
|
||||
. "$RUNNER_TEMP/github-api-backoff.sh"
|
||||
body_file="$RUNNER_TEMP/clawsweeper-comment-body.txt"
|
||||
printf '%s\n' "$COMMENT_BODY" > "$body_file"
|
||||
if ! grep -Eiq '(^|[[:space:]])@(clawsweeper|openclaw-clawsweeper)\b(\[bot\])?|(^|[[:space:]])/(clawsweeper|review|automerge|autoclose)\b' "$body_file"; then
|
||||
echo "No ClawSweeper command found in comment."
|
||||
exit 0
|
||||
fi
|
||||
if [ -n "$TARGET_TOKEN" ]; then
|
||||
err="$(mktemp)"
|
||||
if GH_TOKEN="$TARGET_TOKEN" gh_api_with_retry -X POST \
|
||||
|
||||
@@ -96,7 +96,7 @@ on:
|
||||
- "src/auto-reply/reply/post-compaction-context.ts"
|
||||
- "src/auto-reply/reply/queue/**"
|
||||
- "src/auto-reply/reply/startup-context.ts"
|
||||
- "src/commands/doctor/cron/dreaming-payload-migration.ts"
|
||||
- "src/commands/doctor-cron-dreaming-payload-migration.ts"
|
||||
- "src/commands/doctor-memory-search.ts"
|
||||
- "src/commands/doctor-session-*.ts"
|
||||
- "src/commands/session-store-targets.ts"
|
||||
@@ -257,7 +257,7 @@ jobs:
|
||||
packages/gateway-protocol/src/*|packages/gateway-protocol/src/**/*|src/gateway/method-scopes.ts|src/gateway/server-methods/*|src/gateway/server-methods.ts|src/gateway/server-methods-list.ts)
|
||||
gateway=true
|
||||
;;
|
||||
packages/memory-host-sdk/*|src/commands/doctor/cron/dreaming-payload-migration.ts|src/commands/doctor-memory-search.ts|src/gateway/server-startup-memory.ts|src/memory/*|src/memory-host-sdk/*)
|
||||
packages/memory-host-sdk/*|src/commands/doctor-cron-dreaming-payload-migration.ts|src/commands/doctor-memory-search.ts|src/gateway/server-startup-memory.ts|src/memory/*|src/memory-host-sdk/*)
|
||||
memory=true
|
||||
;;
|
||||
src/infra/outbound/base-session-key.ts|src/infra/outbound/delivery-queue*.ts|src/infra/outbound/outbound-session.ts|src/infra/outbound/session-binding*.ts|src/infra/outbound/session-context.ts|src/infra/outbound/targets-session.ts)
|
||||
@@ -295,7 +295,7 @@ jobs:
|
||||
src/model-catalog/*|src/plugins/*provider*.ts|src/plugins/capability-provider-runtime.ts|src/plugins/compaction-provider.ts|src/plugins/memory-embedding-provider*.ts|src/plugins/memory-embedding-providers*.ts|src/plugins/migration-provider-runtime.ts|src/plugins/synthetic-auth.runtime.ts|src/plugins/web-fetch-providers*.ts|src/plugins/web-search-providers*.ts)
|
||||
provider=true
|
||||
;;
|
||||
src/plugins/activation-planner.ts|src/plugins/api-builder.ts|src/plugins/bundled-*.ts|src/plugins/captured-registration.ts|src/plugins/config-*.ts|src/plugins/discovery.ts|src/plugins/effective-plugin-ids.ts|src/plugins/externalized-bundled-plugins.ts|src/plugins/installed-plugin-index*.ts|src/plugins/loader*.ts|src/plugins/manifest*.ts|src/plugins/module-export.ts|src/plugins/package-entrypoints.ts|src/plugins/plugin-registry*.ts|src/plugins/public-surface*.ts|src/plugins/registry.ts|src/plugins/registry-types.ts|src/plugins/runtime|src/plugins/runtime/*|src/plugins/runtime-state.ts|src/plugins/runtime.ts|src/plugins/sdk-alias.ts|src/plugins/types.ts|src/plugins/validation-diagnostics.ts)
|
||||
src/plugins/activation-planner.ts|src/plugins/api-builder.ts|src/plugins/bundled-*.ts|src/plugins/captured-registration.ts|src/plugins/config-*.ts|src/plugins/discovery.ts|src/plugins/effective-plugin-ids.ts|src/plugins/externalized-bundled-plugins.ts|src/plugins/installed-plugin-index*.ts|src/plugins/loader*.ts|src/plugins/manifest*.ts|src/plugins/module-export.ts|src/plugins/package-entrypoints.ts|src/plugins/plugin-registry*.ts|src/plugins/public-surface*.ts|src/plugins/registry.ts|src/plugins/registry-types.ts|src/plugins/runtime|src/plugins/runtime/*|src/plugins/runtime-state.ts|src/plugins/runtime.ts|src/plugins/sdk-alias.ts|src/plugins/source-loader.ts|src/plugins/types.ts|src/plugins/validation-diagnostics.ts)
|
||||
plugin=true
|
||||
;;
|
||||
packages/plugin-package-contract/*|packages/plugin-sdk/*)
|
||||
|
||||
18
.github/workflows/codeql.yml
vendored
18
.github/workflows/codeql.yml
vendored
@@ -22,6 +22,12 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- ".github/actions/**"
|
||||
- ".github/codeql/**"
|
||||
- ".github/workflows/**"
|
||||
- "packages/**"
|
||||
- "src/**"
|
||||
schedule:
|
||||
- cron: "0 6 * * *"
|
||||
|
||||
@@ -49,32 +55,32 @@ jobs:
|
||||
include:
|
||||
- language: javascript-typescript
|
||||
category: core-auth-secrets
|
||||
runs_on: ubuntu-24.04
|
||||
runs_on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout_minutes: 25
|
||||
config_file: ./.github/codeql/codeql-core-auth-secrets-critical-security.yml
|
||||
- language: javascript-typescript
|
||||
category: channel-runtime-boundary
|
||||
runs_on: ubuntu-24.04
|
||||
runs_on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout_minutes: 25
|
||||
config_file: ./.github/codeql/codeql-channel-runtime-boundary-critical-security.yml
|
||||
- language: javascript-typescript
|
||||
category: network-ssrf-boundary
|
||||
runs_on: ubuntu-24.04
|
||||
runs_on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout_minutes: 25
|
||||
config_file: ./.github/codeql/codeql-network-ssrf-boundary-critical-security.yml
|
||||
- language: javascript-typescript
|
||||
category: mcp-process-tool-boundary
|
||||
runs_on: ubuntu-24.04
|
||||
runs_on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout_minutes: 25
|
||||
config_file: ./.github/codeql/codeql-mcp-process-tool-boundary-critical-security.yml
|
||||
- language: javascript-typescript
|
||||
category: plugin-trust-boundary
|
||||
runs_on: ubuntu-24.04
|
||||
runs_on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout_minutes: 25
|
||||
config_file: ./.github/codeql/codeql-plugin-trust-boundary-critical-security.yml
|
||||
- language: actions
|
||||
category: actions
|
||||
runs_on: ubuntu-24.04
|
||||
runs_on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout_minutes: 10
|
||||
config_file: ./.github/codeql/codeql-actions-critical-security.yml
|
||||
steps:
|
||||
|
||||
107
.github/workflows/full-release-validation.yml
vendored
107
.github/workflows/full-release-validation.yml
vendored
@@ -70,7 +70,7 @@ on:
|
||||
default: ""
|
||||
type: string
|
||||
npm_telegram_package_spec:
|
||||
description: Optional published package spec for the focused package Telegram E2E rerun
|
||||
description: Optional published package spec for the package Telegram E2E lane
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
@@ -95,7 +95,7 @@ on:
|
||||
default: ""
|
||||
type: string
|
||||
npm_telegram_provider_mode:
|
||||
description: Provider mode for the focused package Telegram E2E rerun
|
||||
description: Provider mode for the package Telegram E2E lane
|
||||
required: false
|
||||
default: mock-openai
|
||||
type: choice
|
||||
@@ -103,7 +103,7 @@ on:
|
||||
- mock-openai
|
||||
- live-frontier
|
||||
npm_telegram_scenario:
|
||||
description: Optional comma-separated Telegram scenario ids for the focused package Telegram E2E rerun
|
||||
description: Optional comma-separated Telegram scenario ids for the package Telegram lane
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
@@ -200,16 +200,14 @@ jobs:
|
||||
if [[ -n "${RELEASE_PACKAGE_SPEC// }" ]]; then
|
||||
echo "- Published release package: \`${RELEASE_PACKAGE_SPEC}\`"
|
||||
fi
|
||||
if [[ "$RERUN_GROUP" == "npm-telegram" && -n "${NPM_TELEGRAM_PACKAGE_SPEC// }" ]]; then
|
||||
if [[ -n "${NPM_TELEGRAM_PACKAGE_SPEC// }" ]]; then
|
||||
echo "- Published-package Telegram E2E: \`${NPM_TELEGRAM_PACKAGE_SPEC}\`"
|
||||
elif [[ "$RERUN_GROUP" == "npm-telegram" && -n "${RELEASE_PACKAGE_SPEC// }" ]]; then
|
||||
elif [[ -n "${RELEASE_PACKAGE_SPEC// }" ]]; then
|
||||
echo "- Published-package Telegram E2E: \`${RELEASE_PACKAGE_SPEC}\`"
|
||||
elif [[ "$RERUN_GROUP" == "npm-telegram" ]]; then
|
||||
echo "- Package Telegram E2E: focused rerun requires \`release_package_spec\` or \`npm_telegram_package_spec\`"
|
||||
elif [[ "$RERUN_GROUP" == "all" || "$RERUN_GROUP" == "release-checks" || "$RERUN_GROUP" == "package" ]]; then
|
||||
echo "- Package Telegram E2E: OpenClaw Release Checks Package Acceptance"
|
||||
elif [[ "$RERUN_GROUP" == "all" && "$RELEASE_PROFILE" == "full" ]]; then
|
||||
echo "- Package Telegram E2E: parent \`release-package-under-test\` artifact"
|
||||
else
|
||||
echo "- Package Telegram E2E: skipped by rerun group"
|
||||
echo "- Package Telegram E2E: skipped unless \`release_profile=full\`, \`release_package_spec\`, or \`npm_telegram_package_spec\` is provided"
|
||||
fi
|
||||
if [[ -n "${EVIDENCE_PACKAGE_SPEC// }" ]]; then
|
||||
echo "- Private evidence package proof: \`${EVIDENCE_PACKAGE_SPEC}\`"
|
||||
@@ -766,10 +764,80 @@ jobs:
|
||||
|
||||
dispatch_and_wait openclaw-release-checks.yml "${args[@]}"
|
||||
|
||||
prepare_release_package:
|
||||
name: Prepare release package artifact
|
||||
needs: [resolve_target, docker_runtime_assets_preflight]
|
||||
if: ${{ always() && needs.resolve_target.result == 'success' && inputs.npm_telegram_package_spec == '' && inputs.release_package_spec == '' && inputs.rerun_group == 'all' && inputs.release_profile == 'full' && needs.docker_runtime_assets_preflight.result == 'success' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
outputs:
|
||||
artifact_name: ${{ steps.artifact.outputs.name }}
|
||||
package_sha256: ${{ steps.package.outputs.sha256 }}
|
||||
package_version: ${{ steps.package.outputs.package_version }}
|
||||
source_sha: ${{ steps.package.outputs.source_sha }}
|
||||
steps:
|
||||
- name: Checkout trusted workflow ref
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
persist-credentials: true
|
||||
ref: ${{ github.ref_name }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set artifact metadata
|
||||
id: artifact
|
||||
run: echo "name=release-package-under-test" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
install-bun: "true"
|
||||
install-deps: "false"
|
||||
|
||||
- name: Resolve release package artifact
|
||||
id: package
|
||||
shell: bash
|
||||
env:
|
||||
PACKAGE_REF: ${{ needs.resolve_target.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node scripts/resolve-openclaw-package-candidate.mjs \
|
||||
--source ref \
|
||||
--package-ref "$PACKAGE_REF" \
|
||||
--output-dir .artifacts/docker-e2e-package \
|
||||
--output-name openclaw-current.tgz \
|
||||
--metadata .artifacts/docker-e2e-package/package-candidate.json \
|
||||
--github-output "$GITHUB_OUTPUT"
|
||||
digest="$(node -p "JSON.parse(require('fs').readFileSync('.artifacts/docker-e2e-package/package-candidate.json', 'utf8')).sha256")"
|
||||
version="$(node -p "JSON.parse(require('fs').readFileSync('.artifacts/docker-e2e-package/package-candidate.json', 'utf8')).version")"
|
||||
source_sha="$(node -p "JSON.parse(require('fs').readFileSync('.artifacts/docker-e2e-package/package-candidate.json', 'utf8')).packageSourceSha")"
|
||||
echo "source_sha=$source_sha" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "## Release package artifact"
|
||||
echo
|
||||
echo "- Artifact: \`release-package-under-test\`"
|
||||
echo "- Package ref: \`$PACKAGE_REF\`"
|
||||
echo "- SHA-256: \`$digest\`"
|
||||
echo "- Version: \`$version\`"
|
||||
echo "- Source SHA: \`$source_sha\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload release package artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: release-package-under-test
|
||||
path: |
|
||||
.artifacts/docker-e2e-package/openclaw-current.tgz
|
||||
.artifacts/docker-e2e-package/package-candidate.json
|
||||
if-no-files-found: error
|
||||
|
||||
npm_telegram:
|
||||
name: Run package Telegram E2E
|
||||
needs: [resolve_target]
|
||||
if: ${{ always() && needs.resolve_target.result == 'success' && inputs.rerun_group == 'npm-telegram' && (inputs.npm_telegram_package_spec != '' || inputs.release_package_spec != '') }}
|
||||
needs: [resolve_target, prepare_release_package]
|
||||
if: ${{ always() && contains(fromJSON('["all","npm-telegram"]'), inputs.rerun_group) && (inputs.npm_telegram_package_spec != '' || inputs.release_package_spec != '' || (inputs.rerun_group == 'all' && inputs.release_profile == 'full')) }}
|
||||
continue-on-error: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: ${{ inputs.release_profile == 'full' && 360 || 60 }}
|
||||
@@ -785,6 +853,8 @@ jobs:
|
||||
CHILD_WORKFLOW_REF: ${{ github.ref_name }}
|
||||
TARGET_SHA: ${{ needs.resolve_target.outputs.sha }}
|
||||
PACKAGE_SPEC: ${{ inputs.npm_telegram_package_spec || inputs.release_package_spec }}
|
||||
PACKAGE_ARTIFACT_NAME: ${{ needs.prepare_release_package.outputs.artifact_name }}
|
||||
PREPARE_PACKAGE_RESULT: ${{ needs.prepare_release_package.result }}
|
||||
PROVIDER_MODE: ${{ inputs.npm_telegram_provider_mode }}
|
||||
SCENARIO: ${{ inputs.npm_telegram_scenario }}
|
||||
run: |
|
||||
@@ -813,7 +883,18 @@ jobs:
|
||||
return "$status"
|
||||
}
|
||||
|
||||
args=(-f package_spec="$PACKAGE_SPEC" -f harness_ref="$TARGET_SHA" -f provider_mode="$PROVIDER_MODE")
|
||||
args=(-f package_spec="${PACKAGE_SPEC:-openclaw@beta}" -f harness_ref="$TARGET_SHA" -f provider_mode="$PROVIDER_MODE")
|
||||
if [[ -z "${PACKAGE_SPEC// }" ]]; then
|
||||
if [[ "$PREPARE_PACKAGE_RESULT" != "success" || -z "${PACKAGE_ARTIFACT_NAME// }" ]]; then
|
||||
echo "Full release Telegram requires either npm_telegram_package_spec or a prepared release-package-under-test artifact." >&2
|
||||
exit 1
|
||||
fi
|
||||
args+=(
|
||||
-f package_artifact_name="$PACKAGE_ARTIFACT_NAME"
|
||||
-f package_artifact_run_id="${GITHUB_RUN_ID}"
|
||||
-f package_label="full-release-${TARGET_SHA:0:12}"
|
||||
)
|
||||
fi
|
||||
if [[ -n "${SCENARIO// }" ]]; then
|
||||
args+=(-f scenario="$SCENARIO")
|
||||
fi
|
||||
|
||||
63
.github/workflows/ios-periphery-comment.yml
vendored
63
.github/workflows/ios-periphery-comment.yml
vendored
@@ -27,8 +27,10 @@ jobs:
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
script: |
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const zlib = require("node:zlib");
|
||||
const childProcess = require("node:child_process");
|
||||
|
||||
const marker = "<!-- openclaw-ios-periphery-dead-code -->";
|
||||
const run = context.payload.workflow_run;
|
||||
@@ -124,7 +126,10 @@ jobs:
|
||||
archive_format: "zip",
|
||||
});
|
||||
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ios-periphery-"));
|
||||
const archivePath = path.join(dir, "artifact.zip");
|
||||
const archiveBuffer = Buffer.from(archive.data);
|
||||
fs.writeFileSync(archivePath, archiveBuffer);
|
||||
|
||||
const allowedArtifactFiles = new Set([
|
||||
"periphery.json",
|
||||
@@ -235,59 +240,19 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
entries.set(name, {
|
||||
compressedSize,
|
||||
compressionMethod,
|
||||
localHeaderOffset: readUInt32(offset + 42),
|
||||
uncompressedSize,
|
||||
});
|
||||
entries.set(name, { uncompressedSize });
|
||||
offset = nextOffset;
|
||||
}
|
||||
|
||||
const readZipEntry = (name, entry) => {
|
||||
const localHeaderOffset = entry.localHeaderOffset;
|
||||
if (
|
||||
localHeaderOffset + 30 > archiveBuffer.length ||
|
||||
readUInt32(localHeaderOffset) !== 0x04034b50
|
||||
) {
|
||||
throw new Error(`${name} has an invalid local header.`);
|
||||
}
|
||||
|
||||
const localNameLength = readUInt16(localHeaderOffset + 26);
|
||||
const localExtraLength = readUInt16(localHeaderOffset + 28);
|
||||
const dataStart = localHeaderOffset + 30 + localNameLength + localExtraLength;
|
||||
const dataEnd = dataStart + entry.compressedSize;
|
||||
if (dataEnd > archiveBuffer.length) {
|
||||
throw new Error(`${name} exceeds archive bounds.`);
|
||||
}
|
||||
|
||||
const compressed = archiveBuffer.subarray(dataStart, dataEnd);
|
||||
let contents;
|
||||
if (entry.compressionMethod === 0) {
|
||||
contents = compressed;
|
||||
} else {
|
||||
try {
|
||||
contents = zlib.inflateRawSync(compressed, { maxOutputLength: maxEntryBytes });
|
||||
} catch (error) {
|
||||
if (error && error.code === "ERR_BUFFER_TOO_LARGE") {
|
||||
throw new Error(`${name} exceeded the per-file size limit while reading.`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (contents.length !== entry.uncompressedSize || contents.length > maxEntryBytes) {
|
||||
throw new Error(`${name} exceeded the per-file size limit while reading.`);
|
||||
}
|
||||
return contents.toString("utf8");
|
||||
};
|
||||
|
||||
const files = new Map();
|
||||
for (const [name, entry] of entries) {
|
||||
let contents;
|
||||
try {
|
||||
contents = readZipEntry(name, entry);
|
||||
} catch (error) {
|
||||
core.warning(`Skipping ${artifactName}; ${error instanceof Error ? error.message : String(error)}`);
|
||||
const contents = childProcess.execFileSync("unzip", ["-p", archivePath, name], {
|
||||
encoding: "utf8",
|
||||
maxBuffer: Math.max(1, entry.uncompressedSize + 1024),
|
||||
timeout: 5000,
|
||||
});
|
||||
if (Buffer.byteLength(contents, "utf8") > maxEntryBytes) {
|
||||
core.warning(`Skipping ${artifactName}; ${name} exceeded the per-file size limit while reading.`);
|
||||
return;
|
||||
}
|
||||
files.set(name, contents);
|
||||
|
||||
2
.github/workflows/ios-periphery.yml
vendored
2
.github/workflows/ios-periphery.yml
vendored
@@ -220,7 +220,7 @@ jobs:
|
||||
with:
|
||||
name: ios-periphery-dead-code-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/ios-periphery
|
||||
if-no-files-found: error
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
- name: Fail on dead code
|
||||
|
||||
2
.github/workflows/mantis-discord-smoke.yml
vendored
2
.github/workflows/mantis-discord-smoke.yml
vendored
@@ -171,4 +171,4 @@ jobs:
|
||||
name: mantis-discord-smoke-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: .artifacts/qa-e2e/mantis/
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
if-no-files-found: warn
|
||||
|
||||
@@ -540,7 +540,7 @@ jobs:
|
||||
name: mantis-discord-status-reactions-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_mantis.outputs.output_dir }}
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Create Mantis GitHub App token
|
||||
id: mantis_app_token
|
||||
|
||||
@@ -547,7 +547,7 @@ jobs:
|
||||
with:
|
||||
name: mantis-discord-thread-attachment-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_mantis.outputs.output_dir }}
|
||||
if-no-files-found: error
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
- name: Create Mantis GitHub App token
|
||||
|
||||
@@ -458,7 +458,7 @@ jobs:
|
||||
name: mantis-slack-desktop-smoke-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_mantis.outputs.output_dir }}
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Create Mantis GitHub App token
|
||||
id: mantis_app_token
|
||||
|
||||
@@ -556,7 +556,7 @@ jobs:
|
||||
name: mantis-telegram-desktop-proof-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.inspect.outputs.output_dir }}
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Create Mantis GitHub App token
|
||||
id: mantis_app_token
|
||||
|
||||
2
.github/workflows/mantis-telegram-live.yml
vendored
2
.github/workflows/mantis-telegram-live.yml
vendored
@@ -506,7 +506,7 @@ jobs:
|
||||
name: mantis-telegram-live-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_mantis.outputs.output_dir }}
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Create Mantis GitHub App token
|
||||
id: mantis_app_token
|
||||
|
||||
358
.github/workflows/maturity-scorecard.yml
vendored
358
.github/workflows/maturity-scorecard.yml
vendored
@@ -1,358 +0,0 @@
|
||||
name: Maturity scorecard
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
qa_evidence_run_id:
|
||||
description: Optional workflow run id containing qa-evidence.json
|
||||
required: false
|
||||
type: string
|
||||
ref:
|
||||
description: OpenClaw branch, tag, or SHA containing the maturity score source
|
||||
required: true
|
||||
default: main
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ format('{0}-{1}-{2}', github.workflow, inputs.ref, inputs.qa_evidence_run_id || github.run_id) }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.x"
|
||||
|
||||
jobs:
|
||||
validate_selected_ref:
|
||||
name: Validate selected ref
|
||||
runs-on: ubuntu-24.04
|
||||
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:
|
||||
INPUT_REF: ${{ inputs.ref }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
selected_revision="$(git rev-parse HEAD)"
|
||||
trusted_reason=""
|
||||
|
||||
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 maturity scorecard 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"
|
||||
|
||||
generate_qa_evidence:
|
||||
name: Generate full taxonomy QA evidence
|
||||
needs: validate_selected_ref
|
||||
if: ${{ inputs.qa_evidence_run_id == '' }}
|
||||
uses: ./.github/workflows/qa-profile-evidence.yml
|
||||
with:
|
||||
ref: ${{ needs.validate_selected_ref.outputs.selected_revision }}
|
||||
qa_profile: all
|
||||
secrets:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
publish:
|
||||
name: Publish maturity docs PR
|
||||
needs:
|
||||
- validate_selected_ref
|
||||
- generate_qa_evidence
|
||||
if: ${{ always() && needs.validate_selected_ref.result == 'success' && (inputs.qa_evidence_run_id != '' || needs.generate_qa_evidence.result == 'success') }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
ref: ${{ needs.validate_selected_ref.outputs.selected_revision }}
|
||||
fetch-depth: 1
|
||||
fetch-tags: false
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
install-bun: "false"
|
||||
|
||||
- name: Download provided QA evidence artifact
|
||||
if: ${{ inputs.qa_evidence_run_id != '' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
QA_EVIDENCE_RUN_ID: ${{ inputs.qa_evidence_run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p .artifacts/maturity-evidence
|
||||
gh run download "$QA_EVIDENCE_RUN_ID" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--dir .artifacts/maturity-evidence
|
||||
|
||||
- name: Download generated QA evidence artifact
|
||||
if: ${{ inputs.qa_evidence_run_id == '' }}
|
||||
env:
|
||||
GENERATED_ARTIFACT_NAME: ${{ needs.generate_qa_evidence.outputs.artifact_name }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${GENERATED_ARTIFACT_NAME:-}" ]]; then
|
||||
echo "Generated QA evidence workflow did not expose an artifact name." >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p .artifacts/maturity-evidence
|
||||
gh run download "$GITHUB_RUN_ID" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--name "$GENERATED_ARTIFACT_NAME" \
|
||||
--dir .artifacts/maturity-evidence
|
||||
|
||||
- name: Require one QA evidence file
|
||||
id: evidence
|
||||
env:
|
||||
QA_EVIDENCE_RUN_ID: ${{ inputs.qa_evidence_run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mapfile -t evidence_paths < <(find .artifacts/maturity-evidence -type f -name qa-evidence.json | sort)
|
||||
if [[ "${#evidence_paths[@]}" -eq 0 ]]; then
|
||||
echo "Expected a qa-evidence.json file in the downloaded QA evidence artifact." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${#evidence_paths[@]}" -gt 1 ]]; then
|
||||
echo "Expected exactly one qa-evidence.json file, found ${#evidence_paths[@]}:" >&2
|
||||
printf '%s\n' "${evidence_paths[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "qa_evidence_path=${evidence_paths[0]}" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "### QA evidence"
|
||||
echo
|
||||
echo "- Evidence path: \`${evidence_paths[0]}\`"
|
||||
echo "- Evidence source run: \`${QA_EVIDENCE_RUN_ID:-$GITHUB_RUN_ID}\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Validate QA evidence manifest
|
||||
env:
|
||||
QA_EVIDENCE_PATH: ${{ steps.evidence.outputs.qa_evidence_path }}
|
||||
TARGET_SHA: ${{ needs.validate_selected_ref.outputs.selected_revision }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node --input-type=module <<'NODE'
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const evidencePath = process.env.QA_EVIDENCE_PATH;
|
||||
const targetSha = process.env.TARGET_SHA;
|
||||
if (!evidencePath) {
|
||||
throw new Error("QA_EVIDENCE_PATH is required");
|
||||
}
|
||||
if (!targetSha) {
|
||||
throw new Error("TARGET_SHA is required");
|
||||
}
|
||||
|
||||
const evidence = JSON.parse(fs.readFileSync(evidencePath, "utf8"));
|
||||
if (evidence.profile !== "all") {
|
||||
throw new Error(`qa-evidence.json profile must be all, got ${JSON.stringify(evidence.profile)}`);
|
||||
}
|
||||
|
||||
const artifactDir = path.dirname(evidencePath);
|
||||
const manifestNames = fs
|
||||
.readdirSync(artifactDir)
|
||||
.filter((name) => name.endsWith("qa-profile-evidence-manifest.json"))
|
||||
.sort();
|
||||
if (manifestNames.length !== 1) {
|
||||
throw new Error(
|
||||
`Expected exactly one QA profile evidence manifest next to qa-evidence.json, found ${manifestNames.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
const manifestPath = path.join(artifactDir, manifestNames[0]);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
const manifestProfile = manifest.qaProfile ?? evidence.profile;
|
||||
if (manifestProfile !== "all") {
|
||||
throw new Error(`QA evidence manifest profile must be all, got ${JSON.stringify(manifestProfile)}`);
|
||||
}
|
||||
if (manifest.targetSha !== targetSha) {
|
||||
throw new Error(`QA evidence manifest targetSha ${manifest.targetSha} does not match selected ref ${targetSha}`);
|
||||
}
|
||||
NODE
|
||||
|
||||
- name: Validate maturity score sources
|
||||
run: |
|
||||
node --import tsx --input-type=module <<'NODE'
|
||||
import { readValidatedQaMaturityScoreSources } from "./extensions/qa-lab/src/scorecard-taxonomy.ts";
|
||||
|
||||
const { warnings } = readValidatedQaMaturityScoreSources({
|
||||
scoresPath: "qa/maturity-scores.yaml",
|
||||
taxonomyPath: "taxonomy.yaml",
|
||||
});
|
||||
for (const warning of warnings) {
|
||||
console.error(`warning: ${warning}`);
|
||||
}
|
||||
NODE
|
||||
|
||||
- name: Render artifact docs
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pnpm maturity:render -- \
|
||||
--output-dir .artifacts/maturity-docs \
|
||||
--static-assets-dir .artifacts/maturity-docs/assets/maturity \
|
||||
--scores qa/maturity-scores.yaml \
|
||||
--evidence-dir .artifacts/maturity-evidence \
|
||||
--strict-inputs
|
||||
{
|
||||
echo "### Maturity scorecard docs"
|
||||
echo
|
||||
echo "- Source validation: passed"
|
||||
echo "- Artifact docs: \`.artifacts/maturity-docs\`"
|
||||
echo "- Strict inputs: \`true\`"
|
||||
echo "- QA evidence: included"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Render committed docs preview
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pnpm maturity:render -- \
|
||||
--output-dir docs \
|
||||
--scores qa/maturity-scores.yaml \
|
||||
--evidence-dir .artifacts/maturity-evidence \
|
||||
--strict-inputs
|
||||
|
||||
- name: Create generated docs PR app token
|
||||
id: app-token
|
||||
continue-on-error: true
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
permission-contents: write
|
||||
permission-pull-requests: write
|
||||
|
||||
- name: Create generated docs PR fallback app token
|
||||
if: ${{ steps.app-token.outcome == 'failure' }}
|
||||
id: app-token-fallback
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
|
||||
with:
|
||||
app-id: "2971289"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
|
||||
permission-contents: write
|
||||
permission-pull-requests: write
|
||||
|
||||
- name: Open generated docs PR
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
QA_EVIDENCE_RUN_ID: ${{ inputs.qa_evidence_run_id }}
|
||||
REF_INPUT: ${{ inputs.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${GH_TOKEN:-}" ]]; then
|
||||
echo "Maturity scorecard PR creation requires the OpenClaw GitHub App token secrets." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$(git status --porcelain -- qa/maturity-scores.yaml docs/maturity-scores.yaml docs/maturity/scorecard.md docs/maturity/taxonomy.md)" ]]; then
|
||||
{
|
||||
echo
|
||||
echo "- Pull request: skipped; generated scorecard matches selected ref"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
evidence_run_id="${QA_EVIDENCE_RUN_ID:-$GITHUB_RUN_ID}"
|
||||
branch="automation/maturity-scorecard-${evidence_run_id}"
|
||||
base_branch="${REF_INPUT:-main}"
|
||||
if ! git ls-remote --exit-code --heads origin "$base_branch" >/dev/null 2>&1; then
|
||||
base_branch="main"
|
||||
fi
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
gh auth setup-git
|
||||
git fetch --no-tags --depth=1 origin "refs/heads/${branch}:refs/remotes/origin/${branch}" || true
|
||||
git switch -C "$branch"
|
||||
git add qa/maturity-scores.yaml docs/maturity/scorecard.md docs/maturity/taxonomy.md
|
||||
if git ls-files --error-unmatch docs/maturity-scores.yaml >/dev/null 2>&1 || [[ -e docs/maturity-scores.yaml ]]; then
|
||||
git add docs/maturity-scores.yaml
|
||||
fi
|
||||
git commit -m "docs: update maturity scorecard"
|
||||
git push --force-with-lease origin "$branch"
|
||||
|
||||
body_file=".artifacts/maturity-scorecard-pr-body.md"
|
||||
mkdir -p "$(dirname "$body_file")"
|
||||
cat > "$body_file" <<BODY
|
||||
## Summary
|
||||
|
||||
- render maturity scorecard docs from \`qa/maturity-scores.yaml\` and release QA evidence
|
||||
- maturity source ref: ${REF_INPUT}
|
||||
- QA evidence run: ${evidence_run_id}
|
||||
|
||||
## Verification
|
||||
|
||||
- QA Lab maturity score validation passed
|
||||
- Maturity scorecard workflow rendered docs from release profile qa-evidence.json artifacts with strict inputs
|
||||
BODY
|
||||
|
||||
pr_url="$(gh pr list --head "$branch" --state open --json url --jq '.[0].url // ""')"
|
||||
if [[ -n "$pr_url" ]]; then
|
||||
gh pr edit "$pr_url" \
|
||||
--title "docs: update maturity scorecard" \
|
||||
--body-file "$body_file"
|
||||
else
|
||||
pr_url="$(gh pr create \
|
||||
--base "$base_branch" \
|
||||
--head "$branch" \
|
||||
--title "docs: update maturity scorecard" \
|
||||
--body-file "$body_file")"
|
||||
fi
|
||||
{
|
||||
echo
|
||||
echo "- Pull request: ${pr_url}"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload maturity docs artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: maturity-scorecard-docs-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: .artifacts/maturity-docs/
|
||||
retention-days: 30
|
||||
if-no-files-found: error
|
||||
2
.github/workflows/npm-telegram-beta-e2e.yml
vendored
2
.github/workflows/npm-telegram-beta-e2e.yml
vendored
@@ -273,4 +273,4 @@ jobs:
|
||||
name: npm-telegram-beta-e2e-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: .artifacts/qa-e2e/
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
if-no-files-found: warn
|
||||
|
||||
@@ -717,6 +717,7 @@ jobs:
|
||||
published_upgrade_survivor_baselines: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'last-stable-4 2026.4.23 2026.5.2 2026.4.15' || '' }}
|
||||
published_upgrade_survivor_scenarios: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'reported-issues' || '' }}
|
||||
telegram_mode: mock-openai
|
||||
telegram_scenarios: telegram-help-command,telegram-commands-command,telegram-tools-compact-command,telegram-whoami-command,telegram-status-command,telegram-other-bot-command-gating,telegram-context-command,telegram-mentioned-message-reply,telegram-long-final-reuses-preview,telegram-mention-gating
|
||||
secrets:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
|
||||
@@ -244,11 +244,6 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$ROLLBACK_DRILL_ID" || -z "$ROLLBACK_DRILL_DATE" ]]; then
|
||||
if [[ "$EVENT_NAME" == "push" ]]; then
|
||||
echo "::warning::Stable closeout skipped: rollback drill repository variables are missing; manual dispatch remains required to complete closeout."
|
||||
echo "should_closeout=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "Stable closeout requires repository variables RELEASE_ROLLBACK_DRILL_ID and RELEASE_ROLLBACK_DRILL_DATE, or explicit manual overrides." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
2
.github/workflows/opengrep-precise-full.yml
vendored
2
.github/workflows/opengrep-precise-full.yml
vendored
@@ -66,5 +66,5 @@ jobs:
|
||||
with:
|
||||
name: opengrep-full-sarif
|
||||
path: .opengrep-out/precise.sarif
|
||||
if-no-files-found: error
|
||||
if-no-files-found: warn
|
||||
retention-days: 30
|
||||
|
||||
2
.github/workflows/opengrep-precise.yml
vendored
2
.github/workflows/opengrep-precise.yml
vendored
@@ -97,5 +97,5 @@ jobs:
|
||||
with:
|
||||
name: opengrep-pr-diff-sarif
|
||||
path: .opengrep-out/precise.sarif
|
||||
if-no-files-found: error
|
||||
if-no-files-found: warn
|
||||
retention-days: 30
|
||||
|
||||
16
.github/workflows/qa-live-transports-convex.yml
vendored
16
.github/workflows/qa-live-transports-convex.yml
vendored
@@ -226,7 +226,7 @@ jobs:
|
||||
name: qa-parity-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: .artifacts/qa-e2e/
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
if-no-files-found: warn
|
||||
|
||||
run_live_runtime_token_efficiency:
|
||||
name: Run live runtime token-efficiency lane
|
||||
@@ -315,7 +315,7 @@ jobs:
|
||||
name: qa-live-runtime-token-efficiency-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_lane.outputs.output_dir }}
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
if-no-files-found: warn
|
||||
|
||||
run_live_matrix:
|
||||
name: Run Matrix live QA lane
|
||||
@@ -391,7 +391,7 @@ jobs:
|
||||
name: qa-live-matrix-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_lane.outputs.output_dir }}
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
if-no-files-found: warn
|
||||
|
||||
run_live_matrix_sharded:
|
||||
name: Run Matrix live QA lane (${{ matrix.profile }})
|
||||
@@ -475,7 +475,7 @@ jobs:
|
||||
name: qa-live-matrix-${{ matrix.profile }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_lane.outputs.output_dir }}
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
if-no-files-found: warn
|
||||
|
||||
run_live_telegram:
|
||||
name: Run Telegram live QA lane with Convex leases
|
||||
@@ -570,7 +570,7 @@ jobs:
|
||||
name: qa-live-telegram-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_lane.outputs.output_dir }}
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
if-no-files-found: warn
|
||||
|
||||
run_live_discord:
|
||||
name: Run Discord live QA lane with Convex leases
|
||||
@@ -665,7 +665,7 @@ jobs:
|
||||
name: qa-live-discord-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_lane.outputs.output_dir }}
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
if-no-files-found: warn
|
||||
|
||||
run_live_whatsapp:
|
||||
name: Run WhatsApp live QA lane with Convex leases
|
||||
@@ -763,7 +763,7 @@ jobs:
|
||||
name: qa-live-whatsapp-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_lane.outputs.output_dir }}
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
if-no-files-found: warn
|
||||
|
||||
run_live_slack:
|
||||
name: Run Slack live QA lane with Convex leases
|
||||
@@ -859,4 +859,4 @@ jobs:
|
||||
name: qa-live-slack-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_lane.outputs.output_dir }}
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
if-no-files-found: warn
|
||||
|
||||
375
.github/workflows/qa-profile-evidence.yml
vendored
375
.github/workflows/qa-profile-evidence.yml
vendored
@@ -1,375 +0,0 @@
|
||||
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 (for example release or all)
|
||||
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
|
||||
secrets:
|
||||
OPENAI_API_KEY:
|
||||
description: OpenAI API key used by live QA profile scenarios
|
||||
required: true
|
||||
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 QA profile failed
|
||||
if: always()
|
||||
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,9 +24,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
# Old PR events can carry a stale base SHA that predates current
|
||||
# trusted checker scripts. Use the workflow revision instead.
|
||||
ref: ${{ github.workflow_sha }}
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
persist-credentials: false
|
||||
- uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
|
||||
id: app-token
|
||||
|
||||
42
.github/workflows/tui-pty.yml
vendored
Normal file
42
.github/workflows/tui-pty.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
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
|
||||
@@ -150,7 +150,6 @@ jobs:
|
||||
git --version
|
||||
|
||||
- name: Run Testbox
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
4
.github/workflows/windows-testbox-probe.yml
vendored
4
.github/workflows/windows-testbox-probe.yml
vendored
@@ -297,10 +297,6 @@ jobs:
|
||||
if: ${{ always() && !cancelled() && inputs.require_wsl2 }}
|
||||
run: |
|
||||
if ($env:OPENCLAW_WSL2_PROBE_OK -ne "true") {
|
||||
if ($env:OPENCLAW_WSL2_RESTART_REQUIRED -eq "true") {
|
||||
Write-Error "WSL2 probe enabled required Windows features, but the runner needs a reboot before WSL2 can start."
|
||||
exit 1
|
||||
}
|
||||
Write-Error "WSL2 probe failed or WSL2 is unavailable on this Windows runner."
|
||||
exit 1
|
||||
}
|
||||
|
||||
25
.github/workflows/workflow-sanity.yml
vendored
25
.github/workflows/workflow-sanity.yml
vendored
@@ -129,28 +129,11 @@ jobs:
|
||||
trusted_config="$RUNNER_TEMP/pre-commit-base.yaml"
|
||||
trusted_zizmor_config="$RUNNER_TEMP/zizmor-base.yml"
|
||||
|
||||
fetch_base_ref() {
|
||||
local ref="$1"
|
||||
local target="$2"
|
||||
local fetch_status
|
||||
for attempt in 1 2 3; do
|
||||
timeout --signal=TERM --kill-after=10s 30s git fetch --no-tags --depth=1 origin \
|
||||
"+${ref}:${target}" && return 0
|
||||
fetch_status="$?"
|
||||
if [ "$fetch_status" != "124" ] && [ "$fetch_status" != "137" ]; then
|
||||
return "$fetch_status"
|
||||
fi
|
||||
if [ "$attempt" = "3" ]; then
|
||||
return "$fetch_status"
|
||||
fi
|
||||
echo "::warning::trusted base fetch for '$ref' timed out on attempt $attempt; retrying"
|
||||
sleep 5
|
||||
done
|
||||
}
|
||||
|
||||
if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then
|
||||
fetch_base_ref "$BASE_SHA" "refs/remotes/origin/security-base" ||
|
||||
fetch_base_ref "refs/heads/${BASE_REF}" "refs/remotes/origin/${BASE_REF}"
|
||||
timeout --signal=TERM --kill-after=10s 30s git fetch --no-tags --depth=1 origin \
|
||||
"+${BASE_SHA}:refs/remotes/origin/security-base" ||
|
||||
timeout --signal=TERM --kill-after=10s 30s git fetch --no-tags --depth=1 origin \
|
||||
"+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}"
|
||||
fi
|
||||
|
||||
if git cat-file -e "${BASE_SHA}:.pre-commit-config.yaml" 2>/dev/null; then
|
||||
|
||||
@@ -8,7 +8,6 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Repo: `https://github.com/openclaw/openclaw`
|
||||
- Replies: repo-root refs only: `extensions/telegram/src/index.ts:80`. No absolute paths, no `~/`.
|
||||
- Docs/user-visible work: `pnpm docs:list`, then read relevant docs only.
|
||||
- Existing-solutions preflight: before proposing or building a custom system, feature, workflow, tool, integration, or automation, do a lightweight check for open-source projects, maintained libraries, existing OpenClaw plugins, or free platforms that already solve it well enough. Prefer those when adequate. Build custom only when existing options are unsuitable, too expensive, unmaintained, unsafe, non-compliant, or the user explicitly asks for custom. Avoid paid-service recommendations unless the user explicitly approves spend. Keep this to a brief preflight gate, not a broad research assignment.
|
||||
- Fix/triage answers need source, tests, current/shipped behavior, and dependency contract proof.
|
||||
- Reviews/answers: high confidence required. Default to exhaustive relevant codebase search/read, including owners, callers, siblings, tests, docs, and upstream/dependency contracts before verdict. Diff-only review is insufficient.
|
||||
- Review default: read the whole changed function/module plus callers, callees, sibling implementations, adjacent tests, scoped docs, and dependency/Codex contracts before saying `good`, `bad`, `best fix`, `proof sufficient`, or posting a comment. If challenged, keep reading first; do not defend the earlier verdict until the missing path is checked.
|
||||
|
||||
68
CHANGELOG.md
68
CHANGELOG.md
@@ -6,34 +6,34 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Highlights
|
||||
|
||||
- **Richer Telegram delivery:** Telegram now sends rich HTML, preserves rich markdown and sticker paths, renders progress drafts and command output more faithfully, normalizes HTML tables safely, and keeps mentions and spooled handlers on the right delivery path. (#93286, #93164, #93124, #93364, #93130, #93002, #93088, #93281, #94891, #94856) Thanks @obviyus, @vincentkoc, @goutamadwant, @kesslerio, @NianJiuZst, @SweetSophia, @Marvinthebored, @aaajiao, @zhangguiping-xydt, @zhangqueping, and @jairrab.
|
||||
- **Richer Telegram delivery:** Telegram now sends rich HTML, preserves rich markdown and sticker paths, renders progress drafts and command output more faithfully, and keeps mentions and spooled handlers on the right delivery path. (#93286, #93164, #93124, #93364, #93130, #93088, #93281) Thanks @obviyus, @vincentkoc, @goutamadwant, @kesslerio, @NianJiuZst, @SweetSophia, @Marvinthebored, and @aaajiao.
|
||||
- **More dependable agent recovery:** retries, terminal outcomes, usage after compaction, session history repair, and reply reconciliation now keep more interrupted or partial turns moving toward a visible final result. (#92191, #93073, #93228, #93084, #93469, #93291, #90943) Thanks @ai-hpc, @lml2468, @fuller-stack-dev, @Hollychou924, @leno23, @de1tydev, @425072024, @wuwahe3, @drvoss, @yetval, @sandieman2, and @vincentkoc.
|
||||
- **A stronger Codex integration:** Codex gains automatic plugin approvals, GPT-5.3 Spark OAuth routing, remote-node `exec` as a dynamic tool, and more reliable app-server teardown and terminal outcomes. (#92625, #89133, #93654, #91767, #93287) Thanks @kevinslin, @VACInc, @vincentkoc, @JPKay-AI, and @aliahnaf2013-max.
|
||||
- **Standalone official provider plugins:** external provider packages are now first-class npm releases, externally installed channel plugins load at Gateway startup, and StepFun is available from npm and ClawHub. (#93470) Thanks @sunlit-deng, @cxdnicole, and @vincentkoc.
|
||||
- **Standalone official provider plugins:** external provider packages are now first-class npm releases, externally installed channel plugins load at Gateway startup, and StepFun is intentionally npm-only because its ClawHub package name is unavailable. (#93470) Thanks @sunlit-deng, @cxdnicole, and @vincentkoc.
|
||||
- **More capable web and native clients:** the Control UI adds a session workspace rail and extension health, iOS adds Watch controls, and Android shows chat context. (#92856, #91952, #93387, #92837) Thanks @Solvely-Colin, @jalehman, @joshavant, and @Tosko4.
|
||||
- **More useful search and skills:** Codex Hosted Search is available, key-free search providers remain deliberate opt-ins, and ClawHub skill installs retain verified source provenance. (#93446, #93616, #93283, #93506) Thanks @fuller-stack-dev, @davemorin, @momothemage, @nmccready-tars, and @vincentkoc.
|
||||
|
||||
### Changes
|
||||
|
||||
- Providers and auth: add Codex Hosted Search, improve Gemini CLI OAuth behind proxies, and keep external provider onboarding on current choices and package metadata. (#93446, #92815) Thanks @fuller-stack-dev, @yetval, @EvetteYoung, and @vincentkoc.
|
||||
- Plugins and installs: externalized official providers publish as independent npm packages, Gateway discovers installed channel plugins at startup, and StepFun installs from npm or ClawHub. (#93470) Thanks @sunlit-deng, @cxdnicole, and @vincentkoc.
|
||||
- Plugins and installs: externalized official providers publish as independent npm packages, Gateway discovers installed channel plugins at startup, and StepFun installs exclusively from npm. (#93470) Thanks @sunlit-deng, @cxdnicole, and @vincentkoc.
|
||||
- Dashboard and mobile: add a session workspace rail, plugin health in status, compact cron lists, and iOS Watch controls. (#92856, #91952, #93395, #93387) Thanks @Solvely-Colin, @jalehman, @yu-xin-c, @centralpc, @joshavant, and @vincentkoc.
|
||||
- Codex, observability, and skills: add automatic plugin approvals and SecretRefs, preserve ClawHub skill provenance, add OpenTelemetry log export, and expose remote-node execution to Codex when a node is connected. (#92625, #94324, #93283, #94561, #93654) Thanks @kevinslin, @kevinlin-openai, @momothemage, @nmccready-tars, @jesse-merhi, @vincentkoc, and @JPKay-AI.
|
||||
- QA and release engineering: QA scenarios now use YAML, with broader profile evidence and release coverage for the plugin and channel matrix. Thanks @vincentkoc.
|
||||
- Codex and skills: add automatic plugin approvals, preserve ClawHub skill provenance, and expose remote-node execution to Codex when a node is connected. (#92625, #93283, #93654) Thanks @kevinslin, @momothemage, @nmccready-tars, @vincentkoc, and @JPKay-AI.
|
||||
- QA and release engineering: QA scenarios now use YAML, with broader profile evidence and release coverage for the plugin and channel matrix.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Security and privacy: redact secrets from debug/config output, block internal HTTP session overrides, audit open-DM tool exposure, and retain plugin write ownership checks. (#93333, #88496, #93443, #92883, #93353) Thanks @Alix-007, @jason-allen-oneal, @coygeek, @RichardCao, @yu-xin-c, @cjg20ss, @eleqtrizit, and @vincentkoc.
|
||||
- Agent and session runtime: retry thinking-only and empty post-tool turns, prevent duplicate hook execution, preserve pending subagent delivery, preserve fresh usage through compaction, and repair partial JSON/history artifacts. (#92191, #93073, #93009, #93084, #93469, #94349, #92383, #94257) Thanks @ai-hpc, @lml2468, @fuller-stack-dev, @zenglingbiao, @dertbv, @Hollychou924, @leno23, @de1tydev, @425072024, @wuwahe3, @drvoss, @vincentkoc, @sallyom, @oiGaDio, @Hidetsugu55, and @Nas01010101.
|
||||
- Channels and replies: fix Telegram rich delivery, table rendering, action-error handling, progress draft cleanup before visible tool output, and ingress recovery; preserve command progress detail across channel adapters; retain WhatsApp opening text after a media failure; keep Mattermost thread replies intact; and harden Discord action handling. (#93286, #93364, #93281, #93002, #93076, #93334, #93424, #93488, #94868, #94891, #94856, #94810, #93823) Thanks @obviyus, @NianJiuZst, @mcaxtr, @zhangguiping-xydt, @rushindrasinha, @amknight, @lzyyzznl, @darealgege, @vincentkoc, @zhangqueping, @jairrab, @ZOOWH, @parveshsaini, and @yetval.
|
||||
- Agent and session runtime: retry thinking-only and empty post-tool turns, prevent duplicate hook execution, preserve fresh usage through compaction, and repair partial JSON/history artifacts. (#92191, #93073, #93009, #93084, #93469) Thanks @ai-hpc, @lml2468, @fuller-stack-dev, @zenglingbiao, @dertbv, @Hollychou924, @leno23, @de1tydev, @425072024, @wuwahe3, @drvoss, and @vincentkoc.
|
||||
- Channels and replies: fix Telegram rich delivery and ingress recovery, preserve WhatsApp auth and media error reporting, keep Mattermost thread replies intact, and harden Discord action handling. (#93286, #93364, #93281, #93076, #93334, #93424, #93488) Thanks @obviyus, @NianJiuZst, @mcaxtr, @rushindrasinha, @amknight, @lzyyzznl, @darealgege, and @vincentkoc.
|
||||
- Storage and migrations: avoid SQLite WAL on network filesystems, clean reindex artifacts, keep setup state out of workspace dot-directories, and import default-agent auth profiles into SQLite. (#93454, #92891, #93182, #93295, #93520, #93156) Thanks @vincentkoc, @ZengWen-DT, @Zeng-wen, @potterdigital, @Alix-007, @Pick-cat, @sallyom, @1qh, and @Tazio7.
|
||||
- Provider and model behavior: fix Gemini CLI proxy OAuth, restore Codex Spark OAuth routing, correct Bedrock embedding model IDs, and preserve configured defaults in embedded runs. (#92815, #89133, #93452, #93428) Thanks @yetval, @EvetteYoung, @VACInc, @LiuwqGit, @aleck31, @zenglingbiao, @danielgerlag, and @vincentkoc.
|
||||
- CLI, TUI, and apps: accept global flags after subcommands, keep terminal output and activity indicators visible, preserve CJK IME composition, and refresh stale UI state. (#93455, #93460, #93006, #93427, #93498, #93606) Thanks @ooiuuii, @Alix-007, @ZengWen-DT, @Zeng-wen, @AlethiaQuizForge, @Zhaoqj2016, @liuhao1024, @BrianClaw1955, @vincentkoc, and @NicoBoom13.
|
||||
- Operations and updates: harden official plugin recovery, restart managed Gateways after failed update handoff, keep safe cron delivery defaults, avoid Node-specific npm prefixes, and keep package validation paths reliable. (#93325, #92111, #93650, #94453, #91685) Thanks @vincentkoc, @yetval, @ofan, @yaanfpv, @jincheng-xydt, @sallyom, @davectr, and @nxmxbbd.
|
||||
- Operations and updates: harden official plugin recovery, restart managed Gateways after failed update handoff, avoid Node-specific npm prefixes, and keep package validation paths reliable. (#93325, #92111, #93650) Thanks @vincentkoc, @yetval, @ofan, and @yaanfpv.
|
||||
|
||||
### Complete contribution record
|
||||
|
||||
This audited record covers the complete v2026.6.8..HEAD history: 423 merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.
|
||||
This audited record covers the complete v2026.6.8..HEAD~1 history: 375 merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.
|
||||
|
||||
#### Pull requests
|
||||
|
||||
@@ -57,7 +57,6 @@ This audited record covers the complete v2026.6.8..HEAD history: 423 merged PRs.
|
||||
- **PR #88792** fix(state): harden sqlite path caching. Thanks @vincentkoc.
|
||||
- **PR #93022** fix(gateway): repair usage cost aggregation across agents. Thanks @luke-skywalker-open-claw and @stablegenius49.
|
||||
- **PR #93020** fix(telegram): cool down transient sendChatAction failures. Related #56096. Thanks @Boulea7 and @sumaiazaman and @Pick-cat and @cal-rufus.
|
||||
- **PR #93002** fix(telegram): clear progress drafts before visible tool output. Thanks @zhangguiping-xydt.
|
||||
- **PR #89160** fix(agents): detect truncated API responses to prevent silent session hang. Related #89051. Thanks @joelnishanth and @ArthurusDent.
|
||||
- **PR #93009** fix(agents): make wrapToolWithBeforeToolCallHook idempotent to prevent double hook execution (fixes #92973). Thanks @zenglingbiao and @dertbv.
|
||||
- **PR #92991** fix(agents): tolerate missing attribution baseUrl. Related #92974. Thanks @samrusani and @Haderach-Ram.
|
||||
@@ -176,7 +175,7 @@ This audited record covers the complete v2026.6.8..HEAD history: 423 merged PRs.
|
||||
- **PR #90003** feat(policy): cover exec approvals artifact. Thanks @giodl73-repo.
|
||||
- **PR #93448** fix(guards): allow auth profile sqlite reader. Thanks @amknight.
|
||||
- **PR #93424** fix(mattermost): keep message tool replies in threads. Thanks @amknight and @vincentkoc.
|
||||
- **PR #93418** fix(telegram): forward Bot API 10.1 rich_message content to agent. Related #93410. Thanks @xzh-icenter and @vincentkoc and @0pen7ech.
|
||||
- **PR #93418** fix(telegram): forward Bot API 10.1 rich_message content to agent. Related #93410. Thanks @xzh-xydt and @vincentkoc and @0pen7ech.
|
||||
- **PR #93175** test(qa): taxonomy profiles: includeAllCategories for release profile, update some coverage. Thanks @RomneyDa.
|
||||
- **PR #93456** fix(agents): handle string assistant message content. Thanks @vincentkoc.
|
||||
- **PR #93441** fix(outbound): ignore schema-padded poll metadata on send. Related #43015. Thanks @weichengdeng and @charzhou.
|
||||
@@ -413,53 +412,6 @@ This audited record covers the complete v2026.6.8..HEAD history: 423 merged PRs.
|
||||
- **PR #94658** test(sqlite): use shared temp directory helper. Thanks @vincentkoc.
|
||||
- **PR #92135** fix(openai-embedding): preserve openai/ prefix for non-native base URLs. Related #92124. Thanks @xialonglee and @Kambrian.
|
||||
- **PR #93737** refactor: add session maintenance transaction seam. Thanks @jalehman.
|
||||
- **PR #93685** refactor(auto-reply): add lifecycle storage seams. Thanks @jalehman.
|
||||
- **PR #94349** fix(agents): preserve pending subagent completion announces. Related #93323. Thanks @sallyom and @oiGaDio.
|
||||
- **PR #93174** test: fold channel message flows into qa e2e. Thanks @RomneyDa.
|
||||
- **PR #94093** Prevent Codex thread rotation from losing next-step context. Thanks @VACInc.
|
||||
- **PR #53920** fix(scripts): avoid mutating tracked auth-monitor template during setup. Thanks @JackWuGlobal.
|
||||
- **PR #94702** Standardize QA coverage IDs on dotted names. Thanks @RomneyDa.
|
||||
- **PR #81825** fix(skills/1password): stop forcing tmux for desktop app auth (#52540). Thanks @koshaji and @tylerbittner.
|
||||
- **PR #94725** fix(doctor): warn on volatile SQLite state. Thanks @vincentkoc.
|
||||
- **PR #88551** fix(agents): skip auth gate for CLI-owned transport. Thanks @yu-xin-c.
|
||||
- **PR #88581** feat(commands): add /name to rename the current session from chat. Thanks @BSG2000.
|
||||
- **PR #94324** feat(codex): support app-server SecretRefs. Thanks @kevinlin-openai and @kevinslin.
|
||||
- **PR #90882** fix: add self-knowledge docs rule to system prompt. Related #90713. Thanks @SutraHsing.
|
||||
- **PR #94684** fix: #80507 show dry-run output for message send/poll. Thanks @lzyyzznl and @YB0y.
|
||||
- **PR #93823** fix(whatsapp): keep opening text chunk when first media fails on multi-chunk reply. Thanks @yetval.
|
||||
- **PR #89203** refactor: route SDK session compatibility through seam. Thanks @jalehman.
|
||||
- **PR #94453** fix: default cron runMode to "due" instead of "force" (#94270). Thanks @jincheng-xydt and @sallyom and @davectr.
|
||||
- **PR #94746** fix(note): prevent clack from re-breaking copy-sensitive tokens. Related #94730. Thanks @xzh-icenter and @berkgungor.
|
||||
- **PR #89904** refactor: route sdk session compatibility through accessor. Thanks @jalehman.
|
||||
- **PR #86719** fix(skills): retarget stale plugin skill symlinks. Related #85925. Thanks @stevenepalmer and @shakkernerd.
|
||||
- **PR #94337** fix(tui): show 0 not ? for fresh-session context tokens in footer. Thanks @mushuiyu886.
|
||||
- **PR #94539** fix(android): group settings by intent. Thanks @Tosko4.
|
||||
- **PR #92383** fix(gateway): never return an empty chat.history transcript. Thanks @Hidetsugu55.
|
||||
- **PR #92574** test(browser): cover action-input CLI request bodies. Related #83877. Thanks @yu-xin-c and @davinci282828.
|
||||
- **PR #92873** test(diffs): add viewerState, toolbar toggle, shadow root, and hydrateProps tests (fixes #83915). Thanks @liuhao1024 and @davinci282828.
|
||||
- **PR #94257** fix(sessions): preserve Media\* index alignment when reading user-turn fields. Thanks @Nas01010101.
|
||||
- **PR #94756** fix(codex): bound turn/start text when context budget is non-positive. Related #94748. Thanks @Nas01010101.
|
||||
- **PR #94729** fix(skills/trello): add curl to requires.bins to match body examples (fixes #94727). Thanks @liuhao1024 and @berkgungor.
|
||||
- **PR #94790** feat(slack): log INFO receipt for inbound app_mention events. Related #94691. Thanks @ZengWen-DT and @BryceMurray.
|
||||
- **PR #81696** fix: guard tool event callbacks (AI-assisted). Thanks @enjoylife1243.
|
||||
- **PR #94809** chore: forward-port alpha release fixes.
|
||||
- **PR #94612** fix(macos): open NSOpenPanel for embedded Control UI file inputs (#94468). Thanks @bbblending and @DINGDANGMAOUP.
|
||||
- **PR #89806** fix(feishu): avoid axios interceptor internals. Related #83913. Thanks @sweetcornna and @davinci282828.
|
||||
- **PR #91923** fix(ios): clean up notification settings state. Thanks @zats.
|
||||
- **PR #91345** fix: suggest close CLI commands. Related #83999. Thanks @glenn-agent and @HannesOberreiter.
|
||||
- **PR #94561** Add stdout diagnostics OTEL log exporter. Thanks @jesse-merhi.
|
||||
- **PR #91013** fix(gateway): ignore stale abort markers for fresh chat events. Related #91012. Thanks @nxmxbbd.
|
||||
- **PR #89279** fix(tasks): deliver ACP completions to bound Discord threads. Related #84022. Thanks @anyech and @h-mascot.
|
||||
- **PR #91656** test(cron): expand parseAbsoluteTimeMs test coverage to 39 cases. Related #91654. Thanks @SpecialLeon.
|
||||
- **PR #94810** fix(telegram): classify sendChatAction 401 by structured error_code, not bare substring match. Related #94787. Thanks @ZOOWH and @parveshsaini.
|
||||
- **PR #94737** fix(reply): clarify provider internal error copy. Thanks @snowzlmbot.
|
||||
- **PR #94868** fix(channels): preserve command progress detail. Thanks @vincentkoc.
|
||||
- **PR #94891** fix(telegram): send progress previews as html text. Thanks @obviyus.
|
||||
- **PR #94683** fix(outbound): keep direct-only targets out of group sessions. Related #92384. Thanks @scotthuang and @haiwei01.
|
||||
- **PR #92477** fix: migrate watch app to single-target app (Xcode 27+ compat). Thanks @zats and @joshavant.
|
||||
- **PR #94812** test(perf): compare saved CLI startup benchmarks. Thanks @FelixIsaac.
|
||||
- **PR #94856** fix(telegram): normalize all HTML tables before entity-escaping in rich messages. Related #94317. Thanks @zhangqueping and @jairrab.
|
||||
- **PR #91685** fix(cron): refuse keyless implicit isolated cron delivery inherited from shared agent-main bucket. Thanks @nxmxbbd.
|
||||
|
||||
## 2026.6.8
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ We prioritize secure defaults, but also expose clear knobs for trusted high-powe
|
||||
## Plugins & Memory
|
||||
|
||||
OpenClaw has an extensive plugin API.
|
||||
Core stays lean; optional capabilities should usually ship as plugins.
|
||||
Core stays lean; optional capability should usually ship as plugins.
|
||||
We are generally slimming down core while expanding what plugins can do.
|
||||
If a useful feature cannot be built as a plugin yet, we welcome PRs and design discussions that extend the plugin API instead of adding one-off core behavior.
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# Source of truth: apps/android/version.json
|
||||
# Generated by scripts/android-sync-versioning.ts.
|
||||
|
||||
OPENCLAW_ANDROID_VERSION_NAME=2026.6.9
|
||||
OPENCLAW_ANDROID_VERSION_CODE=2026060901
|
||||
OPENCLAW_ANDROID_VERSION_NAME=2026.6.2
|
||||
OPENCLAW_ANDROID_VERSION_CODE=2026060201
|
||||
|
||||
@@ -59,7 +59,7 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "ai.openclaw.app"
|
||||
compileSdk = 36
|
||||
compileSdk = 37
|
||||
|
||||
// Release signing is local-only; keep the keystore path and passwords out of the repo.
|
||||
signingConfigs {
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
<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:permission="${applicationId}.permission.RUN_VOICE_E2E"
|
||||
android:exported="false">
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="ai.openclaw.app.debug.RUN_VOICE_E2E" />
|
||||
</intent-filter>
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
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,9 +204,6 @@ 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
|
||||
@@ -540,17 +537,6 @@ class MainViewModel(
|
||||
ensureRuntime().refreshNodesDevices()
|
||||
}
|
||||
|
||||
fun refreshExecApprovals() {
|
||||
ensureRuntime().refreshExecApprovals()
|
||||
}
|
||||
|
||||
fun resolveExecApproval(
|
||||
id: String,
|
||||
decision: String,
|
||||
) {
|
||||
ensureRuntime().resolveExecApproval(id = id, decision = decision)
|
||||
}
|
||||
|
||||
fun refreshChannels() {
|
||||
ensureRuntime().refreshChannels()
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import ai.openclaw.app.gateway.GatewayTlsProbeFailure
|
||||
import ai.openclaw.app.gateway.GatewayTlsProbeResult
|
||||
import ai.openclaw.app.gateway.GatewayUpdateAvailableSummary
|
||||
import ai.openclaw.app.gateway.normalizeGatewayTlsFingerprint
|
||||
import ai.openclaw.app.gateway.parseChatSendAck
|
||||
import ai.openclaw.app.gateway.probeGatewayTlsFingerprint
|
||||
import ai.openclaw.app.node.A2UIHandler
|
||||
import ai.openclaw.app.node.CalendarHandler
|
||||
@@ -74,9 +73,7 @@ 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
|
||||
|
||||
/**
|
||||
@@ -402,15 +399,6 @@ 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)
|
||||
@@ -460,7 +448,6 @@ class NodeRuntime(
|
||||
micCapture.onGatewayConnectionChanged(true)
|
||||
scope.launch {
|
||||
subscribeOperatorSessionEvents()
|
||||
refreshExecApprovalsFromGateway()
|
||||
refreshHomeCanvasOverviewIfConnected()
|
||||
if (voiceReplySpeakerLazy.isInitialized()) {
|
||||
voiceReplySpeaker.refreshConfig()
|
||||
@@ -490,11 +477,6 @@ 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()
|
||||
@@ -650,11 +632,7 @@ class NodeRuntime(
|
||||
put("idempotencyKey", JsonPrimitive(idempotencyKey))
|
||||
}
|
||||
val response = operatorSession.request("chat.send", params.toString())
|
||||
val ack = parseChatSendAck(json, response)
|
||||
ack.copy(runId = ack.runId ?: idempotencyKey)
|
||||
},
|
||||
refreshAfterTerminalSuccess = {
|
||||
chat.refresh()
|
||||
parseChatSendRunId(response) ?: idempotencyKey
|
||||
},
|
||||
speakAssistantReply = { text ->
|
||||
// Voice-tab replies should speak through the dedicated reply speaker.
|
||||
@@ -842,24 +820,6 @@ 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()
|
||||
@@ -1035,9 +995,6 @@ class NodeRuntime(
|
||||
_isForeground.value = value
|
||||
if (value) {
|
||||
reconnectPreferredGatewayOnForeground()
|
||||
scope.launch {
|
||||
refreshExecApprovalsFromGateway()
|
||||
}
|
||||
} else {
|
||||
stopManualVoiceSession()
|
||||
publishNodePresenceAliveBeacon(NodePresenceAliveBeacon.Trigger.Background, throttleRecentSuccess = true)
|
||||
@@ -1867,47 +1824,11 @@ 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() }
|
||||
@@ -1922,6 +1843,15 @@ class NodeRuntime(
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseChatSendRunId(response: String): String? {
|
||||
return try {
|
||||
val root = json.parseToJsonElement(response).asObjectOrNull() ?: return null
|
||||
root["runId"].asStringOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseTalkSessionId(response: String): String {
|
||||
val root = json.parseToJsonElement(response).asObjectOrNull()
|
||||
val sessionId =
|
||||
@@ -2154,196 +2084,6 @@ 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) {
|
||||
@@ -2458,19 +2198,12 @@ 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 = sanitizedLine.trim().ifEmpty { "Empty log entry" },
|
||||
raw = sanitizedLine,
|
||||
)
|
||||
} ?: return GatewayLogEntry(time = null, level = null, subsystem = null, message = line.trim().ifEmpty { "Empty log entry" })
|
||||
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())
|
||||
@@ -2488,7 +2221,7 @@ class NodeRuntime(
|
||||
?: root["message"].asStringOrNull()
|
||||
?: line
|
||||
val normalizedMessage =
|
||||
sanitizeGatewayLogText(message)
|
||||
message
|
||||
.trim()
|
||||
.replace(Regex("\\s+"), " ")
|
||||
.take(240)
|
||||
@@ -2496,9 +2229,8 @@ class NodeRuntime(
|
||||
return GatewayLogEntry(
|
||||
time = time,
|
||||
level = level,
|
||||
subsystem = subsystem?.let(::sanitizeGatewayLogText)?.trim()?.takeIf { it.isNotEmpty() },
|
||||
subsystem = subsystem?.trim()?.takeIf { it.isNotEmpty() },
|
||||
message = normalizedMessage,
|
||||
raw = sanitizedLine,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2587,7 +2319,6 @@ 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",
|
||||
@@ -3038,6 +2769,11 @@ internal fun resolveOperatorSessionConnectAuth(
|
||||
)
|
||||
}
|
||||
|
||||
internal fun shouldConnectOperatorSession(
|
||||
auth: NodeRuntime.GatewayConnectAuth,
|
||||
storedOperatorToken: String?,
|
||||
): Boolean = resolveOperatorSessionConnectAuth(auth, storedOperatorToken) != null
|
||||
|
||||
private enum class HomeCanvasGatewayState {
|
||||
Connected,
|
||||
Connecting,
|
||||
@@ -3110,7 +2846,6 @@ data class GatewaySkillsSummary(
|
||||
)
|
||||
|
||||
data class GatewaySkillSummary(
|
||||
val skillKey: String,
|
||||
val name: String,
|
||||
val description: String?,
|
||||
val source: String,
|
||||
@@ -3308,19 +3043,8 @@ 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,6 +393,12 @@ 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,6 +6,14 @@ 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()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package ai.openclaw.app.chat
|
||||
|
||||
import ai.openclaw.app.gateway.GatewaySession
|
||||
import ai.openclaw.app.gateway.parseChatSendAck
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -20,21 +19,11 @@ import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
class ChatController internal constructor(
|
||||
class ChatController(
|
||||
private val scope: CoroutineScope,
|
||||
private val session: GatewaySession,
|
||||
private val json: Json,
|
||||
private val requestGateway: suspend (method: String, paramsJson: String?) -> String,
|
||||
) {
|
||||
constructor(
|
||||
scope: CoroutineScope,
|
||||
session: GatewaySession,
|
||||
json: Json,
|
||||
) : this(
|
||||
scope = scope,
|
||||
json = json,
|
||||
requestGateway = { method, paramsJson -> session.request(method, paramsJson) },
|
||||
)
|
||||
|
||||
private var appliedMainSessionKey = "main"
|
||||
private val _sessionKey = MutableStateFlow("main")
|
||||
val sessionKey: StateFlow<String> = _sessionKey.asStateFlow()
|
||||
@@ -278,9 +267,8 @@ class ChatController internal constructor(
|
||||
)
|
||||
}
|
||||
}
|
||||
val res = requestGateway("chat.send", params.toString())
|
||||
val ack = parseChatSendAck(json, res)
|
||||
val actualRunId = ack.runId ?: runId
|
||||
val res = session.request("chat.send", params.toString())
|
||||
val actualRunId = parseRunId(res) ?: runId
|
||||
if (actualRunId != runId) {
|
||||
// Gateway may return a canonical run id; move all pending bookkeeping to that id.
|
||||
optimisticMessagesByRunId[actualRunId] = optimisticMessagesByRunId.remove(runId) ?: optimisticMessage
|
||||
@@ -291,24 +279,7 @@ class ChatController internal constructor(
|
||||
_pendingRunCount.value = pendingRuns.size
|
||||
}
|
||||
}
|
||||
if (ack.isTerminal) {
|
||||
clearPendingRun(actualRunId)
|
||||
removeOptimisticMessage(actualRunId)
|
||||
pendingToolCallsById.clear()
|
||||
publishPendingToolCalls()
|
||||
_streamingAssistantText.value = null
|
||||
if (ack.isTerminalSuccess) {
|
||||
refreshCurrentHistoryBestEffort()
|
||||
true
|
||||
} else {
|
||||
// Terminal timeout/error means the gateway did not accept a runnable turn.
|
||||
// Surface failed acceptance instead of letting a cleared composer look successful.
|
||||
_errorText.value = "Chat failed before the run started; try again."
|
||||
false
|
||||
}
|
||||
} else {
|
||||
true
|
||||
}
|
||||
true
|
||||
} catch (err: Throwable) {
|
||||
clearPendingRun(runId)
|
||||
removeOptimisticMessage(runId)
|
||||
@@ -332,7 +303,7 @@ class ChatController internal constructor(
|
||||
put("sessionKey", JsonPrimitive(_sessionKey.value))
|
||||
put("runId", JsonPrimitive(runId))
|
||||
}
|
||||
requestGateway("chat.abort", params.toString())
|
||||
session.request("chat.abort", params.toString())
|
||||
} catch (_: Throwable) {
|
||||
// best-effort
|
||||
}
|
||||
@@ -385,7 +356,7 @@ class ChatController internal constructor(
|
||||
) {
|
||||
try {
|
||||
val historyJson =
|
||||
requestGateway(
|
||||
session.request(
|
||||
"chat.history",
|
||||
buildJsonObject { put("sessionKey", JsonPrimitive(sessionKey)) }.toString(),
|
||||
)
|
||||
@@ -420,7 +391,7 @@ class ChatController internal constructor(
|
||||
put("includeUnknown", JsonPrimitive(false))
|
||||
if (limit != null && limit > 0) put("limit", JsonPrimitive(limit))
|
||||
}
|
||||
val res = requestGateway("sessions.list", params.toString())
|
||||
val res = session.request("sessions.list", params.toString())
|
||||
_sessions.value = parseSessions(res)
|
||||
} catch (_: Throwable) {
|
||||
// best-effort
|
||||
@@ -437,7 +408,7 @@ class ChatController internal constructor(
|
||||
if (!force && last != null && now - last < 10_000) return
|
||||
lastHealthPollAtMs = now
|
||||
try {
|
||||
requestGateway("health", null)
|
||||
session.request("health", null)
|
||||
_healthOk.value = true
|
||||
} catch (_: Throwable) {
|
||||
_healthOk.value = false
|
||||
@@ -480,7 +451,7 @@ class ChatController internal constructor(
|
||||
val currentSessionKey = _sessionKey.value
|
||||
val currentGeneration = historyLoadGeneration.get()
|
||||
val historyJson =
|
||||
requestGateway(
|
||||
session.request(
|
||||
"chat.history",
|
||||
buildJsonObject { put("sessionKey", JsonPrimitive(currentSessionKey)) }.toString(),
|
||||
)
|
||||
@@ -538,7 +509,8 @@ 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
|
||||
@@ -660,45 +632,6 @@ class ChatController internal constructor(
|
||||
optimisticMessagesByRunId.entries.removeAll { entry -> entry.value !in retained }
|
||||
}
|
||||
|
||||
private fun refreshCurrentHistoryBestEffort() {
|
||||
scope.launch {
|
||||
try {
|
||||
val currentSessionKey = _sessionKey.value
|
||||
val currentGeneration = historyLoadGeneration.get()
|
||||
val historyJson =
|
||||
requestGateway(
|
||||
"chat.history",
|
||||
buildJsonObject { put("sessionKey", JsonPrimitive(currentSessionKey)) }.toString(),
|
||||
)
|
||||
if (
|
||||
!isCurrentHistoryLoad(
|
||||
currentSessionKey,
|
||||
_sessionKey.value,
|
||||
currentGeneration,
|
||||
historyLoadGeneration.get(),
|
||||
)
|
||||
) {
|
||||
return@launch
|
||||
}
|
||||
val history =
|
||||
parseHistory(
|
||||
historyJson,
|
||||
sessionKey = currentSessionKey,
|
||||
previousMessages = _messages.value,
|
||||
)
|
||||
prunePersistedOptimisticMessages(history.messages)
|
||||
_messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
|
||||
_sessionId.value = history.sessionId
|
||||
history.thinkingLevel
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.let { _thinkingLevel.value = it }
|
||||
} catch (_: Throwable) {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseHistory(
|
||||
historyJson: String,
|
||||
sessionKey: String,
|
||||
@@ -746,16 +679,9 @@ 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,
|
||||
@@ -802,6 +728,17 @@ class ChatController internal constructor(
|
||||
_sessions.value = _sessions.value.filterNot { it.key == key }
|
||||
}
|
||||
|
||||
private fun parseRunId(resJson: String): String? =
|
||||
try {
|
||||
json
|
||||
.parseToJsonElement(resJson)
|
||||
.asObjectOrNull()
|
||||
?.get("runId")
|
||||
.asStringOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun normalizeThinking(raw: String): String =
|
||||
when (raw.trim().lowercase()) {
|
||||
"low" -> "low"
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
package ai.openclaw.app.gateway
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
internal data class ChatSendAck(
|
||||
val runId: String?,
|
||||
val status: String?,
|
||||
) {
|
||||
val normalizedStatus: String
|
||||
get() = status?.trim()?.lowercase().orEmpty()
|
||||
|
||||
val isTerminalSuccess: Boolean
|
||||
get() = normalizedStatus == "ok"
|
||||
|
||||
val isTerminalFailure: Boolean
|
||||
get() = normalizedStatus == "timeout" || normalizedStatus == "error"
|
||||
|
||||
val isTerminal: Boolean
|
||||
get() = isTerminalSuccess || isTerminalFailure
|
||||
}
|
||||
|
||||
internal fun chatSendAckHistorySinceSeconds(
|
||||
ack: ChatSendAck,
|
||||
startedAtSeconds: Double,
|
||||
): Double? = if (ack.isTerminalSuccess) null else startedAtSeconds
|
||||
|
||||
internal fun parseChatSendAck(
|
||||
json: Json,
|
||||
responseJson: String,
|
||||
): ChatSendAck =
|
||||
try {
|
||||
val obj = json.parseToJsonElement(responseJson).asObjectOrNull()
|
||||
ChatSendAck(
|
||||
runId = obj?.get("runId").asStringOrNull(),
|
||||
status = obj?.get("status").asStringOrNull(),
|
||||
)
|
||||
} catch (_: Throwable) {
|
||||
ChatSendAck(runId = null, status = null)
|
||||
}
|
||||
|
||||
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
|
||||
private fun JsonElement?.asStringOrNull(): String? = (this as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||
@@ -1,5 +1,6 @@
|
||||
package ai.openclaw.app.gateway
|
||||
|
||||
import android.annotation.TargetApi
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.DnsResolver
|
||||
@@ -11,7 +12,6 @@ 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
|
||||
@@ -49,8 +49,18 @@ import java.util.concurrent.Executors
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
private fun createDnsResolver(context: Context): DnsResolver =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) {
|
||||
createContextDnsResolver(context)
|
||||
} else {
|
||||
createLegacyDnsResolver()
|
||||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.CINNAMON_BUN)
|
||||
private fun createContextDnsResolver(context: Context): DnsResolver = DnsResolver(context, null)
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun createDnsResolver(): DnsResolver = DnsResolver.getInstance()
|
||||
private fun createLegacyDnsResolver(): DnsResolver = DnsResolver.getInstance()
|
||||
|
||||
/**
|
||||
* Watches local DNS-SD and optional wide-area DNS-SD for reachable OpenClaw gateways.
|
||||
@@ -61,7 +71,7 @@ class GatewayDiscovery(
|
||||
) {
|
||||
private val nsd = context.getSystemService(NsdManager::class.java)
|
||||
private val connectivity = context.getSystemService(ConnectivityManager::class.java)
|
||||
private val dns = createDnsResolver()
|
||||
private val dns = createDnsResolver(context)
|
||||
private val serviceType = "_openclaw-gw._tcp."
|
||||
private val wideAreaDomain = System.getenv("OPENCLAW_WIDE_AREA_DOMAIN")
|
||||
private val logTag = "OpenClaw/GatewayDiscovery"
|
||||
@@ -156,6 +166,14 @@ class GatewayDiscovery(
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopLocalDiscovery() {
|
||||
try {
|
||||
nsd.stopServiceDiscovery(discoveryListener)
|
||||
} catch (_: Throwable) {
|
||||
// ignore (best-effort)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startUnicastDiscovery(domain: String) {
|
||||
unicastJob =
|
||||
scope.launch(Dispatchers.IO) {
|
||||
@@ -179,7 +197,7 @@ class GatewayDiscovery(
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
||||
@TargetApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
||||
private fun resolveWithServiceInfoCallback(serviceInfo: NsdServiceInfo) {
|
||||
val serviceName = BonjourEscapes.decode(serviceInfo.serviceName)
|
||||
val id = stableId(serviceName, "local.")
|
||||
|
||||
@@ -260,6 +260,24 @@ 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,
|
||||
@@ -279,6 +297,28 @@ 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,6 +97,8 @@ class CanvasController {
|
||||
|
||||
fun currentUrl(): String? = url
|
||||
|
||||
fun isDefaultCanvas(): Boolean = url == null
|
||||
|
||||
fun setDebugStatusEnabled(enabled: Boolean) {
|
||||
debugStatusEnabled = enabled
|
||||
applyDebugStatus()
|
||||
@@ -203,6 +205,24 @@ 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,7 +4,6 @@ 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
|
||||
@@ -64,7 +63,7 @@ private class AndroidDeviceAppSource(
|
||||
|
||||
val appInfos =
|
||||
if (includeNonLaunchable) {
|
||||
visibleInstalledApplications(packageManager)
|
||||
packageManager.getInstalledApplications(PackageManager.MATCH_ALL)
|
||||
} else {
|
||||
launchablePackages.mapNotNull { packageName ->
|
||||
runCatching { packageManager.getApplicationInfo(packageName, 0) }.getOrNull()
|
||||
@@ -91,13 +90,6 @@ 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,3 +109,6 @@ 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,7 +5,6 @@ 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
|
||||
@@ -95,11 +94,7 @@ internal fun CommandPalette(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
ClawPlainIconButton(
|
||||
icon = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Close search",
|
||||
onClick = onDismiss,
|
||||
)
|
||||
CommandIconButton(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")
|
||||
}
|
||||
@@ -267,6 +262,19 @@ 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,7 +5,8 @@ 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.ClawStatusRow
|
||||
import ai.openclaw.app.ui.design.ClawStatus
|
||||
import ai.openclaw.app.ui.design.ClawStatusPill
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -91,19 +92,19 @@ private fun DreamingPanel(summary: GatewayDreamingSummary) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
Column {
|
||||
ClawStatusRow(
|
||||
DreamingHealthRow(
|
||||
title = "Memory Store",
|
||||
value = if (summary.storeHealthy) "Healthy" else "Needs attention",
|
||||
healthy = summary.storeHealthy,
|
||||
)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
ClawStatusRow(
|
||||
DreamingHealthRow(
|
||||
title = "Signal Index",
|
||||
value = if (summary.phaseSignalHealthy) "Healthy" else "Needs attention",
|
||||
healthy = summary.phaseSignalHealthy,
|
||||
)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
ClawStatusRow(
|
||||
DreamingHealthRow(
|
||||
title = "Promoted",
|
||||
value = "${summary.promotedToday} today · ${summary.promotedTotal} total",
|
||||
healthy = true,
|
||||
@@ -114,6 +115,23 @@ 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,6 +206,9 @@ 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,10 +7,7 @@ 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
|
||||
@@ -18,18 +15,13 @@ 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
|
||||
@@ -51,7 +43,6 @@ 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) {
|
||||
@@ -61,11 +52,6 @@ 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.",
|
||||
@@ -107,46 +93,7 @@ internal fun HealthLogsSettingsScreen(
|
||||
Text(text = error, style = ClawTheme.type.body, color = ClawTheme.colors.warning)
|
||||
}
|
||||
}
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
GatewayLogsPanel(isConnected = isConnected, summary = logsSummary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,26 +113,41 @@ private fun HealthStatusPanel(
|
||||
) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
Column {
|
||||
ClawStatusRow(title = "Gateway", value = gateway, healthy = isConnected)
|
||||
HealthStatusRow(title = "Gateway", value = gateway, healthy = isConnected)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
ClawStatusRow(title = "Phone Node", value = node, healthy = isNodeConnected)
|
||||
HealthStatusRow(title = "Phone Node", value = node, healthy = isNodeConnected)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
ClawStatusRow(title = "Chat", value = chat, healthy = chatHealthOk)
|
||||
HealthStatusRow(title = "Chat", value = chat, healthy = chatHealthOk)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
ClawStatusRow(title = "Models", value = models, healthy = modelsReady)
|
||||
HealthStatusRow(title = "Models", value = models, healthy = modelsReady)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
ClawStatusRow(title = "Voice", value = voice, healthy = voiceReady)
|
||||
HealthStatusRow(title = "Voice", value = voice, healthy = voiceReady)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
ClawStatusRow(title = "Runs", value = runs, healthy = true)
|
||||
HealthStatusRow(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) {
|
||||
@@ -208,7 +170,7 @@ private fun GatewayLogsPanel(
|
||||
val entries = summary.entries.takeLast(12)
|
||||
Column {
|
||||
entries.forEachIndexed { index, entry ->
|
||||
GatewayLogRow(entry = entry, onClick = { onLogClick(entry) })
|
||||
GatewayLogRow(entry = entry)
|
||||
if (index != entries.lastIndex) {
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
}
|
||||
@@ -223,16 +185,9 @@ private fun GatewayLogsPanel(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GatewayLogRow(
|
||||
entry: GatewayLogEntry,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
private fun GatewayLogRow(entry: GatewayLogEntry) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClickLabel = "Open log entry", onClick = onClick)
|
||||
.padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
@@ -244,11 +199,6 @@ private fun GatewayLogRow(
|
||||
}
|
||||
}
|
||||
ClawStatusPill(text = entry.level?.uppercase() ?: "LOG", status = logLevelStatus(entry.level))
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = ClawTheme.colors.textSubtle,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1378,12 +1378,7 @@ private fun rememberPermissionState(
|
||||
photosGranted = permissions[photosPermission] ?: photosGranted
|
||||
contactsGranted = permissions[Manifest.permission.READ_CONTACTS] ?: contactsGranted
|
||||
calendarGranted = permissions[Manifest.permission.READ_CALENDAR] ?: calendarGranted
|
||||
notificationsGranted =
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
permissions[Manifest.permission.POST_NOTIFICATIONS] ?: notificationsGranted
|
||||
} else {
|
||||
true
|
||||
}
|
||||
notificationsGranted = permissions[Manifest.permission.POST_NOTIFICATIONS] ?: notificationsGranted
|
||||
motionGranted = permissions[Manifest.permission.ACTIVITY_RECOGNITION] ?: motionGranted
|
||||
smsGranted =
|
||||
(permissions[Manifest.permission.SEND_SMS] ?: smsGranted) &&
|
||||
|
||||
@@ -9,10 +9,14 @@ 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.
|
||||
*/
|
||||
@@ -30,6 +34,7 @@ fun OpenClawTheme(
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalMobileColors provides mobileColors,
|
||||
LocalOpenClawDarkTheme provides isDark,
|
||||
) {
|
||||
MaterialTheme(colorScheme = colorScheme, content = content)
|
||||
}
|
||||
@@ -50,3 +55,21 @@ 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,7 +2,6 @@ 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
|
||||
@@ -56,7 +55,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 current chat sessions. */
|
||||
/** Session browser for recent and currently-live chat sessions. */
|
||||
@Composable
|
||||
internal fun SessionsScreen(
|
||||
viewModel: MainViewModel,
|
||||
@@ -74,7 +73,7 @@ internal fun SessionsScreen(
|
||||
.let { rows ->
|
||||
when (filter) {
|
||||
SessionFilter.Recent -> rows
|
||||
SessionFilter.Current -> rows.filter { it.key == chatSessionKey }
|
||||
SessionFilter.Live -> rows.filter { it.key == chatSessionKey }
|
||||
}
|
||||
}.let { rows ->
|
||||
if (recentFirst) {
|
||||
@@ -93,12 +92,12 @@ internal fun SessionsScreen(
|
||||
}
|
||||
|
||||
ClawScaffold(
|
||||
contentPadding = PaddingValues(start = 16.dp, top = 10.dp, end = 16.dp, bottom = 4.dp),
|
||||
contentPadding = PaddingValues(start = 20.dp, top = 14.dp, end = 20.dp, bottom = 6.dp),
|
||||
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(9.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(7.dp),
|
||||
contentPadding = PaddingValues(bottom = 4.dp),
|
||||
) {
|
||||
item {
|
||||
@@ -107,16 +106,16 @@ internal fun SessionsScreen(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
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 })
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
FilterPill(text = "Recent", icon = Icons.Outlined.AccessTime, active = filter == SessionFilter.Recent, onClick = { filter = SessionFilter.Recent })
|
||||
FilterPill(text = "Current", icon = Icons.Outlined.MicNone, active = filter == SessionFilter.Current, showDot = sessions.any { it.key == chatSessionKey }, onClick = { filter = SessionFilter.Current })
|
||||
FilterPill(text = "Live", icon = Icons.Outlined.MicNone, active = filter == SessionFilter.Live, live = sessions.any { it.key == chatSessionKey }, onClick = { filter = SessionFilter.Live })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,7 +179,7 @@ private fun FilterPill(
|
||||
text: String,
|
||||
icon: ImageVector? = null,
|
||||
active: Boolean = false,
|
||||
showDot: Boolean = false,
|
||||
live: Boolean = false,
|
||||
dropdown: Boolean = false,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
@@ -199,7 +198,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 (showDot) {
|
||||
if (live) {
|
||||
Box(modifier = Modifier.size(4.dp).clip(CircleShape).background(ClawTheme.colors.success))
|
||||
}
|
||||
if (dropdown) {
|
||||
@@ -259,7 +258,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) "Current" else "OpenClaw")
|
||||
SessionMiniTag(text = if (active) "Active" else "OpenClaw")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -274,6 +273,19 @@ 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,
|
||||
@@ -308,21 +320,21 @@ private fun SessionMiniTag(text: String) {
|
||||
|
||||
private enum class SessionFilter {
|
||||
Recent,
|
||||
Current,
|
||||
Live,
|
||||
}
|
||||
|
||||
/** Empty-state title selected by the active session browser filter. */
|
||||
private fun emptySessionTitle(filter: SessionFilter): String =
|
||||
when (filter) {
|
||||
SessionFilter.Recent -> "No sessions yet"
|
||||
SessionFilter.Current -> "No current session"
|
||||
SessionFilter.Live -> "No live 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.Current -> "Open Chat to start or resume the current session."
|
||||
SessionFilter.Live -> "Open Chat to start or resume the current session."
|
||||
}
|
||||
|
||||
/** Formats session timestamps for compact mobile metadata. */
|
||||
|
||||
@@ -4,7 +4,6 @@ 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
|
||||
@@ -15,7 +14,6 @@ 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
|
||||
@@ -92,6 +90,7 @@ 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
|
||||
@@ -107,7 +106,6 @@ internal enum class SettingsRoute {
|
||||
Profile,
|
||||
Voice,
|
||||
Agents,
|
||||
ProvidersModels,
|
||||
Approvals,
|
||||
CronJobs,
|
||||
Usage,
|
||||
@@ -138,7 +136,6 @@ 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)
|
||||
@@ -302,62 +299,29 @@ 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 issueCount = execApprovals.count { it.errorText != null } + pendingToolCalls.count { it.isError == true }
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
viewModel.refreshExecApprovals()
|
||||
}
|
||||
}
|
||||
val waitingCount = pendingToolCalls.count { it.isError != true }
|
||||
val issueCount = pendingToolCalls.count { it.isError == true }
|
||||
|
||||
SettingsDetailFrame(title = "Approvals", subtitle = "Review actions that need your attention.", icon = Icons.Default.Lock, onBack = onBack) {
|
||||
SettingsMetricPanel(
|
||||
rows =
|
||||
listOf(
|
||||
SettingsMetric("Gateway Pending", execApprovals.size.toString()),
|
||||
SettingsMetric("Session Activity", pendingToolCalls.size.toString()),
|
||||
SettingsMetric("Pending", waitingCount.toString()),
|
||||
SettingsMetric("Issues", issueCount.toString()),
|
||||
SettingsMetric("Active Runs", pendingRunCount.toString()),
|
||||
),
|
||||
)
|
||||
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) {
|
||||
if (pendingToolCalls.isEmpty()) {
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
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)
|
||||
ApprovalsPanel(toolCalls = pendingToolCalls)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -856,7 +820,6 @@ 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(
|
||||
@@ -877,17 +840,7 @@ 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)
|
||||
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,
|
||||
)
|
||||
}
|
||||
ClawSecondaryButton(text = "Pair New Gateway", onClick = viewModel::pairNewGateway, modifier = Modifier.fillMaxWidth(), icon = Icons.Default.QrCode2)
|
||||
}
|
||||
}
|
||||
ClawPanel {
|
||||
@@ -1108,11 +1061,7 @@ 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)) {
|
||||
ClawPlainIconButton(
|
||||
icon = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
onClick = onBack,
|
||||
)
|
||||
SettingsBackButton(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)
|
||||
}
|
||||
@@ -1149,70 +1098,7 @@ internal data class SettingsMetric(
|
||||
)
|
||||
|
||||
@Composable
|
||||
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>) {
|
||||
private fun ApprovalsPanel(toolCalls: List<ChatPendingToolCall>) {
|
||||
ClawListPanel(items = toolCalls) { toolCall ->
|
||||
ApprovalListRow(toolCall = toolCall)
|
||||
}
|
||||
@@ -1345,30 +1231,6 @@ 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}"
|
||||
|
||||
@@ -1532,6 +1394,15 @@ 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,6 +1253,16 @@ 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,24 +10,17 @@ 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
|
||||
|
||||
@@ -44,7 +37,6 @@ 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) {
|
||||
@@ -52,17 +44,6 @@ 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.",
|
||||
@@ -102,117 +83,25 @@ 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, onSkillClick = { selectedSkillKey = it.skillKey })
|
||||
else -> SkillsPanel(skills = skills)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
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 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,
|
||||
) {
|
||||
private fun SkillsPanel(skills: List<GatewaySkillSummary>) {
|
||||
ClawListPanel(items = skills) { skill ->
|
||||
SkillListRow(skill = skill, onClick = { onSkillClick(skill) })
|
||||
SkillListRow(skill = skill)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SkillListRow(
|
||||
skill: GatewaySkillSummary,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
private fun SkillListRow(skill: GatewaySkillSummary) {
|
||||
ClawDetailRow(
|
||||
title = skill.name,
|
||||
subtitle = skillSubtitle(skill),
|
||||
modifier = Modifier.clickable(onClickLabel = "Open skill detail", onClick = onClick),
|
||||
leading = { ClawTextBadge(text = skillBadge(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,
|
||||
)
|
||||
}
|
||||
},
|
||||
trailing = { ClawStatusPill(text = skillStatusText(skill), status = skillStatus(skill)) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -246,15 +135,6 @@ 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,10 +1,8 @@
|
||||
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
|
||||
@@ -70,7 +68,6 @@ 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
|
||||
@@ -180,8 +177,8 @@ fun VoiceScreen(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(9.dp),
|
||||
.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
VoiceHeader(
|
||||
statusText = voiceAttentionStatus ?: if (voiceActive || !gatewayReady) activeStatus else "Your voice command center.",
|
||||
@@ -270,12 +267,12 @@ private fun DictationScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
ClawPlainIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to voice", onClick = onCancel)
|
||||
VoicePlainIconButton(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)
|
||||
}
|
||||
ClawPlainIconButton(icon = Icons.Default.Settings, contentDescription = "Dictation settings", onClick = onOpenVoiceSettings)
|
||||
VoicePlainIconButton(icon = Icons.Default.Settings, contentDescription = "Dictation settings", onClick = onOpenVoiceSettings)
|
||||
}
|
||||
|
||||
Surface(
|
||||
@@ -407,7 +404,7 @@ private fun TalkSessionScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
ClawPlainIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to voice", onClick = onEndTalk)
|
||||
VoicePlainIconButton(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)) {
|
||||
@@ -426,7 +423,7 @@ private fun TalkSessionScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
ClawPlainIconButton(icon = Icons.Default.Info, contentDescription = "Talk settings", onClick = onOpenVoiceSettings)
|
||||
VoicePlainIconButton(icon = Icons.Default.Info, contentDescription = "Talk settings", onClick = onOpenVoiceSettings)
|
||||
}
|
||||
|
||||
Surface(
|
||||
@@ -550,19 +547,14 @@ 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 = "OpenClaw",
|
||||
style = ClawTheme.type.title.copy(fontSize = 17.sp, lineHeight = 21.sp),
|
||||
text = "O P E N C L A W",
|
||||
style = ClawTheme.type.title.copy(fontSize = 18.sp, lineHeight = 23.sp),
|
||||
color = ClawTheme.colors.text,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
ClawPlainIconButton(icon = Icons.Default.Search, contentDescription = "Search voice", onClick = onOpenCommand)
|
||||
VoicePlainIconButton(icon = Icons.Default.Search, contentDescription = "Search voice", onClick = onOpenCommand)
|
||||
VoiceAvatar(text = "OC")
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -570,7 +562,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 = 24.sp, lineHeight = 28.sp), color = ClawTheme.colors.text)
|
||||
Text(text = "Voice", style = ClawTheme.type.display.copy(fontSize = 16.sp, lineHeight = 20.sp), color = ClawTheme.colors.text)
|
||||
Text(
|
||||
text = statusText,
|
||||
style = ClawTheme.type.body,
|
||||
@@ -579,7 +571,7 @@ private fun VoiceHeader(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
ClawPlainIconButton(
|
||||
VoicePlainIconButton(
|
||||
icon = if (speakerEnabled) Icons.AutoMirrored.Filled.VolumeUp else Icons.AutoMirrored.Filled.VolumeOff,
|
||||
contentDescription = if (speakerEnabled) "Mute speaker" else "Unmute speaker",
|
||||
onClick = onToggleSpeaker,
|
||||
@@ -588,6 +580,34 @@ 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,
|
||||
@@ -841,10 +861,8 @@ private fun VoiceOrb(
|
||||
Surface(
|
||||
modifier = Modifier.size(112.dp),
|
||||
shape = CircleShape,
|
||||
color = if (active || listening || speaking) Color(0xFF1976D2) else Color(0xFF123B63),
|
||||
contentColor = Color.White,
|
||||
tonalElevation = 3.dp,
|
||||
shadowElevation = 7.dp,
|
||||
color = if (active) ClawTheme.colors.surfacePressed else ClawTheme.colors.surface,
|
||||
border = BorderStroke(1.dp, if (active) ClawTheme.colors.borderStrong else ClawTheme.colors.border),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
@@ -857,7 +875,7 @@ private fun VoiceOrb(
|
||||
},
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(32.dp),
|
||||
tint = Color.White,
|
||||
tint = ClawTheme.colors.text,
|
||||
)
|
||||
Waveform(active = active)
|
||||
}
|
||||
@@ -874,7 +892,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) Color.White else Color.White.copy(alpha = 0.52f)),
|
||||
.background(if (active) ClawTheme.colors.text else ClawTheme.colors.textSubtle),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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
|
||||
@@ -40,7 +39,6 @@ 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
|
||||
@@ -65,7 +63,6 @@ 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
|
||||
@@ -156,11 +153,12 @@ fun ChatScreen(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
.padding(horizontal = 18.dp, vertical = 6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(5.dp),
|
||||
) {
|
||||
ChatHeader(
|
||||
sessionTitle = currentSessionTitle(sessionKey = sessionKey, sessions = sessions),
|
||||
thinkingLevel = thinkingLevel,
|
||||
healthOk = healthOk,
|
||||
pendingRunCount = pendingRunCount,
|
||||
onMore = {
|
||||
@@ -263,11 +261,11 @@ private fun ChatSessionSwitcher(
|
||||
if (sessions.size > choices.size) {
|
||||
Surface(
|
||||
onClick = onOpenSessions,
|
||||
modifier = Modifier.heightIn(min = ClawTheme.spacing.touchTarget),
|
||||
modifier = Modifier.heightIn(min = 36.dp),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
color = ClawTheme.colors.surfaceRaised.copy(alpha = 0.72f),
|
||||
color = ClawTheme.colors.canvas,
|
||||
contentColor = ClawTheme.colors.textMuted,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border.copy(alpha = 0.7f)),
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
@@ -290,11 +288,11 @@ private fun ChatSessionChip(
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.heightIn(min = ClawTheme.spacing.touchTarget),
|
||||
modifier = Modifier.heightIn(min = 36.dp),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
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)),
|
||||
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),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
@@ -309,56 +307,48 @@ private fun ChatSessionChip(
|
||||
@Composable
|
||||
private fun ChatHeader(
|
||||
sessionTitle: String,
|
||||
thinkingLevel: String,
|
||||
healthOk: Boolean,
|
||||
pendingRunCount: Int,
|
||||
onMore: () -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
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),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.openclaw_logo),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(25.dp),
|
||||
tint = ClawTheme.colors.text,
|
||||
)
|
||||
Text(
|
||||
text = "OpenClaw",
|
||||
style = ClawTheme.type.title.copy(fontSize = 17.sp, lineHeight = 21.sp),
|
||||
text = sessionTitle,
|
||||
style = ClawTheme.type.title.copy(fontSize = 18.sp, lineHeight = 23.sp),
|
||||
color = ClawTheme.colors.text,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
ModelPill(
|
||||
text =
|
||||
when {
|
||||
pendingRunCount > 0 -> "Working"
|
||||
healthOk -> "Ready"
|
||||
else -> "Offline"
|
||||
healthOk -> "auto"
|
||||
else -> "offline"
|
||||
},
|
||||
status =
|
||||
when {
|
||||
pendingRunCount > 0 -> ClawStatus.Warning
|
||||
healthOk -> ClawStatus.Success
|
||||
healthOk -> ClawStatus.Neutral
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,13 +365,7 @@ private fun ModelPill(
|
||||
}
|
||||
Surface(
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
color =
|
||||
when (status) {
|
||||
ClawStatus.Success -> ClawTheme.colors.successSoft
|
||||
ClawStatus.Warning -> ClawTheme.colors.warningSoft
|
||||
ClawStatus.Danger -> ClawTheme.colors.dangerSoft
|
||||
ClawStatus.Neutral -> ClawTheme.colors.surfaceRaised
|
||||
},
|
||||
color = ClawTheme.colors.surfaceRaised,
|
||||
contentColor = ClawTheme.colors.textMuted,
|
||||
border = BorderStroke(1.dp, borderColor),
|
||||
) {
|
||||
@@ -593,15 +577,13 @@ private fun ChatBubble(
|
||||
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(if (isUser) 0.84f else 0.94f),
|
||||
modifier = Modifier.fillMaxWidth(if (isUser) 0.64f else 0.56f),
|
||||
shape = RoundedCornerShape(7.dp),
|
||||
color = if (isUser) ClawTheme.colors.surfacePressed.copy(alpha = 0.86f) else ClawTheme.colors.surfaceRaised.copy(alpha = 0.84f),
|
||||
color = ClawTheme.colors.surfaceRaised,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, if (live) ClawTheme.colors.borderStrong else ClawTheme.colors.border.copy(alpha = 0.45f)),
|
||||
tonalElevation = 1.dp,
|
||||
shadowElevation = 2.dp,
|
||||
border = BorderStroke(1.dp, if (live) ClawTheme.colors.borderStrong else ClawTheme.colors.border),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 11.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Column(modifier = Modifier.padding(horizontal = 7.dp, vertical = 3.5.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text =
|
||||
when {
|
||||
@@ -782,7 +764,7 @@ private fun ChatContextMeter(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.ArrowDropDown, contentDescription = null, modifier = Modifier.size(13.dp), tint = ClawTheme.colors.textSubtle)
|
||||
Icon(imageVector = Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(12.dp), tint = ClawTheme.colors.textSubtle)
|
||||
Text(
|
||||
text = contextMeterLabel(contextUsage, thinkingLevel),
|
||||
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp),
|
||||
@@ -954,7 +936,7 @@ internal fun resolveChatContextUsage(
|
||||
sessionKey = sessionKey,
|
||||
mainSessionKey = mainSessionKey,
|
||||
)
|
||||
}
|
||||
}
|
||||
return ChatContextUsage(
|
||||
totalTokens = entry?.totalTokens,
|
||||
totalTokensFresh = entry?.totalTokensFresh,
|
||||
@@ -991,6 +973,24 @@ 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,53 +185,6 @@ 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,17 +95,15 @@ internal fun ClawBottomNav(
|
||||
Box(modifier = modifier.fillMaxWidth().background(ClawTheme.colors.canvas)) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = ClawTheme.colors.surface.copy(alpha = 0.92f),
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border.copy(alpha = 0.42f)),
|
||||
color = ClawTheme.colors.surface.copy(alpha = 0.96f),
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
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 = 6.dp),
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
@@ -133,13 +131,13 @@ private fun ClawBottomNavItem(
|
||||
onClick = onClick,
|
||||
modifier = modifier.heightIn(min = 48.dp),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.control),
|
||||
color = if (selected) ClawTheme.colors.surfacePressed.copy(alpha = 0.72f) else Color.Transparent,
|
||||
contentColor = if (selected) ClawTheme.colors.text else ClawTheme.colors.textMuted,
|
||||
color = if (selected) ClawTheme.colors.primary else Color.Transparent,
|
||||
contentColor = if (selected) ClawTheme.colors.primaryText else ClawTheme.colors.textMuted,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 5.dp, vertical = 5.dp),
|
||||
modifier = Modifier.padding(horizontal = 5.dp, vertical = 6.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(3.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,11 +27,31 @@ internal fun ClawPanel(
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.panel),
|
||||
color = ClawTheme.colors.surfaceRaised.copy(alpha = 0.82f),
|
||||
color = ClawTheme.colors.surfaceRaised,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = null,
|
||||
tonalElevation = 2.dp,
|
||||
shadowElevation = 4.dp,
|
||||
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),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(contentPadding)) {
|
||||
content()
|
||||
|
||||
@@ -4,6 +4,7 @@ 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
|
||||
@@ -189,6 +190,12 @@ 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 =
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package ai.openclaw.app.voice
|
||||
|
||||
import ai.openclaw.app.gateway.ChatSendAck
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
@@ -44,7 +43,7 @@ data class VoiceConversationEntry(
|
||||
)
|
||||
|
||||
/** Coordinates live mic transcription, queued sends, and assistant audio replies. */
|
||||
internal class MicCaptureManager(
|
||||
class MicCaptureManager(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
private val createTranscriptionSession: suspend () -> String,
|
||||
@@ -55,12 +54,11 @@ internal class MicCaptureManager(
|
||||
) -> Unit,
|
||||
private val closeTranscriptionSession: suspend (sessionId: String) -> Unit,
|
||||
/**
|
||||
* Send [message] to the gateway and return the full chat.send ACK.
|
||||
* Send [message] to the gateway and return the run ID.
|
||||
* [onRunIdKnown] is called with the idempotency key *before* the network
|
||||
* round-trip so [pendingRunId] is set before any chat events can arrive.
|
||||
*/
|
||||
private val sendToGateway: suspend (message: String, onRunIdKnown: (String) -> Unit) -> ChatSendAck,
|
||||
private val refreshAfterTerminalSuccess: suspend () -> Unit = {},
|
||||
private val sendToGateway: suspend (message: String, onRunIdKnown: (String) -> Unit) -> String?,
|
||||
private val speakAssistantReply: suspend (String) -> Unit = {},
|
||||
) {
|
||||
companion object {
|
||||
@@ -485,30 +483,24 @@ internal class MicCaptureManager(
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
val ack =
|
||||
val runId =
|
||||
sendToGateway(next) { earlyRunId ->
|
||||
// Called with the idempotency key before chat.send fires so that
|
||||
// pendingRunId is populated before any chat events can arrive.
|
||||
pendingRunId = earlyRunId
|
||||
}
|
||||
val runId = ack.runId
|
||||
// Update to the real runId if the gateway returned a different one.
|
||||
if (runId != null && runId != pendingRunId) pendingRunId = runId
|
||||
when {
|
||||
ack.isTerminalSuccess -> {
|
||||
completePendingTurn()
|
||||
refreshAfterTerminalSuccess()
|
||||
}
|
||||
ack.isTerminalFailure -> {
|
||||
completePendingTurn()
|
||||
_statusText.value = "Send failed: Chat failed before the run started; try again."
|
||||
}
|
||||
runId == null -> {
|
||||
completePendingTurn()
|
||||
}
|
||||
else -> {
|
||||
armPendingRunTimeout(runId)
|
||||
}
|
||||
if (runId == null) {
|
||||
pendingRunTimeoutJob?.cancel()
|
||||
pendingRunTimeoutJob = null
|
||||
removeFirstQueuedMessage()
|
||||
publishQueue()
|
||||
_isSending.value = false
|
||||
pendingAssistantEntryId = null
|
||||
sendQueuedIfIdle()
|
||||
} else {
|
||||
armPendingRunTimeout(runId)
|
||||
}
|
||||
} catch (err: Throwable) {
|
||||
pendingRunTimeoutJob?.cancel()
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package ai.openclaw.app.voice
|
||||
|
||||
import ai.openclaw.app.gateway.ChatSendAck
|
||||
import ai.openclaw.app.gateway.GatewaySession
|
||||
import ai.openclaw.app.gateway.chatSendAckHistorySinceSeconds
|
||||
import ai.openclaw.app.gateway.parseChatSendAck
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
@@ -111,6 +108,7 @@ 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
|
||||
@@ -383,20 +381,11 @@ class TalkModeManager internal constructor(
|
||||
reloadConfig()
|
||||
val startedAt = System.currentTimeMillis().toDouble() / 1000.0
|
||||
val prompt = buildPrompt(command)
|
||||
val ack = sendChat(prompt, session)
|
||||
val runId = ack.runId ?: throw IllegalStateException("chat.send returned no run id")
|
||||
if (ack.isTerminalFailure) {
|
||||
_statusText.value = if (ack.normalizedStatus == "error") "Chat error" else "Aborted"
|
||||
return@launch
|
||||
}
|
||||
val ok = if (ack.isTerminalSuccess) true else waitForChatFinal(runId)
|
||||
val runId = sendChat(prompt, session)
|
||||
val ok = waitForChatFinal(runId)
|
||||
val assistant =
|
||||
consumeRunText(runId)
|
||||
?: waitForAssistantText(
|
||||
session,
|
||||
chatSendAckHistorySinceSeconds(ack, startedAt),
|
||||
if (ok) 12_000 else 25_000,
|
||||
)
|
||||
?: waitForAssistantText(session, startedAt, if (ok) 12_000 else 25_000)
|
||||
if (!assistant.isNullOrBlank()) {
|
||||
val playbackToken = playbackGeneration.incrementAndGet()
|
||||
cancelActivePlayback()
|
||||
@@ -409,9 +398,8 @@ class TalkModeManager internal constructor(
|
||||
}
|
||||
} catch (err: Throwable) {
|
||||
Log.w(tag, "speakWakeCommand failed: ${err.message}")
|
||||
} finally {
|
||||
onComplete()
|
||||
}
|
||||
onComplete()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1616,26 +1604,16 @@ class TalkModeManager internal constructor(
|
||||
try {
|
||||
val startedAt = System.currentTimeMillis().toDouble() / 1000.0
|
||||
Log.d(tag, "chat.send start sessionKey=${mainSessionKey.ifBlank { "main" }} chars=${prompt.length}")
|
||||
val ack = sendChat(prompt, session)
|
||||
val runId = ack.runId ?: throw IllegalStateException("chat.send returned no run id")
|
||||
Log.d(tag, "chat.send ok runId=$runId status=${ack.status}")
|
||||
if (ack.isTerminalFailure) {
|
||||
_statusText.value = if (ack.normalizedStatus == "error") "Chat error" else "Aborted"
|
||||
start()
|
||||
return
|
||||
}
|
||||
val ok = if (ack.isTerminalSuccess) true else waitForChatFinal(runId)
|
||||
val runId = sendChat(prompt, session)
|
||||
Log.d(tag, "chat.send ok runId=$runId")
|
||||
val ok = waitForChatFinal(runId)
|
||||
if (!ok) {
|
||||
Log.w(tag, "chat final timeout runId=$runId; attempting history fallback")
|
||||
}
|
||||
// Use text cached from the final event first — avoids chat.history polling
|
||||
val assistant =
|
||||
consumeRunText(runId)
|
||||
?: waitForAssistantText(
|
||||
session,
|
||||
chatSendAckHistorySinceSeconds(ack, startedAt),
|
||||
if (ok) 12_000 else 25_000,
|
||||
)
|
||||
?: waitForAssistantText(session, startedAt, if (ok) 12_000 else 25_000)
|
||||
if (assistant.isNullOrBlank()) {
|
||||
_statusText.value = "No reply"
|
||||
Log.w(tag, "assistant text timeout runId=$runId")
|
||||
@@ -1701,7 +1679,7 @@ class TalkModeManager internal constructor(
|
||||
private suspend fun sendChat(
|
||||
message: String,
|
||||
session: GatewaySession,
|
||||
): ChatSendAck {
|
||||
): String {
|
||||
val runId = UUID.randomUUID().toString()
|
||||
armPendingRun(runId)
|
||||
val params =
|
||||
@@ -1714,15 +1692,11 @@ class TalkModeManager internal constructor(
|
||||
}
|
||||
try {
|
||||
val res = session.request("chat.send", params.toString())
|
||||
val parsed = parseChatSendAck(json, res)
|
||||
val actualRunId = parsed.runId ?: runId
|
||||
if (actualRunId != runId) {
|
||||
pendingRunId = actualRunId
|
||||
val parsed = parseRunId(res) ?: runId
|
||||
if (parsed != runId) {
|
||||
pendingRunId = parsed
|
||||
}
|
||||
if (parsed.isTerminal) {
|
||||
clearPendingRun(actualRunId)
|
||||
}
|
||||
return parsed.copy(runId = actualRunId)
|
||||
return parsed
|
||||
} catch (err: Throwable) {
|
||||
clearPendingRun(runId)
|
||||
throw err
|
||||
@@ -1803,7 +1777,7 @@ class TalkModeManager internal constructor(
|
||||
|
||||
private suspend fun waitForAssistantText(
|
||||
session: GatewaySession,
|
||||
sinceSeconds: Double?,
|
||||
sinceSeconds: Double,
|
||||
timeoutMs: Long,
|
||||
): String? {
|
||||
val deadline = SystemClock.elapsedRealtime() + timeoutMs
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
<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(
|
||||
resolveOperatorSessionConnectAuth(
|
||||
shouldConnectOperatorSession(
|
||||
NodeRuntime.GatewayConnectAuth(token = "", bootstrapToken = "bootstrap-1", password = ""),
|
||||
storedOperatorToken = "",
|
||||
) != null,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
resolveOperatorSessionConnectAuth(
|
||||
shouldConnectOperatorSession(
|
||||
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null),
|
||||
storedOperatorToken = null,
|
||||
) != null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectsOperatorSessionWhenSharedPasswordOrStoredAuthExists() {
|
||||
assertTrue(
|
||||
resolveOperatorSessionConnectAuth(
|
||||
shouldConnectOperatorSession(
|
||||
NodeRuntime.GatewayConnectAuth(token = "shared-token", bootstrapToken = "bootstrap-1", password = null),
|
||||
storedOperatorToken = null,
|
||||
) != null,
|
||||
),
|
||||
)
|
||||
assertTrue(
|
||||
resolveOperatorSessionConnectAuth(
|
||||
shouldConnectOperatorSession(
|
||||
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = "shared-password"),
|
||||
storedOperatorToken = null,
|
||||
) != null,
|
||||
),
|
||||
)
|
||||
assertTrue(
|
||||
resolveOperatorSessionConnectAuth(
|
||||
shouldConnectOperatorSession(
|
||||
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null),
|
||||
storedOperatorToken = "stored-token",
|
||||
) != null,
|
||||
),
|
||||
)
|
||||
assertTrue(
|
||||
resolveOperatorSessionConnectAuth(
|
||||
shouldConnectOperatorSession(
|
||||
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "", password = null),
|
||||
storedOperatorToken = null,
|
||||
) != null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
package ai.openclaw.app.chat
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ChatControllerTerminalAckTest {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun terminalTimeoutAckRemovesOptimisticUserEchoAndSurfacesFailedAcceptance() =
|
||||
runTest {
|
||||
var requestedMethod: String? = null
|
||||
val controller =
|
||||
ChatController(
|
||||
scope = this,
|
||||
json = json,
|
||||
requestGateway = { method, _ ->
|
||||
requestedMethod = method
|
||||
"""{"runId":"run-timeout","status":"timeout"}"""
|
||||
},
|
||||
)
|
||||
controller.handleGatewayEvent("health", null)
|
||||
|
||||
val accepted =
|
||||
controller.sendMessageAwaitAcceptance(
|
||||
message = "message that times out before start",
|
||||
thinkingLevel = "off",
|
||||
attachments = emptyList(),
|
||||
)
|
||||
|
||||
assertFalse(accepted)
|
||||
assertEquals("chat.send", requestedMethod)
|
||||
assertEquals(0, controller.pendingRunCount.value)
|
||||
assertEquals("Chat failed before the run started; try again.", controller.errorText.value)
|
||||
assertFalse(controller.messages.value.hasUserText("message that times out before start"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun nonTerminalStartedAckRetainsOptimisticUserEchoAndPendingRun() =
|
||||
runTest {
|
||||
val controller =
|
||||
ChatController(
|
||||
scope = this,
|
||||
json = json,
|
||||
requestGateway = { _, _ -> """{"runId":"run-started","status":"started"}""" },
|
||||
)
|
||||
controller.handleGatewayEvent("health", null)
|
||||
|
||||
val accepted =
|
||||
controller.sendMessageAwaitAcceptance(
|
||||
message = "message that started",
|
||||
thinkingLevel = "off",
|
||||
attachments = emptyList(),
|
||||
)
|
||||
|
||||
assertTrue(accepted)
|
||||
assertEquals(1, controller.pendingRunCount.value)
|
||||
assertNull(controller.errorText.value)
|
||||
assertTrue(controller.messages.value.hasUserText("message that started"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun terminalOkAckClearsOptimisticUserEchoAndRefreshesHistory() =
|
||||
runTest {
|
||||
val requestedMethods = mutableListOf<String>()
|
||||
val controller =
|
||||
ChatController(
|
||||
scope = this,
|
||||
json = json,
|
||||
requestGateway = { method, _ ->
|
||||
requestedMethods += method
|
||||
when (method) {
|
||||
"chat.send" -> """{"runId":"run-ok","status":"ok"}"""
|
||||
"chat.history" ->
|
||||
"""
|
||||
{
|
||||
"sessionId": "session-1",
|
||||
"messages": [
|
||||
{ "role": "assistant", "content": "cached success reply", "timestamp": 1 }
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
else -> "{}"
|
||||
}
|
||||
},
|
||||
)
|
||||
controller.handleGatewayEvent("health", null)
|
||||
|
||||
val accepted =
|
||||
controller.sendMessageAwaitAcceptance(
|
||||
message = "message that already completed",
|
||||
thinkingLevel = "off",
|
||||
attachments = emptyList(),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(accepted)
|
||||
assertEquals(listOf("chat.send", "chat.history"), requestedMethods)
|
||||
assertEquals(0, controller.pendingRunCount.value)
|
||||
assertNull(controller.errorText.value)
|
||||
assertFalse(controller.messages.value.hasUserText("message that already completed"))
|
||||
assertTrue(controller.messages.value.any { message -> message.role == "assistant" && message.content.any { part -> part.text == "cached success reply" } })
|
||||
}
|
||||
|
||||
@Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun terminalErrorAckRemovesOptimisticUserEchoAndSurfacesErrorText() =
|
||||
runTest {
|
||||
val controller =
|
||||
ChatController(
|
||||
scope = this,
|
||||
json = json,
|
||||
requestGateway = { _, _ -> """{"runId":"run-error","status":"error"}""" },
|
||||
)
|
||||
controller.handleGatewayEvent("health", null)
|
||||
|
||||
val accepted =
|
||||
controller.sendMessageAwaitAcceptance(
|
||||
message = "message that errors before start",
|
||||
thinkingLevel = "off",
|
||||
attachments = emptyList(),
|
||||
)
|
||||
|
||||
assertFalse(accepted)
|
||||
assertEquals(0, controller.pendingRunCount.value)
|
||||
assertEquals("Chat failed before the run started; try again.", controller.errorText.value)
|
||||
assertFalse(controller.messages.value.hasUserText("message that errors before start"))
|
||||
}
|
||||
|
||||
private fun List<ChatMessage>.hasUserText(text: String): Boolean =
|
||||
any { message ->
|
||||
message.role == "user" && message.content.any { part -> part.text == text }
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
package ai.openclaw.app.gateway
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ChatSendAckTest {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@Test
|
||||
fun parseChatSendAckPreservesNonTerminalStartedStatus() {
|
||||
val ack = parseChatSendAck(json, """{"runId":"run-1","status":"started"}""")
|
||||
|
||||
assertEquals("run-1", ack.runId)
|
||||
assertEquals("started", ack.normalizedStatus)
|
||||
assertFalse(ack.isTerminal)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseChatSendAckMarksOkAsTerminalSuccess() {
|
||||
val ack = parseChatSendAck(json, """{"runId":"run-ok","status":" ok "}""")
|
||||
|
||||
assertEquals("run-ok", ack.runId)
|
||||
assertEquals("ok", ack.normalizedStatus)
|
||||
assertTrue(ack.isTerminal)
|
||||
assertTrue(ack.isTerminalSuccess)
|
||||
assertFalse(ack.isTerminalFailure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseChatSendAckMarksTimeoutAndErrorAsTerminalFailures() {
|
||||
val timeout = parseChatSendAck(json, """{"runId":"run-timeout","status":"timeout"}""")
|
||||
val error = parseChatSendAck(json, """{"runId":"run-error","status":" error "}""")
|
||||
|
||||
assertEquals("run-timeout", timeout.runId)
|
||||
assertTrue(timeout.isTerminal)
|
||||
assertFalse(timeout.isTerminalSuccess)
|
||||
assertTrue(timeout.isTerminalFailure)
|
||||
assertEquals("run-error", error.runId)
|
||||
assertTrue(error.isTerminal)
|
||||
assertFalse(error.isTerminalSuccess)
|
||||
assertTrue(error.isTerminalFailure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cachedOkAckUsesUnfilteredHistoryFallback() {
|
||||
val startedAt = 123.0
|
||||
val ok = parseChatSendAck(json, """{"runId":"run-ok","status":"ok"}""")
|
||||
val started = parseChatSendAck(json, """{"runId":"run-started","status":"started"}""")
|
||||
|
||||
assertNull(chatSendAckHistorySinceSeconds(ok, startedAt))
|
||||
assertEquals(startedAt, chatSendAckHistorySinceSeconds(started, startedAt) ?: -1.0, 0.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseChatSendAckToleratesMalformedPayloads() {
|
||||
val ack = parseChatSendAck(json, "not-json")
|
||||
|
||||
assertNull(ack.runId)
|
||||
assertEquals("", ack.normalizedStatus)
|
||||
assertFalse(ack.isTerminal)
|
||||
assertFalse(ack.isTerminalSuccess)
|
||||
assertFalse(ack.isTerminalFailure)
|
||||
}
|
||||
}
|
||||
@@ -204,18 +204,17 @@ class GatewayConfigResolverTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveScannedSetupCodeResultAcceptsRawSetupCode() {
|
||||
fun resolveScannedSetupCodeAcceptsRawSetupCode() {
|
||||
val setupCode =
|
||||
encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""")
|
||||
|
||||
val resolved = resolveScannedSetupCodeResult(setupCode)
|
||||
val resolved = resolveScannedSetupCode(setupCode)
|
||||
|
||||
assertEquals(setupCode, resolved.setupCode)
|
||||
assertNull(resolved.error)
|
||||
assertEquals(setupCode, resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveScannedSetupCodeResultAcceptsQrJsonPayload() {
|
||||
fun resolveScannedSetupCodeAcceptsQrJsonPayload() {
|
||||
val setupCode =
|
||||
encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""")
|
||||
val qrJson =
|
||||
@@ -228,55 +227,49 @@ class GatewayConfigResolverTest {
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val resolved = resolveScannedSetupCodeResult(qrJson)
|
||||
val resolved = resolveScannedSetupCode(qrJson)
|
||||
|
||||
assertEquals(setupCode, resolved.setupCode)
|
||||
assertNull(resolved.error)
|
||||
assertEquals(setupCode, resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveScannedSetupCodeResultRejectsInvalidInput() {
|
||||
val resolved = resolveScannedSetupCodeResult("not-a-valid-setup-code")
|
||||
assertNull(resolved.setupCode)
|
||||
assertEquals(GatewayEndpointValidationError.INVALID_URL, resolved.error)
|
||||
fun resolveScannedSetupCodeRejectsInvalidInput() {
|
||||
val resolved = resolveScannedSetupCode("not-a-valid-setup-code")
|
||||
assertNull(resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveScannedSetupCodeResultRejectsJsonWithInvalidSetupCode() {
|
||||
fun resolveScannedSetupCodeRejectsJsonWithInvalidSetupCode() {
|
||||
val qrJson = """{"setupCode":"invalid"}"""
|
||||
val resolved = resolveScannedSetupCodeResult(qrJson)
|
||||
assertNull(resolved.setupCode)
|
||||
assertEquals(GatewayEndpointValidationError.INVALID_URL, resolved.error)
|
||||
val resolved = resolveScannedSetupCode(qrJson)
|
||||
assertNull(resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveScannedSetupCodeResultRejectsJsonWithNonStringSetupCode() {
|
||||
fun resolveScannedSetupCodeRejectsJsonWithNonStringSetupCode() {
|
||||
val qrJson = """{"setupCode":{"nested":"value"}}"""
|
||||
val resolved = resolveScannedSetupCodeResult(qrJson)
|
||||
assertNull(resolved.setupCode)
|
||||
assertEquals(GatewayEndpointValidationError.INVALID_URL, resolved.error)
|
||||
val resolved = resolveScannedSetupCode(qrJson)
|
||||
assertNull(resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveScannedSetupCodeResultRejectsNonLoopbackCleartextGateway() {
|
||||
fun resolveScannedSetupCodeRejectsNonLoopbackCleartextGateway() {
|
||||
val setupCode =
|
||||
encodeSetupCode("""{"url":"ws://attacker.example:18789","bootstrapToken":"bootstrap-1"}""")
|
||||
|
||||
val resolved = resolveScannedSetupCodeResult(setupCode)
|
||||
val resolved = resolveScannedSetupCode(setupCode)
|
||||
|
||||
assertNull(resolved.setupCode)
|
||||
assertEquals(GatewayEndpointValidationError.INSECURE_REMOTE_URL, resolved.error)
|
||||
assertNull(resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveScannedSetupCodeResultAcceptsPrivateLanCleartextGateway() {
|
||||
fun resolveScannedSetupCodeAcceptsPrivateLanCleartextGateway() {
|
||||
val setupCode =
|
||||
encodeSetupCode("""{"url":"ws://192.168.31.100:18789","bootstrapToken":"bootstrap-1"}""")
|
||||
|
||||
val resolved = resolveScannedSetupCodeResult(setupCode)
|
||||
val resolved = resolveScannedSetupCode(setupCode)
|
||||
|
||||
assertEquals(setupCode, resolved.setupCode)
|
||||
assertNull(resolved.error)
|
||||
assertEquals(setupCode, resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
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
|
||||
@@ -107,7 +105,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.ProvidersModels, providersRow.settingsRoute)
|
||||
assertEquals(SettingsRoute.Gateway, providersRow.settingsRoute)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -159,206 +157,10 @@ 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))
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package ai.openclaw.app.voice
|
||||
|
||||
import ai.openclaw.app.gateway.ChatSendAck
|
||||
import android.Manifest
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
@@ -35,7 +34,7 @@ class MicCaptureManagerTest {
|
||||
sendToGateway = { message, onRunIdKnown ->
|
||||
sentMessages += message
|
||||
onRunIdKnown("run-1")
|
||||
ChatSendAck(runId = "run-1", status = "started")
|
||||
null
|
||||
},
|
||||
)
|
||||
|
||||
@@ -85,7 +84,7 @@ class MicCaptureManagerTest {
|
||||
sendToGateway = { message, onRunIdKnown ->
|
||||
sentMessages += message
|
||||
onRunIdKnown("run-1")
|
||||
ChatSendAck(runId = "run-1", status = "started")
|
||||
"run-1"
|
||||
},
|
||||
)
|
||||
|
||||
@@ -112,7 +111,7 @@ class MicCaptureManagerTest {
|
||||
sendToGateway = { message, onRunIdKnown ->
|
||||
sentMessages += message
|
||||
onRunIdKnown("run-voice-e2e")
|
||||
ChatSendAck(runId = "run-voice-e2e", status = "started")
|
||||
"run-voice-e2e"
|
||||
},
|
||||
)
|
||||
|
||||
@@ -135,88 +134,6 @@ class MicCaptureManagerTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun terminalGatewayTimeoutSendDoesNotAcceptDelayedOldRunEvents() =
|
||||
runTest {
|
||||
val manager =
|
||||
createManager(
|
||||
scope = this,
|
||||
sendToGateway = { _, onRunIdKnown ->
|
||||
onRunIdKnown("run-terminal")
|
||||
ChatSendAck(runId = "run-terminal", status = "timeout")
|
||||
},
|
||||
)
|
||||
|
||||
manager.onGatewayConnectionChanged(true)
|
||||
manager.submitTranscribedMessage("terminal ack message")
|
||||
runCurrent()
|
||||
|
||||
assertNull(privateField<String?>(manager, "pendingRunId"))
|
||||
assertEquals(false, manager.isSending.value)
|
||||
assertEquals("Send failed: Chat failed before the run started; try again.", manager.statusText.value)
|
||||
|
||||
manager.handleGatewayEvent("chat", chatFinalPayload(runId = "run-terminal", text = "stale reply"))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(
|
||||
listOf(VoiceConversationRole.User),
|
||||
manager.conversation.value.map { it.role },
|
||||
)
|
||||
assertEquals(
|
||||
"terminal ack message",
|
||||
manager.conversation.value
|
||||
.single()
|
||||
.text,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun terminalGatewayErrorSurfacesFailureWithoutWaitingForRunEvents() =
|
||||
runTest {
|
||||
val manager =
|
||||
createManager(
|
||||
scope = this,
|
||||
sendToGateway = { _, onRunIdKnown ->
|
||||
onRunIdKnown("run-error")
|
||||
ChatSendAck(runId = "run-error", status = "error")
|
||||
},
|
||||
)
|
||||
|
||||
manager.onGatewayConnectionChanged(true)
|
||||
manager.submitTranscribedMessage("terminal error message")
|
||||
runCurrent()
|
||||
|
||||
assertNull(privateField<String?>(manager, "pendingRunId"))
|
||||
assertEquals(false, manager.isSending.value)
|
||||
assertEquals("Send failed: Chat failed before the run started; try again.", manager.statusText.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun terminalGatewayOkRefreshesHistoryWithoutWaitingForRunEvents() =
|
||||
runTest {
|
||||
var refreshCalls = 0
|
||||
val manager =
|
||||
createManager(
|
||||
scope = this,
|
||||
sendToGateway = { _, onRunIdKnown ->
|
||||
onRunIdKnown("run-ok")
|
||||
ChatSendAck(runId = "run-ok", status = "ok")
|
||||
},
|
||||
refreshAfterTerminalSuccess = { refreshCalls += 1 },
|
||||
)
|
||||
|
||||
manager.onGatewayConnectionChanged(true)
|
||||
manager.submitTranscribedMessage("terminal ok message")
|
||||
runCurrent()
|
||||
|
||||
assertNull(privateField<String?>(manager, "pendingRunId"))
|
||||
assertEquals(false, manager.isSending.value)
|
||||
assertEquals(1, refreshCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pcm16FramesAreEncodedAsPcmuFrames() {
|
||||
val manager = createManager()
|
||||
@@ -313,11 +230,10 @@ class MicCaptureManagerTest {
|
||||
scope: CoroutineScope = CoroutineScope(Dispatchers.Unconfined),
|
||||
createTranscriptionSession: suspend () -> String = { "transcription-1" },
|
||||
closeTranscriptionSession: suspend (String) -> Unit = { _ -> },
|
||||
sendToGateway: suspend (String, (String) -> Unit) -> ChatSendAck = { _, onRunIdKnown ->
|
||||
sendToGateway: suspend (String, (String) -> Unit) -> String? = { _, onRunIdKnown ->
|
||||
onRunIdKnown("run-1")
|
||||
ChatSendAck(runId = "run-1", status = "started")
|
||||
"run-1"
|
||||
},
|
||||
refreshAfterTerminalSuccess: suspend () -> Unit = {},
|
||||
): MicCaptureManager =
|
||||
MicCaptureManager(
|
||||
context =
|
||||
@@ -329,7 +245,6 @@ class MicCaptureManagerTest {
|
||||
appendTranscriptionAudio = { _, _, _ -> },
|
||||
closeTranscriptionSession = closeTranscriptionSession,
|
||||
sendToGateway = sendToGateway,
|
||||
refreshAfterTerminalSuccess = refreshAfterTerminalSuccess,
|
||||
)
|
||||
|
||||
private fun setPrivateField(
|
||||
|
||||
@@ -5,7 +5,7 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "ai.openclaw.app.benchmark"
|
||||
compileSdk = 36
|
||||
compileSdk = 37
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 31
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
Maintenance update for the current OpenClaw Android release.
|
||||
OpenClaw is now available on Android.
|
||||
|
||||
Connect to your OpenClaw Gateway to chat with your assistant, use realtime Talk mode, review approvals, and bring Android device capabilities like camera, location, screen, and notifications into your private automation workflows.
|
||||
|
||||
@@ -4,7 +4,7 @@ androidx-activity = "1.13.0"
|
||||
androidx-benchmark = "1.4.1"
|
||||
androidx-camera = "1.6.0"
|
||||
androidx-compose-bom = "2026.05.01"
|
||||
androidx-core = "1.18.0"
|
||||
androidx-core = "1.19.0"
|
||||
androidx-exifinterface = "1.4.2"
|
||||
androidx-lifecycle = "2.10.0"
|
||||
androidx-security = "1.1.0"
|
||||
|
||||
@@ -164,7 +164,7 @@ run_mode() {
|
||||
no_connect_flag=false
|
||||
fi
|
||||
|
||||
adb shell run-as "$PACKAGE_NAME" am broadcast --user 0 \
|
||||
adb shell am broadcast \
|
||||
-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 run-as "$PACKAGE_NAME" am broadcast --user 0 -a "$RUN_ACTION" -n "$RECEIVER" --es mode stop >/dev/null
|
||||
adb shell am broadcast -a "$RUN_ACTION" -n "$RECEIVER" --es mode stop >/dev/null
|
||||
fi
|
||||
|
||||
echo "$ARTIFACT_DIR"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"version": "2026.6.9",
|
||||
"versionCode": 2026060901
|
||||
"version": "2026.6.2",
|
||||
"versionCode": 2026060201
|
||||
}
|
||||
|
||||
@@ -2,24 +2,8 @@ parent_config: ../../config/swiftlint.yml
|
||||
|
||||
included:
|
||||
- Sources
|
||||
- ShareExtension
|
||||
- ActivityWidget
|
||||
- WatchApp
|
||||
- ../shared/OpenClawKit/Sources/OpenClawChatUI
|
||||
|
||||
excluded:
|
||||
- ../macos
|
||||
- ../shared/ClawdisNodeKit/Sources
|
||||
|
||||
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,30 +74,23 @@ struct OpenClawLiveActivity: Widget {
|
||||
private func statusIcon(state: OpenClawActivityAttributes.ContentState) -> some View {
|
||||
if state.isConnecting {
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
.foregroundStyle(OpenClawActivityStyle.info)
|
||||
.foregroundStyle(.cyan)
|
||||
} else if state.isDisconnected {
|
||||
Image(systemName: "wifi.slash")
|
||||
.foregroundStyle(OpenClawActivityStyle.danger)
|
||||
.foregroundStyle(.red)
|
||||
} else if state.isIdle {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(OpenClawActivityStyle.ok)
|
||||
.foregroundStyle(.green)
|
||||
} else {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(OpenClawActivityStyle.warn)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
private func dotColor(state: OpenClawActivityAttributes.ContentState) -> Color {
|
||||
if state.isDisconnected { return OpenClawActivityStyle.danger }
|
||||
if state.isConnecting { return OpenClawActivityStyle.info }
|
||||
if state.isIdle { return OpenClawActivityStyle.ok }
|
||||
return OpenClawActivityStyle.warn
|
||||
if state.isDisconnected { return .red }
|
||||
if state.isConnecting { return .cyan }
|
||||
if state.isIdle { return .green }
|
||||
return .orange
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
# OpenClaw iOS Changelog
|
||||
|
||||
## 2026.6.9 - 2026-06-20
|
||||
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
- Added Apple Watch controls for common agent actions.
|
||||
- Improved Gateway setup, notification settings, and share-extension identity handling.
|
||||
- Updated the Watch app integration for current Xcode compatibility.
|
||||
|
||||
## 2026.6.2 - 2026-06-02
|
||||
|
||||
OpenClaw is now available on iPhone.
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"platform": "IOS",
|
||||
"profileKey": "OPENCLAW_APP_PROFILE",
|
||||
"profileName": "OpenClaw App Store ai.openclawfoundation.app",
|
||||
"capabilities": ["PUSH_NOTIFICATIONS", "APP_GROUPS", "APP_ATTEST"],
|
||||
"capabilities": ["PUSH_NOTIFICATIONS", "APP_GROUPS"],
|
||||
"appGroups": ["group.ai.openclawfoundation.app.shared"]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -6,7 +6,6 @@ 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
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// Source of truth: apps/ios/version.json
|
||||
// Generated by scripts/ios-sync-versioning.ts.
|
||||
|
||||
OPENCLAW_IOS_VERSION = 2026.6.9
|
||||
OPENCLAW_MARKETING_VERSION = 2026.6.9
|
||||
OPENCLAW_IOS_VERSION = 2026.6.2
|
||||
OPENCLAW_MARKETING_VERSION = 2026.6.2
|
||||
OPENCLAW_BUILD_VERSION = 1
|
||||
|
||||
#include? "../build/Version.xcconfig"
|
||||
|
||||
@@ -67,9 +67,9 @@ 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 `OpenClawPushMode=appStore`, which derives relay transport, official distribution, the canonical production relay, production APNs, production relay profile, `appleStrict` proof, and the App-Attest-capable entitlement file.
|
||||
- App Store release also switches the app to `OpenClawPushTransport=relay`, `OpenClawPushDistribution=official`, `OpenClawPushAPNsEnvironment=production`, and a production `aps-environment` entitlement.
|
||||
- `pnpm ios:release:upload` generates App Store screenshots and uploads release notes before archiving and uploading the IPA.
|
||||
- The release archive is validated before upload by inspecting the exported IPA's signed entitlements, embedded App Store profile, and push mode. The upload fails if the IPA is not an App Store production relay build.
|
||||
- `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.
|
||||
- The release flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`.
|
||||
- `apps/ios/version.json` is the pinned iOS release version source.
|
||||
@@ -83,8 +83,9 @@ Release behavior:
|
||||
|
||||
Relay behavior for App Store builds:
|
||||
|
||||
- App Store release builds use the canonical hosted relay at `https://ios-push-relay.openclaw.ai`.
|
||||
- App Store release builds reject custom relay URL overrides. Future self-hosted relay support should use a separate explicit release path, not the public App Store build lane.
|
||||
- Release builds default to `https://ios-push-relay.openclaw.ai`.
|
||||
- Optional custom relay override: `OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com`
|
||||
This must be a plain `https://host[:port][/path]` base URL without whitespace, query params, fragments, or xcconfig metacharacters.
|
||||
|
||||
Signing setup commands:
|
||||
|
||||
@@ -101,7 +102,6 @@ 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,23 +157,29 @@ 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 must also have App Attest enabled.
|
||||
The main app and share extension must both be associated with the App Group pinned in `apps/ios/Config/AppStoreSigning.json`.
|
||||
|
||||
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.
|
||||
|
||||
4. If you are starting a brand-new production release train, pin iOS to the current gateway version first:
|
||||
4. Optional: set a custom official relay URL for the build. If unset, the release flow uses `https://ios-push-relay.openclaw.ai`.
|
||||
|
||||
```bash
|
||||
export OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com
|
||||
```
|
||||
|
||||
5. If you are starting a brand-new production release train, pin iOS to the current gateway version first:
|
||||
|
||||
```bash
|
||||
pnpm ios:version:pin -- --from-gateway
|
||||
```
|
||||
|
||||
5. Upload the build:
|
||||
6. Upload the build:
|
||||
|
||||
```bash
|
||||
pnpm ios:release:upload
|
||||
```
|
||||
|
||||
6. Expected behavior:
|
||||
7. Expected behavior:
|
||||
- Fastlane reads `apps/ios/version.json`
|
||||
- verifies synced iOS versioning artifacts
|
||||
- resolves the next App Store Connect build number for that short version
|
||||
@@ -181,16 +187,15 @@ pnpm ios:release:upload
|
||||
- uploads release notes and screenshots to the editable App Store version
|
||||
- generates `apps/ios/build/AppStoreRelease.xcconfig`
|
||||
- archives `OpenClaw`
|
||||
- validates the exported IPA's push mode, signed entitlements, and embedded App Store profile
|
||||
- uploads the IPA to App Store Connect for TestFlight/App Review use
|
||||
- leaves App Review submission for a maintainer to complete manually
|
||||
|
||||
7. Expected outputs after a successful run:
|
||||
8. Expected outputs after a successful run:
|
||||
- `apps/ios/build/app-store/OpenClaw-<version>.ipa`
|
||||
- `apps/ios/build/app-store/OpenClaw-<version>.app.dSYM.zip`
|
||||
- Fastlane log line like `Uploaded iOS App Store build: version=<version> short=<short> build=<build>`
|
||||
|
||||
8. If this is a fresh clone on a maintainer machine that already works elsewhere, it is OK to copy the non-secret `apps/ios/fastlane/.env` from another trusted local clone on the same Mac. The Keychain-backed private key remains machine-local and is not stored in the repo.
|
||||
9. If this is a fresh clone on a maintainer machine that already works elsewhere, it is OK to copy the non-secret `apps/ios/fastlane/.env` from another trusted local clone on the same Mac. The Keychain-backed private key remains machine-local and is not stored in the repo.
|
||||
|
||||
## iOS Versioning Workflow
|
||||
|
||||
@@ -238,15 +243,14 @@ 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 Debug builds default to `OpenClawPushMode=localSandbox`, direct APNs registration, and a development `aps-environment` entitlement. Local/manual Release builds default to `OpenClawPushMode=localProduction` and direct production APNs registration.
|
||||
- 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.
|
||||
- If push capability or provisioning is wrong, APNs registration fails at runtime (check Xcode logs for `APNs registration failed`).
|
||||
- The gateway host also needs direct APNs auth configured separately with `OPENCLAW_APNS_TEAM_ID`, `OPENCLAW_APNS_KEY_ID`, and either `OPENCLAW_APNS_PRIVATE_KEY_P8` or `OPENCLAW_APNS_PRIVATE_KEY_PATH`.
|
||||
- Recommended gateway-host storage for the APNs `.p8` file is `~/.openclaw/credentials/apns/AuthKey_<KEYID>.p8` with restrictive permissions, then point `OPENCLAW_APNS_PRIVATE_KEY_PATH` at that file.
|
||||
- `apps/ios/fastlane/.env` only covers App Store Connect / Fastlane auth; it does not provide gateway APNs credentials for local direct-push testing.
|
||||
- Debug builds default to sandbox APNs through `OpenClawPushMode=localSandbox`; Release builds default to production APNs through `OpenClawPushMode=localProduction`.
|
||||
- Debug builds default to `OpenClawPushAPNsEnvironment=sandbox`; Release builds default to `production`.
|
||||
|
||||
## APNs Expectations For Official Builds
|
||||
|
||||
@@ -255,7 +259,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.
|
||||
- App Store release mode uses the internal `production` relay profile, production APNs, App Attest, and a StoreKit app transaction JWS during registration.
|
||||
- Relay mode requires a reachable relay base URL and uses App Attest plus 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,7 +6,6 @@
|
||||
|
||||
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 ? OpenClawBrand.accent : Color.secondary.opacity(0.35))
|
||||
.fill(isOn ? Color.accentColor : 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: OpenClawBrand.info
|
||||
case .ready: Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user