mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-25 00:34:16 +08:00
Compare commits
1 Commits
codex/tool
...
fix/plugin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54e5105c12 |
@@ -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
|
||||
|
||||
@@ -146,7 +139,7 @@ Default guidance:
|
||||
|
||||
Default Completeness bands:
|
||||
|
||||
- `Clawesome` (95-100): complete across expected workflows, variants, and
|
||||
- `Lovable` (95-100): complete across expected workflows, variants, and
|
||||
recovery branches, with only minor polish gaps.
|
||||
- `Stable` (80-95): the expected workflow set is broadly present, with only
|
||||
bounded missing branches.
|
||||
@@ -159,20 +152,19 @@ 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:
|
||||
|
||||
- `Clawesome`: 95-100
|
||||
- `Lovable`: 95-100
|
||||
- `Stable`: 80-95
|
||||
- `Beta`: 70-80
|
||||
- `Alpha`: 50-70
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
---
|
||||
name: openclaw-ci-limits
|
||||
description: Manage OpenClaw GitHub Actions and Blacksmith CI capacity, runner-registration budgets, fanout caps, main-push debounce, shard sizing, hosted-runner offload, queue health, and safe ramp-down/ramp-up changes. Use when tuning `.github/workflows/*`, `docs/ci.md`, CI runner labels, matrix `max-parallel`, ClawSweeper/Blacksmith burst protection, CodeQL runner placement, or investigating slow/queued OpenClaw CI.
|
||||
---
|
||||
|
||||
# OpenClaw CI Limits
|
||||
|
||||
Use this skill for CI capacity changes, not ordinary test failure triage. The
|
||||
goal is to keep OpenClaw fast while staying below GitHub's self-hosted runner
|
||||
registration edge limit.
|
||||
|
||||
## Core Facts
|
||||
|
||||
- The scarce resource is Blacksmith runner registrations, not Blacksmith vCPU
|
||||
capacity.
|
||||
- GitHub runner registrations are capped at 1,500 per 5 minutes per repository,
|
||||
organization, or enterprise. The `openclaw` organization shares one bucket.
|
||||
- Core REST quota does not draw down this bucket. Check
|
||||
`actions_runner_registration` separately; core quota can be healthy while
|
||||
runner registration is throttled.
|
||||
- Use 1,000 registrations per 5 minutes as the operating target. Leave the last
|
||||
third for other repos, retries, and burst overlap.
|
||||
- Jobs that route, notify, summarize, choose shards, or run short CodeQL quality
|
||||
scans should stay on GitHub-hosted runners unless measured evidence says
|
||||
Blacksmith is required.
|
||||
|
||||
## First Checks
|
||||
|
||||
Before changing CI, collect current pressure:
|
||||
|
||||
```bash
|
||||
ghx api rate_limit --jq '{core:.resources.core,graphql:.resources.graphql,search:.resources.search,actions_runner_registration:.resources.actions_runner_registration}'
|
||||
ghx run list -R openclaw/openclaw --limit 20 --json databaseId,status,conclusion,workflowName,event,headBranch,createdAt,updatedAt,url
|
||||
ghx run list -R openclaw/clawsweeper --limit 20 --json databaseId,status,conclusion,workflowName,event,headBranch,createdAt,updatedAt,url
|
||||
curl -fsS https://clawsweeper.openclaw.ai/api/status | jq '{generated_at,fleet,diagnostics:{errors:.diagnostics.errors}}'
|
||||
curl -fsS https://clawsweeper.openclaw.ai/api/exact-review-queue | jq '.'
|
||||
node scripts/ci-run-timings.mjs --latest-main
|
||||
node scripts/ci-run-timings.mjs --recent 10
|
||||
```
|
||||
|
||||
Read:
|
||||
|
||||
- `.github/workflows/ci.yml`
|
||||
- `.github/workflows/codeql-critical-quality.yml`
|
||||
- `docs/ci.md`
|
||||
- `test/scripts/ci-workflow-guards.test.ts`
|
||||
- touched planner files under `scripts/lib/*ci*`, `scripts/lib/*test-plan*`, or
|
||||
`scripts/ci-changed-scope.mjs`
|
||||
|
||||
## Diagnose The Bottleneck
|
||||
|
||||
Classify the issue before changing caps:
|
||||
|
||||
- **Runner-registration throttle:** many jobs queued before runner assignment,
|
||||
Blacksmith/GitHub reports 403/429 or spam-style 422 responses from
|
||||
`generate-jitconfig`, and API core quota is still healthy. Treat 422 as this
|
||||
signal only when the request payload is otherwise valid. Fix burstiness and
|
||||
Blacksmith job count.
|
||||
- **Blacksmith capacity:** Blacksmith dashboard shows actual concurrency caps or
|
||||
unavailable capacity. Do not solve this with GitHub workflow fanout alone.
|
||||
- **OpenClaw test runtime:** jobs start quickly but one lane dominates wall time.
|
||||
Use `$openclaw-test-performance` instead of runner tuning.
|
||||
- **Real failing CI:** one job fails after starting. Use `$github:gh-fix-ci` or
|
||||
`$openclaw-testing`, not this skill.
|
||||
- **ClawSweeper backlog:** exact-review queue grows while CI is healthy. Tune
|
||||
ClawSweeper workers in `openclaw/clawsweeper`, not OpenClaw CI.
|
||||
|
||||
## Registration Budget Math
|
||||
|
||||
Estimate worst-case registrations for a change before editing:
|
||||
|
||||
```text
|
||||
new Blacksmith registrations ~= number of Blacksmith jobs that can become queued
|
||||
inside one 5 minute window
|
||||
```
|
||||
|
||||
For matrix jobs, count every row that can start in the 5-minute window.
|
||||
`strategy.max-parallel` only caps simultaneous rows; short rows can turn over
|
||||
and register more runners before the window resets. Use job duration, retries,
|
||||
and queue turnover to justify any lower estimate. Add non-matrix Blacksmith jobs
|
||||
such as `preflight`, `security-fast`, `build-artifacts`, and platform lanes.
|
||||
|
||||
For repeated pushes, multiply by the number of runs expected to reach
|
||||
Blacksmith admission in the same 5-minute window, including runs canceled after
|
||||
admission. The debounce only suppresses pushes that arrive while
|
||||
`runner-admission` is still sleeping; once Blacksmith jobs register, those
|
||||
registrations are spent even if a later push cancels the run. If timing is
|
||||
uncertain, count every sequential push in the window.
|
||||
|
||||
Reject a change unless the org-level worst case stays below 1,000 registrations
|
||||
per 5 minutes with headroom for ClawSweeper, ClawHub, Clownfish, OpenClaw RTT,
|
||||
and Clawbench.
|
||||
|
||||
## Safe Levers
|
||||
|
||||
Prefer these in order:
|
||||
|
||||
1. Add or preserve concurrency groups that cancel superseded PR and canonical
|
||||
`main` runs before Blacksmith work starts.
|
||||
2. Keep the `runner-admission` hosted debounce for canonical `main` pushes.
|
||||
Change `OPENCLAW_MAIN_CI_DEBOUNCE_SECONDS` only with evidence.
|
||||
3. Move high-frequency, short, non-build jobs to `ubuntu-24.04`.
|
||||
4. Reduce matrix rows by bundling related tests inside one runner job when the
|
||||
combined job stays under timeout and keeps useful failure names.
|
||||
5. Lower `strategy.max-parallel` for bursty Blacksmith matrices.
|
||||
6. Right-size runners from timing evidence. Use fewer/larger jobs only when
|
||||
elapsed time improves enough to justify registration count.
|
||||
7. Split truly slow tests with `$openclaw-test-performance`; do not hide a slow
|
||||
test problem by registering more runners.
|
||||
|
||||
Do not:
|
||||
|
||||
- add another Blacksmith installation expecting a higher registration bucket;
|
||||
- move CodeQL Critical Quality back to Blacksmith;
|
||||
- raise all `max-parallel` values at once;
|
||||
- make manual `workflow_dispatch` runs cancel normal push/PR validation;
|
||||
- delete coverage just to reduce runner count;
|
||||
- treat cancelled superseded runs as failures without checking the newest run
|
||||
for the same ref.
|
||||
|
||||
## Current OpenClaw Knobs
|
||||
|
||||
These are intentionally guarded by `test/scripts/ci-workflow-guards.test.ts`:
|
||||
|
||||
- `CI` concurrency key version and `cancel-in-progress` for PRs and canonical
|
||||
`main` pushes.
|
||||
- `runner-admission` on `ubuntu-24.04` with
|
||||
`OPENCLAW_MAIN_CI_DEBOUNCE_SECONDS=90`.
|
||||
- `preflight` and `security-fast` needing `runner-admission`.
|
||||
- CI matrix caps: fast/check lanes at 8, compact Node PR plan at current caps,
|
||||
Windows and Android at 2.
|
||||
- `build-artifacts` on `blacksmith-16vcpu-ubuntu-2404`.
|
||||
- lower-weight Node/check shards on `blacksmith-4vcpu-ubuntu-2404`.
|
||||
- heavy retained Linux/Android shards on `blacksmith-8vcpu-ubuntu-2404`.
|
||||
- CodeQL Critical Quality on `ubuntu-24.04` with no `blacksmith-` labels.
|
||||
|
||||
When changing one knob, update `docs/ci.md` and the guard test in the same PR.
|
||||
|
||||
## Validation
|
||||
|
||||
For workflow-only or docs/skill-only changes in a Codex worktree:
|
||||
|
||||
```bash
|
||||
node scripts/run-vitest.mjs test/scripts/ci-workflow-guards.test.ts
|
||||
node scripts/check-workflows.mjs
|
||||
node scripts/docs-list.js
|
||||
./node_modules/.bin/oxfmt --check .github/workflows/ci.yml .github/workflows/codeql-critical-quality.yml docs/ci.md test/scripts/ci-workflow-guards.test.ts .agents/skills/openclaw-ci-limits/SKILL.md .agents/skills/openclaw-ci-limits/agents/openai.yaml
|
||||
git diff --check
|
||||
```
|
||||
|
||||
If `pnpm docs:list` tries to reconcile dependencies in a linked Codex worktree,
|
||||
stop and use `node scripts/docs-list.js`.
|
||||
|
||||
For a PR before requesting maintainer approval:
|
||||
|
||||
```bash
|
||||
.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
|
||||
ghx pr checks <pr> -R openclaw/openclaw --watch --interval 15
|
||||
```
|
||||
|
||||
Use hosted exact-head gates for CI workflow tuning. Do not burn local
|
||||
`pnpm test` on unrelated full-suite proof.
|
||||
|
||||
Only after the maintainer explicitly asks you to prepare or land the PR, run the
|
||||
repo-native mutating wrapper:
|
||||
|
||||
```bash
|
||||
scripts/pr review-init <pr>
|
||||
scripts/pr review-artifacts-init <pr>
|
||||
scripts/pr review-validate-artifacts <pr>
|
||||
OPENCLAW_TESTBOX=1 scripts/pr prepare-run <pr>
|
||||
```
|
||||
|
||||
`prepare-run` can push a prepared commit to the PR branch. Only run
|
||||
`scripts/pr merge-run <pr>` after the maintainer has explicitly asked you to
|
||||
land the PR. Both commands mutate GitHub state.
|
||||
|
||||
## Post-Land Monitoring
|
||||
|
||||
After merge, watch at least one fresh main cycle and the adjacent repos:
|
||||
|
||||
```bash
|
||||
ghx run list -R openclaw/openclaw --limit 20 --json databaseId,status,conclusion,workflowName,event,headBranch,createdAt,updatedAt,url
|
||||
for repo in openclaw/clawsweeper openclaw/clawhub openclaw/clownfish openclaw/openclaw-rtt openclaw/clawbench; do
|
||||
ghx run list -R "$repo" --limit 12 --json databaseId,status,conclusion,workflowName,event,headBranch,createdAt,updatedAt,url
|
||||
done
|
||||
curl -fsS https://clawsweeper.openclaw.ai/api/exact-review-queue | jq '.'
|
||||
```
|
||||
|
||||
Report:
|
||||
|
||||
- exact PR/commit landed;
|
||||
- expected registration reduction or added headroom;
|
||||
- CI run status and slowest/queued jobs;
|
||||
- ClawSweeper queue pending, dispatching, leased, oldest pending age;
|
||||
- any real failures that remain outside runner registration.
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "OpenClaw CI Limits"
|
||||
short_description: "Tune OpenClaw CI fanout and runner budgets"
|
||||
default_prompt: "Use $openclaw-ci-limits to inspect OpenClaw CI pressure, tune runner-registration fanout safely, and document the exact validation before landing."
|
||||
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.
|
||||
@@ -1,23 +0,0 @@
|
||||
# OpenClaw Maturity Scorecard Agent
|
||||
|
||||
You are refreshing the OpenClaw maturity score source for a release scorecard.
|
||||
|
||||
Goal: use the `$claw-score` skill to refresh `qa/maturity-scores.yaml` for every active surface in `taxonomy.yaml`, using the current repository and the release evidence artifacts in `.artifacts/maturity-evidence`.
|
||||
|
||||
Allowed tracked paths:
|
||||
|
||||
- `qa/maturity-scores.yaml`
|
||||
|
||||
Hard limits:
|
||||
|
||||
- Do not edit generated docs, taxonomy, workflows, scripts, package metadata, lockfiles, tests, or application code.
|
||||
- Do not render docs. The workflow renders docs after validating the score source.
|
||||
- Keep the score source schema valid for QA Lab maturity score validation.
|
||||
|
||||
Required workflow:
|
||||
|
||||
1. Use the `$claw-score` skill before editing.
|
||||
2. Read `taxonomy.yaml`, any existing maturity score file, and the release evidence artifacts.
|
||||
3. Refresh scores for every active surface in `taxonomy.yaml`.
|
||||
4. Run the QA Lab maturity score validation used by this repository.
|
||||
5. If no defensible score update is possible, leave a valid `qa/maturity-scores.yaml` and explain the uncertainty in the final message.
|
||||
1
.github/labeler.yml
vendored
1
.github/labeler.yml
vendored
@@ -118,7 +118,6 @@
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/qa-lab/**"
|
||||
- "qa/scenarios/**"
|
||||
- "docs/maturity/**"
|
||||
- "docs/concepts/qa-e2e-automation.md"
|
||||
- "docs/concepts/personal-agent-benchmark-pack.md"
|
||||
- "docs/channels/qa-channel.md"
|
||||
|
||||
19
.github/workflows/ci-build-artifacts-testbox.yml
vendored
19
.github/workflows/ci-build-artifacts-testbox.yml
vendored
@@ -198,19 +198,10 @@ jobs:
|
||||
"+refs/heads/main:refs/remotes/origin/main"
|
||||
|
||||
node_bin="$(dirname "$(node -p 'process.execPath')")"
|
||||
link_node_tool() {
|
||||
local tool="$1"
|
||||
local source="$node_bin/$tool"
|
||||
local target="/usr/local/bin/$tool"
|
||||
if [ -e "$target" ] && [ "$(readlink -f "$source")" = "$(readlink -f "$target")" ]; then
|
||||
return
|
||||
fi
|
||||
sudo ln -sf "$source" "$target"
|
||||
}
|
||||
link_node_tool node
|
||||
link_node_tool npm
|
||||
link_node_tool npx
|
||||
link_node_tool corepack
|
||||
sudo ln -sf "$node_bin/node" /usr/local/bin/node
|
||||
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
|
||||
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
|
||||
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
|
||||
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
|
||||
#!/usr/bin/env bash
|
||||
exec /usr/local/bin/corepack pnpm "$@"
|
||||
@@ -270,6 +261,6 @@ jobs:
|
||||
|
||||
- name: Run Testbox
|
||||
uses: useblacksmith/run-testbox@3f60ff9ceb2c10c3feefa87dc0c6490cffae059d
|
||||
if: always()
|
||||
if: success()
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
19
.github/workflows/ci-check-arm-testbox.yml
vendored
19
.github/workflows/ci-check-arm-testbox.yml
vendored
@@ -116,19 +116,10 @@ jobs:
|
||||
"+refs/heads/main:refs/remotes/origin/main"
|
||||
|
||||
node_bin="$(dirname "$(node -p 'process.execPath')")"
|
||||
link_node_tool() {
|
||||
local tool="$1"
|
||||
local source="$node_bin/$tool"
|
||||
local target="/usr/local/bin/$tool"
|
||||
if [ -e "$target" ] && [ "$(readlink -f "$source")" = "$(readlink -f "$target")" ]; then
|
||||
return
|
||||
fi
|
||||
sudo ln -sf "$source" "$target"
|
||||
}
|
||||
link_node_tool node
|
||||
link_node_tool npm
|
||||
link_node_tool npx
|
||||
link_node_tool corepack
|
||||
sudo ln -sf "$node_bin/node" /usr/local/bin/node
|
||||
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
|
||||
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
|
||||
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
|
||||
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
|
||||
#!/usr/bin/env bash
|
||||
exec /usr/local/bin/corepack pnpm "$@"
|
||||
@@ -188,6 +179,6 @@ jobs:
|
||||
|
||||
- name: Run Testbox
|
||||
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
|
||||
if: always()
|
||||
if: success()
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
21
.github/workflows/ci-check-testbox.yml
vendored
21
.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
|
||||
@@ -105,19 +105,10 @@ jobs:
|
||||
"+refs/heads/main:refs/remotes/origin/main"
|
||||
|
||||
node_bin="$(dirname "$(node -p 'process.execPath')")"
|
||||
link_node_tool() {
|
||||
local tool="$1"
|
||||
local source="$node_bin/$tool"
|
||||
local target="/usr/local/bin/$tool"
|
||||
if [ -e "$target" ] && [ "$(readlink -f "$source")" = "$(readlink -f "$target")" ]; then
|
||||
return
|
||||
fi
|
||||
sudo ln -sf "$source" "$target"
|
||||
}
|
||||
link_node_tool node
|
||||
link_node_tool npm
|
||||
link_node_tool npx
|
||||
link_node_tool corepack
|
||||
sudo ln -sf "$node_bin/node" /usr/local/bin/node
|
||||
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
|
||||
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
|
||||
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
|
||||
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
|
||||
#!/usr/bin/env bash
|
||||
exec /usr/local/bin/corepack pnpm "$@"
|
||||
@@ -177,6 +168,6 @@ jobs:
|
||||
|
||||
- name: Run Testbox
|
||||
uses: useblacksmith/run-testbox@3f60ff9ceb2c10c3feefa87dc0c6490cffae059d
|
||||
if: always()
|
||||
if: success()
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
94
.github/workflows/ci.yml
vendored
94
.github/workflows/ci.yml
vendored
@@ -100,7 +100,6 @@ jobs:
|
||||
run_macos_node: ${{ steps.manifest.outputs.run_macos_node }}
|
||||
macos_node_matrix: ${{ steps.manifest.outputs.macos_node_matrix }}
|
||||
run_macos_swift: ${{ steps.manifest.outputs.run_macos_swift }}
|
||||
run_ios_build: ${{ steps.manifest.outputs.run_ios_build }}
|
||||
run_android_job: ${{ steps.manifest.outputs.run_android_job }}
|
||||
android_matrix: ${{ steps.manifest.outputs.android_matrix }}
|
||||
steps:
|
||||
@@ -205,7 +204,6 @@ jobs:
|
||||
OPENCLAW_CI_DOCS_CHANGED: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.docs_scope.outputs.docs_changed }}
|
||||
OPENCLAW_CI_RUN_NODE: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_node || 'false' }}
|
||||
OPENCLAW_CI_RUN_MACOS: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_macos || 'false' }}
|
||||
OPENCLAW_CI_RUN_IOS_BUILD: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_ios_build || 'false' }}
|
||||
OPENCLAW_CI_RUN_ANDROID: ${{ github.event_name == 'workflow_dispatch' && (inputs.release_gate || inputs.include_android) && 'true' || steps.changed_scope.outputs.run_android || 'false' }}
|
||||
OPENCLAW_CI_RUN_WINDOWS: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_windows || 'false' }}
|
||||
OPENCLAW_CI_RUN_NODE_FAST_ONLY: ${{ github.event_name == 'workflow_dispatch' && 'false' || steps.changed_scope.outputs.run_node_fast_only || 'false' }}
|
||||
@@ -269,8 +267,6 @@ jobs:
|
||||
const runPluginContractShards = runNodeFull || runNodeFastPluginContracts;
|
||||
const runMacos =
|
||||
parseBoolean(process.env.OPENCLAW_CI_RUN_MACOS) && !docsOnly && isCanonicalRepository;
|
||||
const runIosBuild =
|
||||
parseBoolean(process.env.OPENCLAW_CI_RUN_IOS_BUILD) && !docsOnly && isCanonicalRepository;
|
||||
const runAndroid =
|
||||
parseBoolean(process.env.OPENCLAW_CI_RUN_ANDROID) && !docsOnly && isCanonicalRepository;
|
||||
const runWindows =
|
||||
@@ -309,7 +305,6 @@ jobs:
|
||||
shard_name: shard.shardName,
|
||||
groups: shard.groups,
|
||||
configs: shard.configs,
|
||||
env: shard.env,
|
||||
includePatterns: shard.includePatterns,
|
||||
requires_dist: shard.requiresDist,
|
||||
runner: shard.runner,
|
||||
@@ -365,7 +360,6 @@ jobs:
|
||||
runMacos ? [{ check_name: "macos-node", runtime: "node", task: "test" }] : [],
|
||||
),
|
||||
run_macos_swift: runMacos,
|
||||
run_ios_build: runIosBuild,
|
||||
run_android_job: runAndroid,
|
||||
android_matrix: createMatrix(
|
||||
runAndroid
|
||||
@@ -1182,9 +1176,7 @@ jobs:
|
||||
timeout-minutes: ${{ matrix.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
|
||||
max-parallel: 12
|
||||
matrix: ${{ fromJson(needs.preflight.outputs.checks_node_core_nondist_matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -1245,7 +1237,6 @@ jobs:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
OPENCLAW_NODE_TEST_GROUPS_JSON: ${{ toJson(matrix.groups || null) }}
|
||||
OPENCLAW_NODE_TEST_CONFIGS_JSON: ${{ toJson(matrix.configs) }}
|
||||
OPENCLAW_NODE_TEST_ENV_JSON: ${{ toJson(matrix.env) }}
|
||||
OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON: ${{ toJson(matrix.includePatterns) }}
|
||||
OPENCLAW_VITEST_SHARD_NAME: ${{ matrix.shard_name }}
|
||||
OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS: "300000"
|
||||
@@ -1264,7 +1255,6 @@ jobs:
|
||||
? groups
|
||||
: [{
|
||||
configs: JSON.parse(process.env.OPENCLAW_NODE_TEST_CONFIGS_JSON ?? "[]"),
|
||||
env: JSON.parse(process.env.OPENCLAW_NODE_TEST_ENV_JSON ?? "null"),
|
||||
includePatterns: JSON.parse(
|
||||
process.env.OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON ?? "null",
|
||||
),
|
||||
@@ -1280,13 +1270,6 @@ jobs:
|
||||
...process.env,
|
||||
...(plan.shard_name ? { OPENCLAW_VITEST_SHARD_NAME: plan.shard_name } : {}),
|
||||
};
|
||||
if (plan.env && typeof plan.env === "object" && !Array.isArray(plan.env)) {
|
||||
for (const [key, value] of Object.entries(plan.env)) {
|
||||
if (typeof value === "string") {
|
||||
childEnv[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Array.isArray(plan.includePatterns) && plan.includePatterns.length > 0) {
|
||||
const includeFile = join(
|
||||
process.env.RUNNER_TEMP ?? ".",
|
||||
@@ -2167,76 +2150,6 @@ jobs:
|
||||
done
|
||||
exit 1
|
||||
|
||||
ios-build:
|
||||
permissions:
|
||||
contents: read
|
||||
name: "ios-build"
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_ios_build == 'true'
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-26' || (github.repository == 'openclaw/openclaw' && 'blacksmith-12vcpu-macos-26' || 'macos-26') }}
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
fetch_timeout_seconds=90
|
||||
fetch_checkout_ref() {
|
||||
git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout" &
|
||||
local fetch_pid="$!"
|
||||
local elapsed=0
|
||||
while kill -0 "$fetch_pid" 2>/dev/null; do
|
||||
if [ "$elapsed" -ge "$fetch_timeout_seconds" ]; then
|
||||
kill -TERM "$fetch_pid" 2>/dev/null || true
|
||||
sleep 10
|
||||
kill -KILL "$fetch_pid" 2>/dev/null || true
|
||||
wait "$fetch_pid" || true
|
||||
return 124
|
||||
fi
|
||||
sleep 1
|
||||
elapsed=$((elapsed + 1))
|
||||
done
|
||||
wait "$fetch_pid"
|
||||
}
|
||||
fetch_checkout_ref
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Select Xcode 26
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for xcode_app in /Applications/Xcode_26.5.app /Applications/Xcode-26.5.0.app; do
|
||||
if [ -d "$xcode_app/Contents/Developer" ]; then
|
||||
sudo xcode-select -s "$xcode_app/Contents/Developer"
|
||||
break
|
||||
fi
|
||||
done
|
||||
xcodebuild -version
|
||||
xcode_version="$(xcodebuild -version | awk 'NR == 1 { print $2 }')"
|
||||
if [[ "$xcode_version" != 26.* ]]; then
|
||||
echo "error: expected Xcode 26.x, got $xcode_version" >&2
|
||||
exit 1
|
||||
fi
|
||||
swift --version
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
|
||||
- name: Install iOS Swift tooling
|
||||
run: brew install xcodegen swiftlint swiftformat
|
||||
|
||||
- name: Build iOS app
|
||||
run: pnpm ios:build
|
||||
|
||||
android:
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -2310,7 +2223,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-
|
||||
|
||||
@@ -2340,7 +2253,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 }}
|
||||
@@ -2388,7 +2301,6 @@ jobs:
|
||||
- checks-windows
|
||||
- macos-node
|
||||
- macos-swift
|
||||
- ios-build
|
||||
- android
|
||||
if: ${{ !cancelled() && always() && github.event_name != 'push' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
32
.github/workflows/codeql-critical-quality.yml
vendored
32
.github/workflows/codeql-critical-quality.yml
vendored
@@ -152,7 +152,7 @@ jobs:
|
||||
quality-shards:
|
||||
name: Select Critical Quality shards
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
agent: ${{ steps.detect.outputs.agent }}
|
||||
@@ -333,7 +333,7 @@ jobs:
|
||||
name: Critical Quality (core-auth-secrets)
|
||||
needs: quality-shards
|
||||
if: ${{ needs.quality-shards.outputs.core_auth_secrets == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'core-auth-secrets') }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -356,7 +356,7 @@ jobs:
|
||||
name: Critical Quality (config-boundary)
|
||||
needs: quality-shards
|
||||
if: ${{ needs.quality-shards.outputs.config == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'config-boundary') }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -379,7 +379,7 @@ jobs:
|
||||
name: Critical Quality (gateway-runtime-boundary)
|
||||
needs: quality-shards
|
||||
if: ${{ needs.quality-shards.outputs.gateway == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'gateway-runtime-boundary') }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -402,7 +402,7 @@ jobs:
|
||||
name: Critical Quality (channel-runtime-boundary)
|
||||
needs: quality-shards
|
||||
if: ${{ needs.quality-shards.outputs.channel == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'channel-runtime-boundary') }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -425,7 +425,7 @@ jobs:
|
||||
name: Critical Quality (network-runtime-boundary)
|
||||
needs: quality-shards
|
||||
if: ${{ needs.quality-shards.outputs.network_runtime == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'network-runtime-boundary') }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -509,7 +509,7 @@ jobs:
|
||||
name: Critical Quality (agent-runtime-boundary)
|
||||
needs: quality-shards
|
||||
if: ${{ needs.quality-shards.outputs.agent == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'agent-runtime-boundary') }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -532,7 +532,7 @@ jobs:
|
||||
name: Critical Quality (mcp-process-runtime-boundary)
|
||||
needs: quality-shards
|
||||
if: ${{ needs.quality-shards.outputs.mcp_process == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'mcp-process-runtime-boundary') }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -555,7 +555,7 @@ jobs:
|
||||
name: Critical Quality (memory-runtime-boundary)
|
||||
needs: quality-shards
|
||||
if: ${{ needs.quality-shards.outputs.memory == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'memory-runtime-boundary') }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -578,7 +578,7 @@ jobs:
|
||||
name: Critical Quality (session-diagnostics-boundary)
|
||||
needs: quality-shards
|
||||
if: ${{ needs.quality-shards.outputs.session_diagnostics == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'session-diagnostics-boundary') }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -601,7 +601,7 @@ jobs:
|
||||
name: Critical Quality (plugin-sdk-reply-runtime)
|
||||
needs: quality-shards
|
||||
if: ${{ needs.quality-shards.outputs.plugin_sdk_reply == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'plugin-sdk-reply-runtime') }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -624,7 +624,7 @@ jobs:
|
||||
name: Critical Quality (provider-runtime-boundary)
|
||||
needs: quality-shards
|
||||
if: ${{ needs.quality-shards.outputs.provider == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'provider-runtime-boundary') }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -646,7 +646,7 @@ jobs:
|
||||
ui-control-plane:
|
||||
name: Critical Quality (ui-control-plane)
|
||||
if: ${{ github.event_name != 'pull_request' && (github.event_name != 'workflow_dispatch' || inputs.profile == 'all') }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -668,7 +668,7 @@ jobs:
|
||||
web-media-runtime-boundary:
|
||||
name: Critical Quality (web-media-runtime-boundary)
|
||||
if: ${{ github.event_name != 'pull_request' && (github.event_name != 'workflow_dispatch' || inputs.profile == 'all') }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -691,7 +691,7 @@ jobs:
|
||||
name: Critical Quality (plugin-boundary)
|
||||
needs: quality-shards
|
||||
if: ${{ needs.quality-shards.outputs.plugin == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'plugin-boundary') }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -714,7 +714,7 @@ jobs:
|
||||
name: Critical Quality (plugin-sdk-package-contract)
|
||||
needs: quality-shards
|
||||
if: ${{ needs.quality-shards.outputs.plugin_sdk_package == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'plugin-sdk-package-contract') }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
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:
|
||||
|
||||
38
.github/workflows/crabbox-hydrate.yml
vendored
38
.github/workflows/crabbox-hydrate.yml
vendored
@@ -171,19 +171,10 @@ jobs:
|
||||
set -euo pipefail
|
||||
|
||||
node_bin="$(dirname "$(node -p 'process.execPath')")"
|
||||
link_node_tool() {
|
||||
local tool="$1"
|
||||
local source="$node_bin/$tool"
|
||||
local target="/usr/local/bin/$tool"
|
||||
if [ -e "$target" ] && [ "$(readlink -f "$source")" = "$(readlink -f "$target")" ]; then
|
||||
return
|
||||
fi
|
||||
sudo ln -sf "$source" "$target"
|
||||
}
|
||||
link_node_tool node
|
||||
link_node_tool npm
|
||||
link_node_tool npx
|
||||
link_node_tool corepack
|
||||
sudo ln -sf "$node_bin/node" /usr/local/bin/node
|
||||
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
|
||||
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
|
||||
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
|
||||
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
|
||||
#!/usr/bin/env bash
|
||||
exec /usr/local/bin/corepack pnpm "$@"
|
||||
@@ -499,7 +490,7 @@ jobs:
|
||||
if (-not $env:CRABBOX_ID -or $env:CRABBOX_ID -notmatch '^[A-Za-z0-9._-]+$') {
|
||||
Write-Error "Invalid crabbox_id"
|
||||
}
|
||||
$actionsRoot = "C:\ProgramData\crabbox\actions"
|
||||
$actionsRoot = Join-Path $HOME ".crabbox\actions"
|
||||
New-Item -ItemType Directory -Force $actionsRoot | Out-Null
|
||||
$state = Join-Path $actionsRoot "$env:CRABBOX_ID.env"
|
||||
$envFile = Join-Path $actionsRoot "$env:CRABBOX_ID.env.ps1"
|
||||
@@ -555,7 +546,7 @@ jobs:
|
||||
if ($env:CRABBOX_KEEP_ALIVE_MINUTES -match '^[0-9]+$') {
|
||||
$minutes = [int]$env:CRABBOX_KEEP_ALIVE_MINUTES
|
||||
}
|
||||
$stop = Join-Path "C:\ProgramData\crabbox\actions" "$env:CRABBOX_ID.stop"
|
||||
$stop = Join-Path $HOME ".crabbox\actions\$env:CRABBOX_ID.stop"
|
||||
$deadline = (Get-Date).AddMinutes($minutes)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
if (Test-Path $stop) {
|
||||
@@ -593,19 +584,10 @@ jobs:
|
||||
fi
|
||||
|
||||
node_bin="$(dirname "$(node -p 'process.execPath')")"
|
||||
link_node_tool() {
|
||||
local tool="$1"
|
||||
local source="$node_bin/$tool"
|
||||
local target="/usr/local/bin/$tool"
|
||||
if [ -e "$target" ] && [ "$(readlink -f "$source")" = "$(readlink -f "$target")" ]; then
|
||||
return
|
||||
fi
|
||||
sudo ln -sf "$source" "$target"
|
||||
}
|
||||
link_node_tool node
|
||||
link_node_tool npm
|
||||
link_node_tool npx
|
||||
link_node_tool corepack
|
||||
sudo ln -sf "$node_bin/node" /usr/local/bin/node
|
||||
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
|
||||
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
|
||||
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
|
||||
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
|
||||
#!/usr/bin/env bash
|
||||
exec /usr/local/bin/corepack pnpm "$@"
|
||||
|
||||
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
|
||||
|
||||
464
.github/workflows/maturity-scorecard.yml
vendored
464
.github/workflows/maturity-scorecard.yml
vendored
@@ -1,464 +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
|
||||
expected_sha:
|
||||
description: Optional full SHA that ref must resolve to
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
qa_evidence_run_id:
|
||||
description: Optional workflow run id containing qa-evidence.json
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
ref:
|
||||
description: OpenClaw branch, tag, or SHA containing the maturity score source
|
||||
required: true
|
||||
type: string
|
||||
expected_sha:
|
||||
description: Optional full SHA that ref must resolve to
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
secrets:
|
||||
OPENAI_API_KEY:
|
||||
description: OpenAI API key used by live QA profile scenarios
|
||||
required: true
|
||||
OPENCLAW_MATURITY_SCORECARD_AGENT_OPENAI_API_KEY:
|
||||
description: Optional OpenAI API key used by maturity scorecard agent steps
|
||||
required: false
|
||||
GH_APP_PRIVATE_KEY:
|
||||
description: Optional GitHub App private key for generated docs PR creation
|
||||
required: false
|
||||
GH_APP_PRIVATE_KEY_FALLBACK:
|
||||
description: Optional fallback GitHub App private key for generated docs PR creation
|
||||
required: false
|
||||
|
||||
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:
|
||||
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 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: ${{ inputs.ref }}
|
||||
expected_sha: ${{ needs.validate_selected_ref.outputs.selected_revision }}
|
||||
qa_profile: release
|
||||
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 !== "release") {
|
||||
throw new Error(`qa-evidence.json profile must be release, 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 !== "release") {
|
||||
throw new Error(`QA evidence manifest profile must be release, 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: Ensure maturity scorecard agent key exists
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENCLAW_MATURITY_SCORECARD_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${OPENAI_API_KEY:-}" ]]; then
|
||||
echo "Missing OPENCLAW_MATURITY_SCORECARD_AGENT_OPENAI_API_KEY or OPENAI_API_KEY secret." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run Codex maturity scorecard agent
|
||||
uses: openai/codex-action@e0fdf01220eb9a88167c4898839d273e3f2609d1
|
||||
env:
|
||||
MATURITY_EVIDENCE_DIR: .artifacts/maturity-evidence
|
||||
MATURITY_SCORES_PATH: qa/maturity-scores.yaml
|
||||
MATURITY_TAXONOMY_PATH: taxonomy.yaml
|
||||
with:
|
||||
openai-api-key: ${{ secrets.OPENCLAW_MATURITY_SCORECARD_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
|
||||
prompt-file: .github/codex/prompts/maturity-scorecard-agent.md
|
||||
model: ${{ vars.OPENCLAW_CI_OPENAI_MODEL_BARE }}
|
||||
effort: high
|
||||
sandbox: workspace-write
|
||||
safety-strategy: drop-sudo
|
||||
|
||||
- name: Enforce focused maturity score patch
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git restore --staged :/
|
||||
|
||||
allowed='^qa/maturity-scores\.yaml$'
|
||||
bad_tracked="$(
|
||||
git diff --name-only HEAD -- | while IFS= read -r path; do
|
||||
if [[ ! "$path" =~ $allowed ]]; then
|
||||
printf '%s\n' "$path"
|
||||
fi
|
||||
done
|
||||
)"
|
||||
if [[ -n "$bad_tracked" ]]; then
|
||||
echo "Maturity scorecard agent touched forbidden tracked paths:"
|
||||
printf '%s\n' "$bad_tracked"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
bad_untracked="$(
|
||||
git ls-files --others --exclude-standard | while IFS= read -r path; do
|
||||
if [[ "$path" != "qa/maturity-scores.yaml" ]]; then
|
||||
printf '%s\n' "$path"
|
||||
fi
|
||||
done
|
||||
)"
|
||||
if [[ -n "$bad_untracked" ]]; then
|
||||
echo "Maturity scorecard agent created forbidden untracked paths:"
|
||||
printf '%s\n' "$bad_untracked"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f qa/maturity-scores.yaml ]]; then
|
||||
echo "Maturity scorecard agent must produce qa/maturity-scores.yaml." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- 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
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
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: ${{ github.event_name == 'workflow_dispatch' && 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
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
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/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
|
||||
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
|
||||
|
||||
@@ -609,6 +609,7 @@ jobs:
|
||||
requires_repo_e2e: true
|
||||
requires_live_suites: false
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENCLAW_E2E_WORKERS: "1"
|
||||
OPENCLAW_VITEST_MAX_WORKERS: "1"
|
||||
steps:
|
||||
@@ -642,74 +643,9 @@ jobs:
|
||||
set -euo pipefail
|
||||
case "${{ matrix.suite_id }}" in
|
||||
openshell-e2e)
|
||||
echo "OPENCLAW_E2E_OPENSHELL_CONFIG_HOME=$HOME/.config" >> "$GITHUB_ENV"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Install OpenShell CLI
|
||||
if: |
|
||||
(inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id) &&
|
||||
matrix.suite_id == 'openshell-e2e'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export OPENSHELL_VERSION=v0.0.68
|
||||
curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/d64542f69d06694cbd203b64929d286dd0533bbb/install.sh | sh
|
||||
openshell --version
|
||||
|
||||
- name: Bootstrap OpenShell gateway
|
||||
if: |
|
||||
(inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id) &&
|
||||
matrix.suite_id == 'openshell-e2e'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mtls_dir="$HOME/.config/openshell/gateways/openshell/mtls"
|
||||
gateway_tls_dir="$RUNNER_TEMP/openshell-gateway-certs"
|
||||
fallback_pid=""
|
||||
if ! openshell --gateway openshell sandbox list >/dev/null 2>&1; then
|
||||
rm -rf "$gateway_tls_dir"
|
||||
openshell-gateway generate-certs \
|
||||
--output-dir "$gateway_tls_dir" \
|
||||
--server-san 127.0.0.1 \
|
||||
--server-san localhost \
|
||||
--server-san host.openshell.internal
|
||||
rm -rf "$mtls_dir"
|
||||
mkdir -p "$mtls_dir"
|
||||
cp "$gateway_tls_dir/ca.crt" "$mtls_dir/ca.crt"
|
||||
cp "$gateway_tls_dir/client/tls.crt" "$mtls_dir/tls.crt"
|
||||
cp "$gateway_tls_dir/client/tls.key" "$mtls_dir/tls.key"
|
||||
openshell gateway remove openshell >/dev/null 2>&1 || true
|
||||
OPENSHELL_LOCAL_TLS_DIR="$gateway_tls_dir" nohup openshell-gateway \
|
||||
--bind-address 0.0.0.0 \
|
||||
--port 17670 \
|
||||
--drivers docker \
|
||||
--tls-cert "$gateway_tls_dir/server/tls.crt" \
|
||||
--tls-key "$gateway_tls_dir/server/tls.key" \
|
||||
--tls-client-ca "$mtls_dir/ca.crt" \
|
||||
>"$RUNNER_TEMP/openshell-gateway.log" 2>&1 &
|
||||
fallback_pid=$!
|
||||
echo "OPENCLAW_OPENSHELL_FALLBACK_PID=$fallback_pid" >> "$GITHUB_ENV"
|
||||
for _ in $(seq 1 30); do
|
||||
if openshell gateway add --local --name openshell https://127.0.0.1:17670; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
openshell gateway select openshell
|
||||
for _ in $(seq 1 60); do
|
||||
if openshell --gateway openshell sandbox list >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
fi
|
||||
if [[ -z "$fallback_pid" ]]; then
|
||||
echo "OPENCLAW_OPENSHELL_FALLBACK_PID=" >> "$GITHUB_ENV"
|
||||
fi
|
||||
openshell --gateway openshell sandbox list >/dev/null
|
||||
openshell gateway list
|
||||
|
||||
- name: Validate suite credentials
|
||||
if: inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id
|
||||
shell: bash
|
||||
@@ -729,15 +665,6 @@ jobs:
|
||||
(inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id)
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
- name: Stop fallback OpenShell gateway
|
||||
if: always() && matrix.suite_id == 'openshell-e2e'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -n "${OPENCLAW_OPENSHELL_FALLBACK_PID:-}" ]]; then
|
||||
kill "$OPENCLAW_OPENSHELL_FALLBACK_PID" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
validate_docker_e2e:
|
||||
needs: [validate_selected_ref, prepare_docker_e2e_image, plan_release_workflow_matrices]
|
||||
if: inputs.include_release_path_suites && inputs.docker_lanes == '' && needs.plan_release_workflow_matrices.outputs.docker_e2e_count != '0'
|
||||
|
||||
30
.github/workflows/openclaw-performance.yml
vendored
30
.github/workflows/openclaw-performance.yml
vendored
@@ -151,39 +151,11 @@ jobs:
|
||||
echo "present=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Resolve OpenClaw target ref
|
||||
id: target
|
||||
if: steps.lane.outputs.run == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TARGET_REF_INPUT: ${{ inputs.target_ref }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
requested="${TARGET_REF_INPUT:-}"
|
||||
if [[ -z "$requested" ]]; then
|
||||
echo "checkout_ref=${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
|
||||
echo "tested_ref=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
encoded_ref="$(node -e 'process.stdout.write(encodeURIComponent(process.argv[1]))' "$requested")"
|
||||
if ! resolved_sha="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${encoded_ref}" --jq '.sha')"; then
|
||||
echo "::error::Unable to resolve OpenClaw target_ref '${requested}'." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "$resolved_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "::error::OpenClaw target_ref '${requested}' resolved to invalid SHA '${resolved_sha}'." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "checkout_ref=${resolved_sha}" >> "$GITHUB_OUTPUT"
|
||||
echo "tested_ref=${requested}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout OpenClaw
|
||||
if: steps.lane.outputs.run == 'true'
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
ref: ${{ steps.target.outputs.checkout_ref }}
|
||||
ref: ${{ inputs.target_ref || github.ref }}
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
52
.github/workflows/openclaw-release-checks.yml
vendored
52
.github/workflows/openclaw-release-checks.yml
vendored
@@ -44,11 +44,6 @@ on:
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
run_maturity_scorecard:
|
||||
description: Render advisory maturity scorecard release docs; default release checks rely on dedicated package, QA, live, and E2E gates
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
rerun_group:
|
||||
description: Release check group to run
|
||||
required: false
|
||||
@@ -111,7 +106,6 @@ jobs:
|
||||
mode: ${{ steps.inputs.outputs.mode }}
|
||||
release_profile: ${{ steps.inputs.outputs.release_profile }}
|
||||
run_release_soak: ${{ steps.inputs.outputs.run_release_soak }}
|
||||
run_maturity_scorecard: ${{ steps.inputs.outputs.run_maturity_scorecard }}
|
||||
rerun_group: ${{ steps.inputs.outputs.rerun_group }}
|
||||
live_suite_filter: ${{ steps.inputs.outputs.live_suite_filter }}
|
||||
cross_os_suite_filter: ${{ steps.inputs.outputs.cross_os_suite_filter }}
|
||||
@@ -285,7 +279,6 @@ jobs:
|
||||
RELEASE_MODE_INPUT: ${{ inputs.mode }}
|
||||
RELEASE_PROFILE_INPUT: ${{ inputs.release_profile }}
|
||||
RELEASE_RUN_RELEASE_SOAK_INPUT: ${{ inputs.run_release_soak }}
|
||||
RELEASE_RUN_MATURITY_SCORECARD_INPUT: ${{ inputs.run_maturity_scorecard }}
|
||||
RELEASE_RERUN_GROUP_INPUT: ${{ inputs.rerun_group }}
|
||||
RELEASE_LIVE_SUITE_FILTER_INPUT: ${{ inputs.live_suite_filter }}
|
||||
RELEASE_CROSS_OS_SUITE_FILTER_INPUT: ${{ inputs.cross_os_suite_filter }}
|
||||
@@ -326,12 +319,6 @@ jobs:
|
||||
else
|
||||
run_release_soak=true
|
||||
fi
|
||||
run_maturity_scorecard="$(printf '%s' "$RELEASE_RUN_MATURITY_SCORECARD_INPUT" | tr '[:upper:]' '[:lower:]')"
|
||||
if [[ "$run_maturity_scorecard" != "true" && "$run_maturity_scorecard" != "1" && "$run_maturity_scorecard" != "yes" ]]; then
|
||||
run_maturity_scorecard=false
|
||||
else
|
||||
run_maturity_scorecard=true
|
||||
fi
|
||||
release_profile="$RELEASE_PROFILE_INPUT"
|
||||
if [[ "$release_profile" == "minimum" ]]; then
|
||||
release_profile=beta
|
||||
@@ -435,7 +422,6 @@ jobs:
|
||||
printf 'mode=%s\n' "$RELEASE_MODE_INPUT"
|
||||
printf 'release_profile=%s\n' "$release_profile"
|
||||
printf 'run_release_soak=%s\n' "$run_release_soak"
|
||||
printf 'run_maturity_scorecard=%s\n' "$run_maturity_scorecard"
|
||||
printf 'rerun_group=%s\n' "$RELEASE_RERUN_GROUP_INPUT"
|
||||
printf 'live_suite_filter=%s\n' "$RELEASE_LIVE_SUITE_FILTER_INPUT"
|
||||
printf 'cross_os_suite_filter=%s\n' "$RELEASE_CROSS_OS_SUITE_FILTER_INPUT"
|
||||
@@ -458,7 +444,6 @@ jobs:
|
||||
RELEASE_MODE: ${{ inputs.mode }}
|
||||
RELEASE_PROFILE: ${{ steps.inputs.outputs.release_profile }}
|
||||
RUN_RELEASE_SOAK: ${{ steps.inputs.outputs.run_release_soak }}
|
||||
RUN_MATURITY_SCORECARD: ${{ steps.inputs.outputs.run_maturity_scorecard }}
|
||||
RELEASE_RERUN_GROUP: ${{ inputs.rerun_group }}
|
||||
RELEASE_LIVE_SUITE_FILTER: ${{ inputs.live_suite_filter }}
|
||||
RELEASE_CROSS_OS_SUITE_FILTER: ${{ inputs.cross_os_suite_filter }}
|
||||
@@ -476,7 +461,6 @@ jobs:
|
||||
echo "- Cross-OS mode: \`${RELEASE_MODE}\`"
|
||||
echo "- Release profile: \`${RELEASE_PROFILE}\`"
|
||||
echo "- Release soak lanes: \`${RUN_RELEASE_SOAK}\`"
|
||||
echo "- Maturity scorecard docs: \`${RUN_MATURITY_SCORECARD}\`"
|
||||
echo "- Rerun group: \`${RELEASE_RERUN_GROUP}\`"
|
||||
if [[ -n "${RELEASE_LIVE_SUITE_FILTER// }" ]]; then
|
||||
echo "- Live suite filter: \`${RELEASE_LIVE_SUITE_FILTER}\`"
|
||||
@@ -783,20 +767,6 @@ jobs:
|
||||
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
|
||||
|
||||
maturity_scorecard_release_checks:
|
||||
name: Render maturity scorecard release docs
|
||||
needs: [resolve_target]
|
||||
if: contains(fromJSON('["all","qa"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.run_maturity_scorecard == 'true'
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
uses: ./.github/workflows/maturity-scorecard.yml
|
||||
with:
|
||||
ref: ${{ needs.resolve_target.outputs.ref }}
|
||||
expected_sha: ${{ needs.resolve_target.outputs.revision }}
|
||||
secrets:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
qa_lab_parity_lane_release_checks:
|
||||
name: Run QA Lab parity lane (${{ matrix.lane }})
|
||||
needs: [resolve_target]
|
||||
@@ -883,7 +853,7 @@ jobs:
|
||||
name: release-qa-parity-${{ matrix.lane }}-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
@@ -989,7 +959,7 @@ jobs:
|
||||
name: release-qa-parity-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
@@ -1161,7 +1131,7 @@ jobs:
|
||||
name: release-qa-runtime-parity-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
@@ -1271,13 +1241,13 @@ jobs:
|
||||
--output .artifacts/qa-e2e/runtime-parity-standard-report/qa-runtime-tool-coverage-report.md
|
||||
|
||||
- name: Upload runtime tool coverage artifacts
|
||||
if: ${{ always() && steps.verify_runtime_parity_status.outputs.ready == 'true' }}
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: release-qa-runtime-tool-coverage-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/runtime-parity-standard-report/
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
if-no-files-found: warn
|
||||
|
||||
qa_live_matrix_release_checks:
|
||||
name: Run QA Lab live Matrix lane
|
||||
@@ -1357,7 +1327,7 @@ jobs:
|
||||
name: release-qa-live-matrix-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
@@ -1497,7 +1467,7 @@ jobs:
|
||||
name: release-qa-live-telegram-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
@@ -1637,7 +1607,7 @@ jobs:
|
||||
name: release-qa-live-discord-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
@@ -1780,7 +1750,7 @@ jobs:
|
||||
name: release-qa-live-whatsapp-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
@@ -1920,7 +1890,7 @@ jobs:
|
||||
name: release-qa-live-slack-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
@@ -1976,7 +1946,6 @@ jobs:
|
||||
- docker_e2e_release_checks
|
||||
- package_acceptance_release_checks
|
||||
- qa_lab_parity_lane_release_checks
|
||||
- maturity_scorecard_release_checks
|
||||
- qa_lab_parity_report_release_checks
|
||||
- qa_lab_runtime_parity_release_checks
|
||||
- runtime_tool_coverage_release_checks
|
||||
@@ -2062,7 +2031,6 @@ jobs:
|
||||
"docker_e2e_release_checks=${{ needs.docker_e2e_release_checks.result }}" \
|
||||
"package_acceptance_release_checks=${{ needs.package_acceptance_release_checks.result }}" \
|
||||
"qa_lab_parity_lane_release_checks=${{ needs.qa_lab_parity_lane_release_checks.result }}" \
|
||||
"maturity_scorecard_release_checks=${{ needs.maturity_scorecard_release_checks.result }}" \
|
||||
"qa_lab_parity_report_release_checks=${{ needs.qa_lab_parity_report_release_checks.result }}" \
|
||||
"qa_lab_runtime_parity_release_checks=${{ needs.qa_lab_runtime_parity_release_checks.result }}" \
|
||||
"runtime_tool_coverage_release_checks=${{ needs.runtime_tool_coverage_release_checks.result }}" \
|
||||
|
||||
@@ -1466,9 +1466,9 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload postpublish evidence
|
||||
if: ${{ always() && inputs.publish_openclaw_npm }}
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: openclaw-release-postpublish-evidence-${{ inputs.tag }}
|
||||
path: ${{ runner.temp }}/openclaw-release-postpublish-evidence
|
||||
if-no-files-found: error
|
||||
if-no-files-found: ignore
|
||||
|
||||
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
|
||||
|
||||
385
.github/workflows/qa-profile-evidence.yml
vendored
385
.github/workflows/qa-profile-evidence.yml
vendored
@@ -1,385 +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: |
|
||||
// Reusable workflow jobs inherit the caller event but run as
|
||||
// github-actions[bot]; selected ref validation still gates secrets.
|
||||
if (context.actor === "github-actions[bot]") {
|
||||
core.info("Skipping manual actor permission check for a reusable workflow call.");
|
||||
core.setOutput("authorized", "true");
|
||||
return;
|
||||
}
|
||||
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: Ensure Playwright Chromium
|
||||
run: node scripts/ensure-playwright-chromium.mjs
|
||||
|
||||
- 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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
40
CHANGELOG.md
40
CHANGELOG.md
@@ -2,45 +2,6 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## 2026.6.10
|
||||
|
||||
### Highlights
|
||||
|
||||
- **Automatic fast mode for talks:** OpenClaw can enable fast mode for short conversational turns, then return to normal mode for longer runs with bounded fallback and delivery behavior. (#85104) Thanks @alexph-dev and @vincentkoc.
|
||||
- **More reliable model routing:** Zai model synthesis, GLM overload failover, and native reasoning-level selection now follow the active model catalog more consistently. (#94461, #93241, #94067, #94136) Thanks @Pandah97, @chrysb, @0xghost42, @zhengli0922, @openperf, @civiltox, and @BorClaw.
|
||||
- **Safer session and channel state:** channel switches reset stale origin fields, and cron delivery awareness stays attached to the target session. (#95328, #93580) Thanks @ZengWen-DT, @jalehman, @gorkem2020, and @scotthuang.
|
||||
- **Trusted policies survive hook composition:** composed hook registries keep the trusted tool policies required by approval-sensitive flows. (#94545) Thanks @jesse-merhi.
|
||||
|
||||
### Changes
|
||||
|
||||
- **Agent and channel runtime:** fast-mode state now survives retries, fallback transitions, progress events, and embedded/CLI/ACP normalization; session and channel routing retain the current target and delivery context. (#85104, #93580, #95328) Thanks @alexph-dev, @vincentkoc, @scotthuang, @ZengWen-DT, @jalehman, and @gorkem2020.
|
||||
- **Provider behavior:** model catalogs now supply the correct Zai base URL, overload classification, and native reasoning controls for live-discovered models. (#94461, #93241, #94067, #94136) Thanks @Pandah97, @chrysb, @0xghost42, @zhengli0922, @openperf, @civiltox, and @BorClaw.
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Fast-mode and policy correctness:** fallback cutoffs and reset notices are bounded, repeated progress events remain visible, Codex service-tier state is normalized, and trusted policies are not lost when hook registries are composed. (#85104, #94545) Thanks @alexph-dev, @vincentkoc, and @jesse-merhi.
|
||||
- **Model and delivery edge cases:** Zai and GLM failover paths use the right runtime metadata, while stale channel-origin state no longer leaks across session changes. (#94461, #93241, #95328) Thanks @Pandah97, @chrysb, @0xghost42, @zhengli0922, @ZengWen-DT, @jalehman, and @gorkem2020.
|
||||
- **Provider plugin onboarding:** setup refreshes provider plugin registry metadata after installing setup-selected provider plugins, so auth continuation uses the newly installed provider instead of stale registry state. (#95792) Thanks @snowzlmbot.
|
||||
|
||||
### Complete contribution record
|
||||
|
||||
This audited record covers the complete v2026.6.9..HEAD history: 12 merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.
|
||||
|
||||
#### Pull requests
|
||||
|
||||
- **PR #86627** Keep core doctor health in contribution order. Thanks @giodl73-repo.
|
||||
- **PR #93580** fix: preserve cron delivery awareness for target sessions. Thanks @scotthuang and @jalehman.
|
||||
- **PR #95030** refactor: add SDK transcript identity target API. Thanks @jalehman.
|
||||
- **PR #94838** refactor(copilot): complete harness lifecycle parity. Thanks @vincentkoc.
|
||||
- **PR #95328** fix(sessions): reset stale per-channel origin fields on channel switch. Related #95325. Thanks @ZengWen-DT and @jalehman and @gorkem2020.
|
||||
- **PR #94461** fix(zai): fall back to manifest baseUrl for synthesized GLM-5 models. Related #94269. Thanks @Pandah97 and @chrysb.
|
||||
- **PR #93241** fix(agents): classify Zhipu GLM overload as overloaded for failover. Related #93211. Thanks @0xghost42 and @zhengli0922.
|
||||
- **PR #94067** fix(channels): resolve native /think menu levels via runtime catalog for live-discovered models. Related #93835. Thanks @openperf and @civiltox.
|
||||
- **PR #94136** fix(zai): expose GLM-5.2 reasoning levels [AI-assisted]. Thanks @BorClaw.
|
||||
- **PR #85104** feat: fast talks auto mode. Related #85087. Thanks @alexph-dev.
|
||||
- **PR #94545** fix: keep trusted policies with hook registry. Thanks @jesse-merhi.
|
||||
- **PR #95792** fix(onboard): refresh provider plugin registry after setup installs. Related #95765. Thanks @snowzlmbot.
|
||||
|
||||
## 2026.6.9
|
||||
|
||||
### Highlights
|
||||
@@ -76,7 +37,6 @@ This audited record covers the complete v2026.6.8..HEAD history: 423 merged PRs.
|
||||
|
||||
#### Pull requests
|
||||
|
||||
- **PR #92154** fix(qqbot): gate private group commands and close strict command visibility gaps. Thanks @sliverp.
|
||||
- **PR #90463** refactor: add session accessor seam with gateway consumer. Thanks @jalehman.
|
||||
- **PR #88656** Drop reasoning-only length turns from replay. Thanks @abel-zer0.
|
||||
- **PR #92856** feat(webui): add session workspace rail. Thanks @Solvely-Colin.
|
||||
|
||||
@@ -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.10
|
||||
OPENCLAW_ANDROID_VERSION_CODE=2026061001
|
||||
OPENCLAW_ANDROID_VERSION_NAME=2026.6.9
|
||||
OPENCLAW_ANDROID_VERSION_CODE=2026060901
|
||||
|
||||
@@ -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,3 +0,0 @@
|
||||
Adds settings detail panels, refreshes the Android overview controls, and routes exec approvals into the in-app inbox.
|
||||
|
||||
Improves chat acknowledgement handling, gateway pairing readiness, microphone foreground-service behavior, and release screenshot reliability.
|
||||
@@ -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.10",
|
||||
"versionCode": 2026061001
|
||||
"version": "2026.6.9",
|
||||
"versionCode": 2026060901
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
# App Review Notes
|
||||
|
||||
Use these steps to exercise the live OpenClaw iOS App Review Gateway.
|
||||
|
||||
## Demo Account / Setup
|
||||
|
||||
Use the OpenClaw iOS app with the live review Gateway setup code included in
|
||||
the `Notes` field of this App Review submission.
|
||||
|
||||
The setup code is a single generated code string. It already contains the public
|
||||
Gateway host and setup credential.
|
||||
|
||||
## Setup Walkthrough
|
||||
|
||||
1. Open the OpenClaw app.
|
||||
2. Tap `Continue`.
|
||||
3. On `Connect Gateway`, tap `Set Up Manually`.
|
||||
4. In the `Setup Code` section, tap the `Paste setup code` field.
|
||||
5. Paste the setup code string from the App Review submission `Notes` field.
|
||||
6. Tap `Apply Setup Code`.
|
||||
7. If `Trust and connect` appears, tap `Trust and connect`.
|
||||
8. Wait for the `Connected` screen.
|
||||
9. On `Connected`, tap `Open OpenClaw`.
|
||||
10. Confirm the `Control` screen shows `Gateway Online`.
|
||||
11. Tap `Settings`.
|
||||
12. Tap `Approvals`.
|
||||
13. Tap `Open Notifications`.
|
||||
14. Tap `Enable Notifications`.
|
||||
15. On `Enable OpenClaw Hosted Push Relay?`, tap `Continue`.
|
||||
16. If iOS asks whether OpenClaw may send notifications, tap `Allow`.
|
||||
17. Confirm `Notifications` shows `Enabled`.
|
||||
|
||||
## Chat
|
||||
|
||||
1. Tap the `Chat` tab.
|
||||
2. Tap the text field labeled `Message main...`.
|
||||
3. Send this exact message:
|
||||
|
||||
```text
|
||||
Start Apple review checklist.
|
||||
```
|
||||
|
||||
Expected result: the assistant replies with the available App Review demos.
|
||||
|
||||
## Approval Demo
|
||||
|
||||
1. Tap the `Chat` tab.
|
||||
2. Tap the text field labeled `Message main...`.
|
||||
3. Send this exact message:
|
||||
|
||||
```text
|
||||
Run the approval demo.
|
||||
```
|
||||
|
||||
Expected result: the iPhone shows `Exec approval required` with the harmless
|
||||
command `printf 'OpenClaw App Review approval demo complete\n'`. Tap
|
||||
`Allow Once`. The chat then replies:
|
||||
|
||||
```text
|
||||
The approval demo completed.
|
||||
```
|
||||
|
||||
## Talk
|
||||
|
||||
1. Tap the `Talk` tab.
|
||||
2. Tap `Start Talk`.
|
||||
3. If iOS asks for microphone access, tap `Allow`.
|
||||
4. If iOS asks for Speech Recognition access, tap `Allow`.
|
||||
5. Confirm the screen changes to `Ready to talk` and shows `Stop Talk`.
|
||||
6. Say:
|
||||
|
||||
```text
|
||||
Summarize this review setup in one sentence.
|
||||
```
|
||||
|
||||
Expected result: the assistant responds by voice. Tap `Stop Talk` when done.
|
||||
|
||||
## Talk + Background Audio
|
||||
|
||||
1. Tap the `Talk` tab.
|
||||
2. Confirm `Speakerphone` is on.
|
||||
3. Confirm `Background listening` is on.
|
||||
4. Tap `Start Talk`.
|
||||
5. If iOS asks for microphone access, tap `Allow`.
|
||||
6. If iOS asks for Speech Recognition access, tap `Allow`.
|
||||
7. Confirm `Stop Talk` is visible.
|
||||
8. Say:
|
||||
|
||||
```text
|
||||
Tell me when you can hear me.
|
||||
```
|
||||
|
||||
9. While Talk is active, send OpenClaw to the background by returning to the
|
||||
Home Screen or locking the iPhone. Do not force quit the app.
|
||||
10. Continue speaking then wait for assistant audio reply.
|
||||
|
||||
Expected result: realtime Talk audio continues while OpenClaw is backgrounded.
|
||||
Reopen OpenClaw, confirm Talk is still active, then tap `Stop Talk`.
|
||||
|
||||
## Gateway Status
|
||||
|
||||
1. Tap `Control`.
|
||||
2. Tap `Instances`.
|
||||
3. Confirm the screen shows `Gateway online`.
|
||||
4. Confirm at least one `agent` row is connected.
|
||||
5. Confirm the iPhone review device appears in the connected instances list.
|
||||
|
||||
## Push Notification
|
||||
|
||||
1. Tap the `Chat` tab.
|
||||
2. Tap the text field labeled `Message main...`.
|
||||
3. Send this exact message:
|
||||
|
||||
```text
|
||||
Start push notification demo.
|
||||
```
|
||||
|
||||
4. Immediately send OpenClaw to the background and lock the iPhone. Do not
|
||||
force quit the app.
|
||||
|
||||
Expected result: the iPhone Lock Screen receives a visible `OpenClaw`
|
||||
notification with this body:
|
||||
|
||||
```text
|
||||
OpenClaw App Review push notification demo
|
||||
```
|
||||
|
||||
Tap the notification and unlock the iPhone if prompted. If OpenClaw opens on
|
||||
`Control`, tap `Chat`. Expected chat reply:
|
||||
|
||||
```text
|
||||
The push notification demo completed.
|
||||
```
|
||||
|
||||
## Push Wake / Status
|
||||
|
||||
1. Tap the `Chat` tab.
|
||||
2. Send this exact message:
|
||||
|
||||
```text
|
||||
Start push wake demo.
|
||||
```
|
||||
|
||||
3. Immediately send OpenClaw to the background and lock the iPhone. Do not
|
||||
force quit the app.
|
||||
4. Wait for the `OpenClaw` notification on the Lock Screen. It normally appears
|
||||
about 10 seconds after the message is sent.
|
||||
5. Tap the notification and unlock the iPhone if prompted. If OpenClaw opens on
|
||||
`Control`, tap `Chat`.
|
||||
|
||||
Expected result: the app reconnects to the live Gateway and Chat replies:
|
||||
|
||||
```text
|
||||
The push wake and node status demo completed.
|
||||
```
|
||||
|
||||
## Device Permissions
|
||||
|
||||
1. Tap `Settings`.
|
||||
2. Tap `Permissions`.
|
||||
3. Confirm these current app controls are available:
|
||||
- `Camera`
|
||||
- `Location` with `Off`, `While Using`, and `Always`
|
||||
- `Keep Awake`
|
||||
4. Expand `Privacy & Access`.
|
||||
5. Confirm these request controls are available:
|
||||
- `Contacts` / `Request Access`
|
||||
- `Calendar (Add Events)` / `Request Access`
|
||||
- `Calendar (View Events)` / `Request Full Access`
|
||||
- `Reminders` / `Request Access`
|
||||
|
||||
## Share Sheet
|
||||
|
||||
1. Open Safari.
|
||||
2. Navigate to `https://example.com`.
|
||||
3. Tap the Safari toolbar `More` button.
|
||||
4. Tap `Share`.
|
||||
5. Tap `OpenClaw`.
|
||||
6. Confirm the OpenClaw share extension appears and shows
|
||||
`Edit text, then tap Send.` and `Send to OpenClaw`.
|
||||
7. Tap `Send to OpenClaw`.
|
||||
|
||||
Expected result: the OpenClaw share extension sends the shared Safari page to
|
||||
the live review Gateway and shows `Sent to OpenClaw.` Returning to OpenClaw
|
||||
Chat shows the shared `Example Domain` page.
|
||||
@@ -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,11 +1,5 @@
|
||||
# OpenClaw iOS Changelog
|
||||
|
||||
## 2026.6.10 - 2026-06-21
|
||||
|
||||
Maintenance update for the current OpenClaw beta release.
|
||||
|
||||
- Improved notification cleanup, Watch app compatibility, and native file input handling.
|
||||
|
||||
## 2026.6.9 - 2026-06-20
|
||||
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
@@ -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.10
|
||||
OPENCLAW_MARKETING_VERSION = 2026.6.10
|
||||
OPENCLAW_IOS_VERSION = 2026.6.9
|
||||
OPENCLAW_MARKETING_VERSION = 2026.6.9
|
||||
OPENCLAW_BUILD_VERSION = 1
|
||||
|
||||
#include? "../build/Version.xcconfig"
|
||||
|
||||
@@ -67,10 +67,10 @@ 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.
|
||||
- `pnpm ios:release:upload` generates App Store screenshots, uploads release notes, and attaches `apps/ios/APP-REVIEW-NOTES.md` as a rendered PDF 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.
|
||||
- App Review submission is manual in App Store Connect. The release lane uploads a build, public metadata, and the App Review PDF attachment, but it does not submit for review or upload the App Store Connect `Notes` field.
|
||||
- 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.
|
||||
- `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.
|
||||
- `apps/ios/CHANGELOG.md` is the iOS-only changelog and release-note 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,40 +157,45 @@ 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
|
||||
- generates deterministic App Store screenshots
|
||||
- uploads release notes, screenshots, and the App Review PDF attachment to the editable App Store version
|
||||
- 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ struct ChatProTab: View {
|
||||
}
|
||||
|
||||
private var chatUserAccent: Color {
|
||||
self.colorScheme == .light ? OpenClawBrand.info : OpenClawBrand.accent
|
||||
self.colorScheme == .light ? Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0) : OpenClawBrand.accent
|
||||
}
|
||||
|
||||
private var activeAgent: AgentSummary? {
|
||||
|
||||
@@ -1036,7 +1036,7 @@ struct IPadSkillProposalRow: View {
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
self.isSelected ? OpenClawBrand.danger.opacity(0.08) : Color.clear,
|
||||
self.isSelected ? Color.red.opacity(0.08) : Color.clear,
|
||||
in: RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,13 +47,11 @@ enum AppAppearancePreference: String, CaseIterable, Identifiable {
|
||||
}
|
||||
|
||||
enum OpenClawBrand {
|
||||
static let uiAccent = UIColor { traits in
|
||||
static let accent = Color(uiColor: UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 198 / 255.0, green: 62 / 255.0, blue: 56 / 255.0, alpha: 1)
|
||||
: UIColor(red: 183 / 255.0, green: 56 / 255.0, blue: 51 / 255.0, alpha: 1)
|
||||
}
|
||||
|
||||
static let accent = Color(uiColor: Self.uiAccent)
|
||||
})
|
||||
static let accentHot = Color(uiColor: UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 232 / 255.0, green: 92 / 255.0, blue: 86 / 255.0, alpha: 1)
|
||||
@@ -66,7 +64,6 @@ enum OpenClawBrand {
|
||||
})
|
||||
static let ok = Color(red: 34 / 255.0, green: 197 / 255.0, blue: 94 / 255.0)
|
||||
static let warn = Color(red: 245 / 255.0, green: 158 / 255.0, blue: 11 / 255.0)
|
||||
static let info = Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0)
|
||||
static let graphite = Color(uiColor: UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 20 / 255.0, green: 22 / 255.0, blue: 24 / 255.0, alpha: 1)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user