mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-11 08:21:38 +08:00
Compare commits
7 Commits
fix/webcha
...
codex/pr-8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0635aeca7 | ||
|
|
2171e714f5 | ||
|
|
9dc496986a | ||
|
|
80c54f8288 | ||
|
|
074621dfd9 | ||
|
|
9588d72156 | ||
|
|
9c0e21f239 |
@@ -1,146 +0,0 @@
|
||||
---
|
||||
name: autoreview
|
||||
description: "Autoreview closeout: local dirty changes, PR branch vs main, parallel tests."
|
||||
---
|
||||
|
||||
# Autoreview
|
||||
|
||||
Run Codex's built-in code review as a closeout check. This is code review (`codex review`), not Guardian `auto_review` approval routing.
|
||||
|
||||
Codex native review mode performs best and is recommended. Non-Codex reviewers are fallback/second-opinion paths that receive a generated diff prompt, not the full Codex review-mode runtime.
|
||||
|
||||
Use when:
|
||||
- user asks for Codex review / autoreview / second-model review
|
||||
- after non-trivial code edits, before final/commit/ship
|
||||
- reviewing a local branch or PR branch after fixes
|
||||
|
||||
## Contract
|
||||
|
||||
- Treat review output as advisory. Never blindly apply it.
|
||||
- Verify every finding by reading the real code path and adjacent files.
|
||||
- Read dependency docs/source/types when the finding depends on external behavior.
|
||||
- Reject unrealistic edge cases, speculative risks, broad rewrites, and fixes that over-complicate the codebase.
|
||||
- Prefer small fixes at the right ownership boundary; no refactor unless it clearly improves the bug class.
|
||||
- Keep going until the selected review path returns no accepted/actionable findings.
|
||||
- If a review-triggered fix changes code, rerun focused tests and rerun the review helper.
|
||||
- Default to Codex review. If Codex is unavailable or exits with an error, the helper falls back to the first configured CLI from `claude -p`, `pi -p`, `opencode run`, `droid exec`, or `copilot`. Prefer Codex for final closeout because it uses native review mode; non-Codex reviewers use a Codex-inspired generated diff prompt. The helper runs nested Codex review in yolo/full-access mode by default; use `--no-yolo` only when intentionally testing sandbox behavior.
|
||||
- Stop as soon as the review command/helper exits 0 with no accepted/actionable findings. Do not run an extra direct `codex review` just to get a nicer "clean" line, a second opinion, or clearer closeout wording.
|
||||
- Treat the helper's successful exit plus absence of actionable findings as the clean review result, even if the underlying Codex CLI output is terse.
|
||||
- If rejecting a finding as intentional/not worth fixing, add a brief inline code comment only when it explains a real invariant or ownership decision that future reviewers should know.
|
||||
- If creating or updating a PR while rejecting any autoreview finding, record the rejected finding and reason in the PR description so later reviewers can distinguish intentional design decisions from missed review output.
|
||||
- Do not push just to review. Push only when the user requested push/ship/PR update.
|
||||
- For OpenClaw maintainers, keep autoreview validation Crabbox/Testbox-aware when maintainer validation mode is enabled (`OPENCLAW_TESTBOX=1` or `AUTOREVIEW_OPENCLAW_MAINTAINER_VALIDATION=1`). A review pass may inspect files and run cheap non-Node probes, but it must not start local `pnpm`, Vitest, `tsgo`, `npm test`, or `node scripts/run-vitest.mjs` from a Codex/worktree review unless the operator explicitly requested local proof. For runtime proof, use existing evidence or route through Crabbox/Testbox and report the id. Do not apply this rule to ordinary contributors who do not have maintainer Testbox access.
|
||||
|
||||
## Pick Target
|
||||
|
||||
Dirty local work:
|
||||
|
||||
```bash
|
||||
codex review --uncommitted
|
||||
```
|
||||
|
||||
Use this only when the patch is actually unstaged/staged/untracked in the
|
||||
current checkout. For committed, pushed, or PR work, point Codex at the commit
|
||||
or branch diff instead; do not force `--mode local` / `--uncommitted` just
|
||||
because the helper docs mention dirty work first. A clean `--uncommitted` review
|
||||
only proves there is no local patch.
|
||||
|
||||
Branch/PR work:
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
codex review --base origin/main
|
||||
```
|
||||
|
||||
Do not pass any prompt with `--base`. Some Codex CLI versions reject both inline
|
||||
and stdin prompt forms, including the helper's `codex review --base <ref> -`,
|
||||
with `--base <BRANCH> cannot be used with [PROMPT]`. If the helper hits this
|
||||
error, run plain `codex review --base <ref>` and report that the helper prompt
|
||||
injection was skipped.
|
||||
|
||||
If an open PR exists, use its actual base:
|
||||
|
||||
```bash
|
||||
base=$(gh pr view --json baseRefName --jq .baseRefName)
|
||||
codex review --base "origin/$base"
|
||||
```
|
||||
|
||||
Committed single change:
|
||||
|
||||
```bash
|
||||
codex review --commit HEAD
|
||||
```
|
||||
|
||||
or with the helper:
|
||||
|
||||
```bash
|
||||
.agents/skills/autoreview/scripts/autoreview --mode commit --commit HEAD
|
||||
```
|
||||
|
||||
Use commit review for already-landed or already-pushed work on `main`. Reviewing
|
||||
clean `main` against `origin/main` is usually an empty diff after push. For a
|
||||
small stack, review each commit explicitly or review the branch before merging
|
||||
with `--base`.
|
||||
|
||||
## Parallel Closeout
|
||||
|
||||
Format first if formatting can change line locations. Then it is OK to run tests and review in parallel:
|
||||
|
||||
```bash
|
||||
.agents/skills/autoreview/scripts/autoreview --parallel-tests "<focused test command>"
|
||||
```
|
||||
|
||||
Tradeoff: tests may force code changes that stale the review. If tests or review lead to code edits, rerun the affected tests and rerun review until no accepted/actionable findings remain. Once that rerun exits cleanly, stop; do not spend another long review cycle on redundant confirmation.
|
||||
|
||||
## Context Efficiency
|
||||
|
||||
Codex review is usually noisy. Default to a subagent filter when subagents are available. Ask it to run the review and return only:
|
||||
- actionable findings it accepts
|
||||
- findings it rejects, with one-line reason
|
||||
- exact files/tests to rerun
|
||||
|
||||
Run inline only for tiny changes or when subagents are unavailable.
|
||||
|
||||
## Helper
|
||||
|
||||
Bundled helper:
|
||||
|
||||
```bash
|
||||
.agents/skills/autoreview/scripts/autoreview --help
|
||||
```
|
||||
|
||||
The helper:
|
||||
- chooses dirty `--uncommitted` first
|
||||
- otherwise uses current PR base if `gh pr view` works
|
||||
- otherwise uses `origin/main` for non-main branches
|
||||
- auto-runs `PNPM_CONFIG_PM_ON_FAIL=ignore PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=false PNPM_CONFIG_OFFLINE=true pnpm run check` in parallel when a repo has `package.json`, `pnpm-lock.yaml`, `node_modules`, and a `check` script; disable with `AUTOREVIEW_AUTO_TESTS=0`
|
||||
- use `--mode commit --commit <ref>` for already-committed work, especially clean `main` after landing
|
||||
- should be left in `--mode auto` or forced to `--mode branch` for PR/branch work; do not force `--mode local` after committing
|
||||
- supports `--reviewer codex|claude|pi|opencode|droid|copilot|auto`; `auto` means Codex first
|
||||
- supports `--fallback-reviewer auto|claude|pi|opencode|droid|copilot|none`; default is configured CLI fallback
|
||||
- falls back only when Codex is unavailable or exits nonzero, not when Codex reports findings
|
||||
- writes only to stdout unless `--output` or `AUTOREVIEW_OUTPUT` is set
|
||||
- supports `--dry-run`, `--parallel-tests`, and commit refs
|
||||
- runs nested review with `--dangerously-bypass-approvals-and-sandbox --sandbox danger-full-access` by default
|
||||
- injects maintainer-only OpenClaw validation policy into native Codex review when `OPENCLAW_TESTBOX=1` or `AUTOREVIEW_OPENCLAW_MAINTAINER_VALIDATION=1`, so local memory-heavy Node/Vitest checks are avoided in favor of Crabbox/Testbox proof
|
||||
- branch mode may fail on Codex CLI versions that reject `--base` plus the helper's stdin prompt; on that exact parser error, rerun plain `codex review --base <ref>` instead of falling back to a non-Codex reviewer
|
||||
- keeps accepting `--full-access`; use `--no-yolo` or `AUTOREVIEW_YOLO=0` to opt out
|
||||
- still accepts legacy `CODEX_REVIEW_*` env vars when the matching `AUTOREVIEW_*` var is unset
|
||||
- prints `autoreview clean: no accepted/actionable findings reported` when the selected review command exits 0
|
||||
|
||||
## Final Report
|
||||
|
||||
Include:
|
||||
- review command used
|
||||
- tests/proof run
|
||||
- findings accepted/rejected, briefly why
|
||||
- the clean review result from the final helper/review run, or why a remaining finding was consciously rejected
|
||||
|
||||
Do not run another Codex review solely to improve the final report wording. If the final helper run exited 0 and produced no accepted/actionable findings, report that exact run as clean.
|
||||
|
||||
## PR / CI Closeout
|
||||
|
||||
- Prefer direct run/job APIs after CI starts: `gh run view <run-id> --json jobs`; use PR rollup only for final mergeability.
|
||||
- After rebase, compare `origin/main..HEAD`; drop CI-fix commits already upstream before pushing.
|
||||
- For prompt snapshot CI failures, prove/generate with Linux Node 24 before rerunning the failed job.
|
||||
- Update PR body once near the final head unless proof labels are missing or stale enough to block CI.
|
||||
@@ -1,707 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: autoreview [options]
|
||||
|
||||
Options:
|
||||
--mode auto|local|branch|commit
|
||||
Target selection. Default: auto.
|
||||
--base REF Base ref for branch review. Default: PR base or origin/main.
|
||||
--commit REF Commit ref for commit review. Default: HEAD.
|
||||
--reviewer codex|claude|pi|opencode|droid|copilot|auto
|
||||
Review engine. Default: Codex with configured fallback on error.
|
||||
--fallback-reviewer auto|claude|pi|opencode|droid|copilot|none
|
||||
Fallback when Codex is unavailable or exits nonzero. Default: auto.
|
||||
--codex-bin PATH Codex binary. Default: codex.
|
||||
--claude-bin PATH Claude binary. Default: claude.
|
||||
--pi-bin PATH Pi binary. Default: pi.
|
||||
--opencode-bin PATH OpenCode binary. Default: opencode.
|
||||
--droid-bin PATH Droid binary. Default: droid.
|
||||
--copilot-bin PATH GitHub Copilot binary. Default: copilot.
|
||||
--full-access Keep yolo/full-access mode enabled. Default.
|
||||
--no-yolo Run nested Codex review with normal sandbox/approval prompts.
|
||||
--output FILE Also save output to file.
|
||||
--parallel-tests CMD Run review and test command concurrently.
|
||||
Default: PNPM_CONFIG_PM_ON_FAIL=ignore PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=false PNPM_CONFIG_OFFLINE=true pnpm run check when available.
|
||||
--dry-run Print selected commands, do not run.
|
||||
-h, --help Show help.
|
||||
|
||||
Environment:
|
||||
OPENCLAW_TESTBOX=1 or AUTOREVIEW_OPENCLAW_MAINTAINER_VALIDATION=1
|
||||
Enable maintainer-only OpenClaw Crabbox/Testbox validation policy.
|
||||
|
||||
Modes:
|
||||
local codex review --uncommitted
|
||||
branch codex review --base <base>
|
||||
commit codex review --commit <commit>
|
||||
auto dirty tree -> local, else PR/current branch -> branch
|
||||
EOF
|
||||
}
|
||||
|
||||
mode=auto
|
||||
base_ref=
|
||||
commit_ref=HEAD
|
||||
reviewer=${AUTOREVIEW_REVIEWER:-${CODEX_REVIEW_REVIEWER:-auto}}
|
||||
fallback_reviewer=${AUTOREVIEW_FALLBACK_REVIEWER:-${CODEX_REVIEW_FALLBACK_REVIEWER:-auto}}
|
||||
codex_bin=${CODEX_BIN:-codex}
|
||||
claude_bin=${CLAUDE_BIN:-claude}
|
||||
pi_bin=${PI_BIN:-pi}
|
||||
opencode_bin=${OPENCODE_BIN:-opencode}
|
||||
droid_bin=${DROID_BIN:-droid}
|
||||
copilot_bin=${COPILOT_BIN:-copilot}
|
||||
codex_args=()
|
||||
yolo=${AUTOREVIEW_YOLO:-${CODEX_REVIEW_YOLO:-1}}
|
||||
output=${AUTOREVIEW_OUTPUT:-${CODEX_REVIEW_OUTPUT:-}}
|
||||
parallel_tests=
|
||||
parallel_tests_auto=false
|
||||
dry_run=false
|
||||
codex_review_prompt=
|
||||
codex_review_stdin_prompt=false
|
||||
codex_review_prompt_file=false
|
||||
openclaw_maintainer_validation=${AUTOREVIEW_OPENCLAW_MAINTAINER_VALIDATION:-${OPENCLAW_TESTBOX:-0}}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--mode)
|
||||
mode=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--base)
|
||||
base_ref=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--commit)
|
||||
commit_ref=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--reviewer)
|
||||
reviewer=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--fallback-reviewer)
|
||||
fallback_reviewer=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--codex-bin)
|
||||
codex_bin=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--claude-bin)
|
||||
claude_bin=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--pi-bin)
|
||||
pi_bin=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--opencode-bin)
|
||||
opencode_bin=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--droid-bin)
|
||||
droid_bin=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--copilot-bin)
|
||||
copilot_bin=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--full-access)
|
||||
yolo=1
|
||||
shift
|
||||
;;
|
||||
--no-yolo)
|
||||
yolo=0
|
||||
shift
|
||||
;;
|
||||
--output)
|
||||
output=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--parallel-tests)
|
||||
parallel_tests=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--dry-run)
|
||||
dry_run=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$yolo" in
|
||||
0|false|False|FALSE|no|No|NO|off|Off|OFF) ;;
|
||||
*) codex_args+=(--dangerously-bypass-approvals-and-sandbox --sandbox danger-full-access) ;;
|
||||
esac
|
||||
|
||||
case "$mode" in
|
||||
auto|local|branch|commit) ;;
|
||||
*)
|
||||
echo "invalid --mode: $mode" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$reviewer" in
|
||||
auto|codex|claude|pi|opencode|droid|copilot) ;;
|
||||
*)
|
||||
echo "invalid --reviewer: $reviewer" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$fallback_reviewer" in
|
||||
auto|claude|pi|opencode|droid|copilot|none) ;;
|
||||
*)
|
||||
echo "invalid --fallback-reviewer: $fallback_reviewer" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
repo_root=$(git rev-parse --show-toplevel)
|
||||
printf -v quoted_repo_root '%q' "$repo_root"
|
||||
|
||||
has_package_check_script() {
|
||||
command -v node >/dev/null 2>&1 || return 1
|
||||
node -e 'const { readFileSync } = require("node:fs"); const p = JSON.parse(readFileSync(process.argv[1], "utf8")); process.exit(p.scripts?.check ? 0 : 1)' \
|
||||
"$repo_root/package.json" \
|
||||
>/dev/null 2>&1
|
||||
}
|
||||
|
||||
auto_tests_disabled() {
|
||||
case "${AUTOREVIEW_AUTO_TESTS:-${CODEX_REVIEW_AUTO_TESTS:-1}}" in
|
||||
0|false|False|FALSE|no|No|NO|off|Off|OFF) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
current_branch=$(git branch --show-current 2>/dev/null || true)
|
||||
dirty=false
|
||||
if [[ -n "$(git status --porcelain)" ]]; then
|
||||
dirty=true
|
||||
fi
|
||||
|
||||
pr_url=
|
||||
if [[ -z "$base_ref" && "$mode" != local ]] && command -v gh >/dev/null 2>&1; then
|
||||
if pr_lines=$(gh pr view --json baseRefName,url --jq '[.baseRefName, .url] | @tsv' 2>/dev/null); then
|
||||
base_name=${pr_lines%%$'\t'*}
|
||||
pr_url=${pr_lines#*$'\t'}
|
||||
if [[ -n "$base_name" ]]; then
|
||||
base_ref="origin/$base_name"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "$base_ref" ]]; then
|
||||
base_ref=origin/main
|
||||
fi
|
||||
|
||||
review_kind=
|
||||
if [[ "$mode" == local || ( "$mode" == auto && "$dirty" == true ) ]]; then
|
||||
review_kind=local
|
||||
elif [[ "$mode" == commit ]]; then
|
||||
review_kind=commit
|
||||
elif [[ "$mode" == branch || ( "$mode" == auto && -n "$current_branch" && "$current_branch" != "main" ) ]]; then
|
||||
review_kind=branch
|
||||
else
|
||||
echo "no review target: clean main checkout and no forced mode" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$review_kind" == local ]]; then
|
||||
review_cmd=("$codex_bin" "${codex_args[@]}" review --uncommitted)
|
||||
elif [[ "$review_kind" == commit ]]; then
|
||||
review_cmd=("$codex_bin" "${codex_args[@]}" review --commit "$commit_ref")
|
||||
else
|
||||
review_cmd=("$codex_bin" "${codex_args[@]}" review --base "$base_ref")
|
||||
fi
|
||||
|
||||
repo_url=$(git -C "$repo_root" config --get remote.origin.url 2>/dev/null || true)
|
||||
case "$openclaw_maintainer_validation" in
|
||||
1|true|True|TRUE|yes|Yes|YES|on|On|ON) openclaw_maintainer_validation=1 ;;
|
||||
*) openclaw_maintainer_validation=0 ;;
|
||||
esac
|
||||
if [[ -z "$parallel_tests" && "$openclaw_maintainer_validation" != 1 ]] &&
|
||||
! auto_tests_disabled; then
|
||||
if [[ -f "$repo_root/package.json" && -f "$repo_root/pnpm-lock.yaml" && -d "$repo_root/node_modules" ]] &&
|
||||
command -v pnpm >/dev/null 2>&1 &&
|
||||
has_package_check_script; then
|
||||
parallel_tests="cd $quoted_repo_root && PNPM_CONFIG_PM_ON_FAIL=ignore PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=false PNPM_CONFIG_OFFLINE=true pnpm run check"
|
||||
parallel_tests_auto=true
|
||||
fi
|
||||
fi
|
||||
if [[ "$repo_url" == *"openclaw/openclaw"* && "$openclaw_maintainer_validation" == 1 ]]; then
|
||||
codex_review_prompt=$(cat <<'EOF'
|
||||
OpenClaw maintainer autoreview validation policy:
|
||||
- Review the diff by reading code, tests, and dependency contracts.
|
||||
- Do not run local memory-heavy Node validation from review mode. This includes local pnpm checks/tests, Vitest, tsgo, npm test, and node scripts/run-vitest.mjs.
|
||||
- If runtime proof is needed, use existing proof or route validation through Crabbox / Blacksmith Testbox and report the exact provider and id.
|
||||
- If remote validation is not necessary for the finding, state the targeted proof that should be run instead of starting local tests.
|
||||
EOF
|
||||
)
|
||||
if [[ "$review_kind" == local ]]; then
|
||||
review_cmd+=(-)
|
||||
codex_review_stdin_prompt=true
|
||||
else
|
||||
review_cmd=("$codex_bin" "${codex_args[@]}" review -)
|
||||
codex_review_prompt_file=true
|
||||
fi
|
||||
fi
|
||||
|
||||
printf 'autoreview target: %s\n' "$review_kind"
|
||||
printf 'branch: %s\n' "${current_branch:-detached}"
|
||||
if [[ -n "$pr_url" ]]; then
|
||||
printf 'pr: %s\n' "$pr_url"
|
||||
fi
|
||||
if [[ "$reviewer" == auto ]]; then
|
||||
printf 'reviewer: codex\n'
|
||||
else
|
||||
printf 'reviewer: %s\n' "$reviewer"
|
||||
fi
|
||||
case "$reviewer" in
|
||||
codex|auto) ;;
|
||||
*)
|
||||
printf 'note: Codex native review mode is the recommended and best-supported review path; %s uses a generated diff prompt.\n' "$reviewer"
|
||||
;;
|
||||
esac
|
||||
if [[ "$reviewer" == auto || "$reviewer" == codex ]]; then
|
||||
printf 'review:'
|
||||
printf ' %q' "${review_cmd[@]}"
|
||||
printf '\n'
|
||||
if [[ "$codex_review_stdin_prompt" == true || "$codex_review_prompt_file" == true ]]; then
|
||||
printf 'review policy: OpenClaw maintainer Crabbox/Testbox-aware validation prompt injected\n'
|
||||
fi
|
||||
else
|
||||
printf 'review: %s prompt review\n' "$reviewer"
|
||||
fi
|
||||
if [[ -n "$parallel_tests" ]]; then
|
||||
printf 'tests: %s' "$parallel_tests"
|
||||
if [[ "$parallel_tests_auto" == true ]]; then
|
||||
printf ' (auto)'
|
||||
fi
|
||||
printf '\n'
|
||||
fi
|
||||
if [[ "$review_kind" == branch ]]; then
|
||||
printf 'fetch: git fetch origin --quiet\n'
|
||||
fi
|
||||
if [[ -n "$output" ]]; then
|
||||
printf 'output: %s\n' "$output"
|
||||
fi
|
||||
|
||||
if [[ "$dry_run" == true ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$review_kind" == branch ]]; then
|
||||
git fetch origin --quiet || {
|
||||
echo "warning: git fetch origin failed; reviewing with existing refs" >&2
|
||||
}
|
||||
fi
|
||||
|
||||
review_output=$output
|
||||
review_output_is_temp=false
|
||||
if [[ -z "$review_output" ]]; then
|
||||
review_output=$(mktemp)
|
||||
review_output_is_temp=true
|
||||
fi
|
||||
mkdir -p "$(dirname "$review_output")"
|
||||
: > "$review_output"
|
||||
|
||||
cleanup() {
|
||||
if [[ "${review_output_is_temp:-false}" == true && -n "${review_output:-}" ]]; then
|
||||
rm -f "$review_output"
|
||||
fi
|
||||
if [[ -n "${prompt_file:-}" ]]; then
|
||||
rm -f "$prompt_file"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
run_review() {
|
||||
local status=0
|
||||
mkdir -p "$(dirname "$review_output")"
|
||||
if [[ "$codex_review_prompt_file" == true ]]; then
|
||||
build_prompt_file || return
|
||||
"${review_cmd[@]}" < "$prompt_file" 2>&1 | tee "$review_output"
|
||||
status=${PIPESTATUS[0]}
|
||||
rm -f "$prompt_file"
|
||||
prompt_file=
|
||||
return "$status"
|
||||
elif [[ "$codex_review_stdin_prompt" == true ]]; then
|
||||
printf '%s\n' "$codex_review_prompt" | "${review_cmd[@]}" 2>&1 | tee "$review_output"
|
||||
else
|
||||
"${review_cmd[@]}" 2>&1 | tee "$review_output"
|
||||
fi
|
||||
}
|
||||
|
||||
diff_for_review() {
|
||||
case "$review_kind" in
|
||||
local)
|
||||
git -C "$repo_root" diff --stat
|
||||
git -C "$repo_root" diff --cached --stat
|
||||
git -C "$repo_root" diff --find-renames
|
||||
git -C "$repo_root" diff --cached --find-renames
|
||||
while IFS= read -r untracked_file; do
|
||||
[[ -n "$untracked_file" ]] || continue
|
||||
git -C "$repo_root" diff --no-index -- /dev/null "$untracked_file" || true
|
||||
done < <(git -C "$repo_root" ls-files --others --exclude-standard)
|
||||
;;
|
||||
commit)
|
||||
git -C "$repo_root" show --find-renames --stat --format=fuller "$commit_ref"
|
||||
git -C "$repo_root" show --find-renames --format=medium "$commit_ref"
|
||||
;;
|
||||
branch)
|
||||
git -C "$repo_root" diff --find-renames --stat "$base_ref"...HEAD
|
||||
git -C "$repo_root" diff --find-renames "$base_ref"...HEAD
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
build_prompt_file() {
|
||||
prompt_file=$(mktemp)
|
||||
{
|
||||
cat <<EOF
|
||||
You are performing a closeout code review for the current repository.
|
||||
|
||||
Review target: $review_kind
|
||||
Branch: ${current_branch:-detached}
|
||||
Base: ${base_ref:-}
|
||||
Commit: ${commit_ref:-}
|
||||
|
||||
Rules:
|
||||
- Review the proposed code change as a closeout reviewer.
|
||||
- Focus on the diff below. If your CLI exposes read-only repository tools, inspect surrounding code and tests to verify findings; never modify files.
|
||||
- Do not modify files.
|
||||
${codex_review_prompt}
|
||||
- Report only discrete, actionable issues introduced by this change.
|
||||
- Prioritize correctness, regressions, security, data loss, performance cliffs, and missing tests that would catch a real bug.
|
||||
- Do not report pre-existing issues, speculative risks, broad rewrites, style nits, changelog gaps, or findings that depend on unstated assumptions.
|
||||
- Identify the concrete scenario where the issue appears, and keep the line reference as small as possible.
|
||||
- A finding should overlap changed code or clearly cite changed code as the cause.
|
||||
- For each accepted/actionable finding, use exactly this format:
|
||||
[P<0-3>] Short title
|
||||
File: path:line
|
||||
Why: one sentence
|
||||
Fix: one sentence
|
||||
- If no accepted/actionable findings, output exactly:
|
||||
autoreview clean: no accepted/actionable findings reported
|
||||
|
||||
Diff:
|
||||
EOF
|
||||
diff_for_review
|
||||
} > "$prompt_file" || return
|
||||
}
|
||||
|
||||
reviewer_output_has_clean_marker() {
|
||||
local path=$1
|
||||
grep -Eq '^[^[:alnum:]]*autoreview clean: no accepted/actionable findings reported[[:space:]]*$' "$path"
|
||||
}
|
||||
|
||||
run_prompt_reviewer() {
|
||||
local selected=$1
|
||||
local copilot_prompt=
|
||||
local prompt_bytes=0
|
||||
local reviewer_output
|
||||
local status=0
|
||||
|
||||
if ! build_prompt_file; then
|
||||
rm -f "${prompt_file:-}"
|
||||
prompt_file=
|
||||
return 1
|
||||
fi
|
||||
reviewer_output=$(mktemp)
|
||||
mkdir -p "$(dirname "$review_output")"
|
||||
|
||||
case "$selected" in
|
||||
claude)
|
||||
if ! command -v "$claude_bin" >/dev/null 2>&1; then
|
||||
echo "fallback reviewer unavailable: $claude_bin" >&2
|
||||
status=127
|
||||
elif printf 'fallback: claude -p\n' | tee -a "$review_output"; then
|
||||
"$claude_bin" --tools "" --no-session-persistence -p < "$prompt_file" 2>&1 | tee -a "$review_output" "$reviewer_output"
|
||||
status=$?
|
||||
else
|
||||
status=$?
|
||||
fi
|
||||
;;
|
||||
pi)
|
||||
if ! command -v "$pi_bin" >/dev/null 2>&1; then
|
||||
echo "fallback reviewer unavailable: $pi_bin" >&2
|
||||
status=127
|
||||
elif printf 'fallback: pi -p\n' | tee -a "$review_output"; then
|
||||
"$pi_bin" --no-tools --no-session -p < "$prompt_file" 2>&1 | tee -a "$review_output" "$reviewer_output"
|
||||
status=$?
|
||||
else
|
||||
status=$?
|
||||
fi
|
||||
;;
|
||||
opencode)
|
||||
if ! command -v "$opencode_bin" >/dev/null 2>&1; then
|
||||
echo "fallback reviewer unavailable: $opencode_bin" >&2
|
||||
status=127
|
||||
elif printf 'fallback: opencode run\n' | tee -a "$review_output"; then
|
||||
"$opencode_bin" run --pure --dir "$repo_root" \
|
||||
"Review the attached prompt file. Do not modify files." \
|
||||
--file "$prompt_file" 2>&1 | tee -a "$review_output" "$reviewer_output"
|
||||
status=$?
|
||||
else
|
||||
status=$?
|
||||
fi
|
||||
;;
|
||||
droid)
|
||||
if ! command -v "$droid_bin" >/dev/null 2>&1; then
|
||||
echo "fallback reviewer unavailable: $droid_bin" >&2
|
||||
status=127
|
||||
elif printf 'fallback: droid exec\n' | tee -a "$review_output"; then
|
||||
"$droid_bin" exec --cwd "$repo_root" -f "$prompt_file" 2>&1 | tee -a "$review_output" "$reviewer_output"
|
||||
status=$?
|
||||
else
|
||||
status=$?
|
||||
fi
|
||||
;;
|
||||
copilot)
|
||||
if ! command -v "$copilot_bin" >/dev/null 2>&1; then
|
||||
echo "fallback reviewer unavailable: $copilot_bin" >&2
|
||||
status=127
|
||||
elif printf 'fallback: copilot\n' | tee -a "$review_output"; then
|
||||
prompt_bytes=$(wc -c < "$prompt_file" | tr -d '[:space:]')
|
||||
if (( prompt_bytes > 120000 )); then
|
||||
echo "copilot reviewer unavailable: generated prompt is too large for copilot -p; use codex, droid, or another file/stdin-capable reviewer" \
|
||||
2>&1 | tee -a "$review_output" "$reviewer_output"
|
||||
status=1
|
||||
else
|
||||
copilot_prompt=$(< "$prompt_file")
|
||||
"$copilot_bin" -C "$repo_root" --available-tools=none --stream off --output-format text --silent \
|
||||
-p "$copilot_prompt" \
|
||||
2>&1 | tee -a "$review_output" "$reviewer_output"
|
||||
status=$?
|
||||
fi
|
||||
else
|
||||
status=$?
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "unsupported prompt reviewer: $selected" >&2
|
||||
status=2
|
||||
;;
|
||||
esac
|
||||
if [[ "$status" == 0 ]]; then
|
||||
if grep -Eq '\[P[0-3]\]' "$reviewer_output"; then
|
||||
status=1
|
||||
elif ! grep -q '[^[:space:]]' "$reviewer_output"; then
|
||||
status=1
|
||||
elif ! reviewer_output_has_clean_marker "$reviewer_output"; then
|
||||
status=1
|
||||
fi
|
||||
fi
|
||||
rm -f "$reviewer_output"
|
||||
rm -f "$prompt_file"
|
||||
prompt_file=
|
||||
return "$status"
|
||||
}
|
||||
|
||||
run_selected_review() {
|
||||
local selected=$1
|
||||
case "$selected" in
|
||||
codex)
|
||||
if ! command -v "$codex_bin" >/dev/null 2>&1; then
|
||||
echo "codex reviewer unavailable: $codex_bin" >&2
|
||||
return 127
|
||||
fi
|
||||
run_review
|
||||
;;
|
||||
claude|pi|opencode|droid|copilot)
|
||||
run_prompt_reviewer "$selected"
|
||||
;;
|
||||
*)
|
||||
echo "unsupported reviewer: $selected" >&2
|
||||
return 2
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
fallback_reviewer_is_available() {
|
||||
local selected=$1
|
||||
case "$selected" in
|
||||
claude) command -v "$claude_bin" >/dev/null 2>&1 ;;
|
||||
pi) command -v "$pi_bin" >/dev/null 2>&1 ;;
|
||||
opencode) command -v "$opencode_bin" >/dev/null 2>&1 ;;
|
||||
droid) command -v "$droid_bin" >/dev/null 2>&1 ;;
|
||||
copilot) command -v "$copilot_bin" >/dev/null 2>&1 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
run_auto_fallback_review() {
|
||||
local selected
|
||||
if [[ "$fallback_reviewer" != auto ]]; then
|
||||
run_selected_review "$fallback_reviewer"
|
||||
return $?
|
||||
fi
|
||||
|
||||
for selected in claude pi opencode droid copilot; do
|
||||
if fallback_reviewer_is_available "$selected"; then
|
||||
run_selected_review "$selected"
|
||||
return $?
|
||||
fi
|
||||
done
|
||||
|
||||
echo "fallback reviewer unavailable: no configured fallback CLI found" >&2
|
||||
return 127
|
||||
}
|
||||
|
||||
run_auto_review() {
|
||||
run_selected_review codex
|
||||
local status=$?
|
||||
if [[ "$status" == 0 ]]; then
|
||||
return 0
|
||||
fi
|
||||
if (( status > 128 && status < 192 )); then
|
||||
return "$status"
|
||||
fi
|
||||
if review_output_has_findings; then
|
||||
return "$status"
|
||||
fi
|
||||
if [[ "$fallback_reviewer" == none ]]; then
|
||||
return "$status"
|
||||
fi
|
||||
if [[ "$fallback_reviewer" == auto ]]; then
|
||||
printf 'autoreview warning: codex exited %s; trying configured fallback reviewers\n' "$status" >&2
|
||||
else
|
||||
printf 'autoreview warning: codex exited %s; falling back to %s\n' "$status" "$fallback_reviewer" >&2
|
||||
fi
|
||||
run_auto_fallback_review
|
||||
}
|
||||
|
||||
elapsed_since() {
|
||||
local started_at=$1
|
||||
local finished_at
|
||||
finished_at=$(date +%s)
|
||||
printf '%s\n' "$((finished_at - started_at))"
|
||||
}
|
||||
|
||||
format_elapsed() {
|
||||
local seconds=$1
|
||||
if (( seconds < 60 )); then
|
||||
printf '%ss\n' "$seconds"
|
||||
else
|
||||
printf '%sm%ss\n' "$((seconds / 60))" "$((seconds % 60))"
|
||||
fi
|
||||
}
|
||||
|
||||
review_output_empty() {
|
||||
[[ ! -s "$review_output" ]] || ! grep -q '[^[:space:]]' "$review_output"
|
||||
}
|
||||
|
||||
review_findings_text() {
|
||||
if grep -Fxq 'codex' "$review_output"; then
|
||||
awk '
|
||||
$0 == "codex" {
|
||||
capture = 1
|
||||
output = $0 ORS
|
||||
next
|
||||
}
|
||||
capture {
|
||||
output = output $0 ORS
|
||||
}
|
||||
END {
|
||||
printf "%s", output
|
||||
}
|
||||
' "$review_output"
|
||||
return
|
||||
fi
|
||||
cat "$review_output"
|
||||
}
|
||||
|
||||
review_output_has_findings() {
|
||||
review_findings_text | grep -Eq '\[P[0-3]\]'
|
||||
}
|
||||
|
||||
report_clean_review_or_fail() {
|
||||
local elapsed_text
|
||||
elapsed_text=$(format_elapsed "${review_elapsed_seconds:-0}")
|
||||
|
||||
if review_output_has_findings; then
|
||||
printf 'autoreview complete after %s\n' "$elapsed_text"
|
||||
printf 'autoreview findings: accepted/actionable findings reported\n'
|
||||
return 1
|
||||
fi
|
||||
if review_output_empty; then
|
||||
printf 'autoreview complete after %s; no output\n' "$elapsed_text"
|
||||
return 1
|
||||
fi
|
||||
printf 'autoreview complete after %s\n' "$elapsed_text"
|
||||
printf 'autoreview clean: no accepted/actionable findings reported\n'
|
||||
}
|
||||
|
||||
if [[ -z "$parallel_tests" ]]; then
|
||||
review_started_at=$(date +%s)
|
||||
set +e
|
||||
if [[ "$reviewer" == auto ]]; then
|
||||
run_auto_review
|
||||
else
|
||||
run_selected_review "$reviewer"
|
||||
fi
|
||||
review_status=$?
|
||||
review_elapsed_seconds=$(elapsed_since "$review_started_at")
|
||||
set -e
|
||||
if [[ "$review_status" == 0 ]]; then
|
||||
report_clean_review_or_fail
|
||||
exit $?
|
||||
fi
|
||||
exit "$review_status"
|
||||
fi
|
||||
|
||||
review_status_file=$(mktemp)
|
||||
review_elapsed_file=$(mktemp)
|
||||
tests_status_file=$(mktemp)
|
||||
|
||||
(
|
||||
set +e
|
||||
review_started_at=$(date +%s)
|
||||
if [[ "$reviewer" == auto ]]; then
|
||||
run_auto_review
|
||||
else
|
||||
run_selected_review "$reviewer"
|
||||
fi
|
||||
status=$?
|
||||
elapsed=$(elapsed_since "$review_started_at")
|
||||
printf '%s\n' "$status" > "$review_status_file"
|
||||
printf '%s\n' "$elapsed" > "$review_elapsed_file"
|
||||
) &
|
||||
review_pid=$!
|
||||
|
||||
(
|
||||
set +e
|
||||
bash -lc "$parallel_tests"
|
||||
status=$?
|
||||
printf '%s\n' "$status" > "$tests_status_file"
|
||||
) &
|
||||
tests_pid=$!
|
||||
|
||||
wait "$review_pid" || true
|
||||
wait "$tests_pid" || true
|
||||
|
||||
review_status=$(cat "$review_status_file")
|
||||
review_elapsed_seconds=$(cat "$review_elapsed_file")
|
||||
tests_status=$(cat "$tests_status_file")
|
||||
rm -f "$review_status_file" "$review_elapsed_file" "$tests_status_file"
|
||||
|
||||
printf 'autoreview exit: %s\n' "$review_status"
|
||||
printf 'tests exit: %s\n' "$tests_status"
|
||||
|
||||
if [[ "$review_status" != 0 || "$tests_status" != 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
report_clean_review_or_fail
|
||||
@@ -1,44 +0,0 @@
|
||||
---
|
||||
name: channel-message-flows
|
||||
description: "Use when previewing local channel message flow fixtures."
|
||||
---
|
||||
|
||||
# Channel Message Flows
|
||||
|
||||
Use this from the OpenClaw repo root to send canned channel preview flows while iterating on message UX. These are real sends/edits/deletes against the configured channel target.
|
||||
|
||||
## Telegram
|
||||
|
||||
Native Telegram `sendMessageDraft` tool progress, then a final answer:
|
||||
|
||||
```bash
|
||||
node --import tsx scripts/dev/channel-message-flows.ts \
|
||||
--channel telegram \
|
||||
--target <telegram-chat-id> \
|
||||
--flow working-final \
|
||||
--duration-ms 20000
|
||||
```
|
||||
|
||||
Thinking preview, then a final answer:
|
||||
|
||||
```bash
|
||||
node --import tsx scripts/dev/channel-message-flows.ts \
|
||||
--channel telegram \
|
||||
--target <telegram-chat-id> \
|
||||
--flow thinking-final
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
- `--account <accountId>`: Telegram account id when not using the default.
|
||||
- `--thread-id <id>`: Telegram forum topic/message thread id.
|
||||
- `--delay-ms <ms>`: Override preview update cadence.
|
||||
- `--duration-ms <ms>`: Simulated working duration for `working-final`.
|
||||
- `--final-text <text>`: Override the durable final message.
|
||||
|
||||
## Notes
|
||||
|
||||
- `--target` is the numeric Telegram chat id.
|
||||
- `working-final` exercises native Telegram `sendMessageDraft` with static `Working` status and sample tool progress.
|
||||
- `thinking-final` exercises formatted `Thinking` reasoning preview clearing before the final answer.
|
||||
- Only `--channel telegram` is implemented for now.
|
||||
103
.agents/skills/codex-review/SKILL.md
Normal file
103
.agents/skills/codex-review/SKILL.md
Normal file
@@ -0,0 +1,103 @@
|
||||
---
|
||||
name: codex-review
|
||||
description: "Codex code review closeout: local dirty changes, PR branch vs main, parallel tests."
|
||||
---
|
||||
|
||||
# Codex Review
|
||||
|
||||
Run Codex's built-in code review as a closeout check. This is code review (`codex review`), not Guardian `auto_review` approval routing.
|
||||
|
||||
Use when:
|
||||
- user asks for Codex review / autoreview / second-model review
|
||||
- after non-trivial code edits, before final/commit/ship
|
||||
- reviewing a local branch or PR branch after fixes
|
||||
|
||||
## Contract
|
||||
|
||||
- Treat review output as advisory. Never blindly apply it.
|
||||
- Verify every finding by reading the real code path and adjacent files.
|
||||
- Read dependency docs/source/types when the finding depends on external behavior.
|
||||
- Reject unrealistic edge cases, speculative risks, broad rewrites, and fixes that over-complicate the codebase.
|
||||
- Prefer small fixes at the right ownership boundary; no refactor unless it clearly improves the bug class.
|
||||
- Keep going until Codex review returns no accepted/actionable findings.
|
||||
- If a review-triggered fix changes code, rerun focused tests and rerun Codex review.
|
||||
- If rejecting a finding as intentional/not worth fixing, add a brief inline code comment only when it explains a real invariant or ownership decision that future reviewers should know.
|
||||
- Do not push just to review. Push only when the user requested push/ship/PR update.
|
||||
|
||||
## Pick Target
|
||||
|
||||
Dirty local work:
|
||||
|
||||
```bash
|
||||
codex review --uncommitted
|
||||
```
|
||||
|
||||
Branch/PR work:
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
codex review --base origin/main
|
||||
```
|
||||
|
||||
Do not pass an inline prompt with `--base`; current CLI rejects `--base` + `[PROMPT]` even though help text is ambiguous. If custom instructions are needed, run the plain base review first, then do a local/manual follow-up pass.
|
||||
|
||||
If an open PR exists, use its actual base:
|
||||
|
||||
```bash
|
||||
base=$(gh pr view --json baseRefName --jq .baseRefName)
|
||||
codex review --base "origin/$base"
|
||||
```
|
||||
|
||||
Committed single change:
|
||||
|
||||
```bash
|
||||
codex review --commit HEAD
|
||||
```
|
||||
|
||||
## Parallel Closeout
|
||||
|
||||
Format first if formatting can change line locations. Then it is OK to run tests and review in parallel:
|
||||
|
||||
```bash
|
||||
scripts/codex-review --parallel-tests "<focused test command>"
|
||||
```
|
||||
|
||||
Tradeoff: tests may force code changes that stale the review. If tests or review lead to code edits, rerun the affected tests and rerun review until no accepted/actionable findings remain.
|
||||
|
||||
## Context Efficiency
|
||||
|
||||
Codex review is usually noisy. Default to a subagent filter when subagents are available. Ask it to run the review and return only:
|
||||
- actionable findings it accepts
|
||||
- findings it rejects, with one-line reason
|
||||
- exact files/tests to rerun
|
||||
|
||||
Run inline only for tiny changes or when subagents are unavailable.
|
||||
|
||||
## Helper
|
||||
|
||||
Bundled helper:
|
||||
|
||||
```bash
|
||||
~/.codex/skills/codex-review/scripts/codex-review --help
|
||||
```
|
||||
|
||||
If installed from `agent-scripts`, path is:
|
||||
|
||||
```bash
|
||||
/Users/steipete/Projects/agent-scripts/skills/codex-review/scripts/codex-review --help
|
||||
```
|
||||
|
||||
The helper:
|
||||
- chooses dirty `--uncommitted` first
|
||||
- otherwise uses current PR base if `gh pr view` works
|
||||
- otherwise uses `origin/main` for non-main branches
|
||||
- writes only to stdout unless `--output` or `CODEX_REVIEW_OUTPUT` is set
|
||||
- supports `--dry-run` and `--parallel-tests`
|
||||
|
||||
## Final Report
|
||||
|
||||
Include:
|
||||
- review command used
|
||||
- tests/proof run
|
||||
- findings accepted/rejected, briefly why
|
||||
- final clean review command, or why a remaining finding was consciously rejected
|
||||
188
.agents/skills/codex-review/scripts/codex-review
Executable file
188
.agents/skills/codex-review/scripts/codex-review
Executable file
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: codex-review [options]
|
||||
|
||||
Options:
|
||||
--mode auto|local|branch Target selection. Default: auto.
|
||||
--base REF Base ref for branch review. Default: PR base or origin/main.
|
||||
--codex-bin PATH Codex binary. Default: codex.
|
||||
--output FILE Also save output to file.
|
||||
--parallel-tests CMD Run review and test command concurrently.
|
||||
--dry-run Print selected commands, do not run.
|
||||
-h, --help Show help.
|
||||
|
||||
Modes:
|
||||
local codex review --uncommitted
|
||||
branch codex review --base <base>
|
||||
auto dirty tree -> local, else PR/current branch -> branch
|
||||
EOF
|
||||
}
|
||||
|
||||
mode=auto
|
||||
base_ref=
|
||||
codex_bin=${CODEX_BIN:-codex}
|
||||
output=${CODEX_REVIEW_OUTPUT:-}
|
||||
parallel_tests=
|
||||
dry_run=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--mode)
|
||||
mode=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--base)
|
||||
base_ref=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--codex-bin)
|
||||
codex_bin=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--output)
|
||||
output=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--parallel-tests)
|
||||
parallel_tests=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--dry-run)
|
||||
dry_run=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$mode" in
|
||||
auto|local|branch) ;;
|
||||
*)
|
||||
echo "invalid --mode: $mode" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
git rev-parse --show-toplevel >/dev/null
|
||||
|
||||
current_branch=$(git branch --show-current 2>/dev/null || true)
|
||||
dirty=false
|
||||
if [[ -n "$(git status --porcelain)" ]]; then
|
||||
dirty=true
|
||||
fi
|
||||
|
||||
pr_url=
|
||||
if [[ -z "$base_ref" && "$mode" != local ]] && command -v gh >/dev/null 2>&1; then
|
||||
if pr_lines=$(gh pr view --json baseRefName,url --jq '[.baseRefName, .url] | @tsv' 2>/dev/null); then
|
||||
base_name=${pr_lines%%$'\t'*}
|
||||
pr_url=${pr_lines#*$'\t'}
|
||||
if [[ -n "$base_name" ]]; then
|
||||
base_ref="origin/$base_name"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "$base_ref" ]]; then
|
||||
base_ref=origin/main
|
||||
fi
|
||||
|
||||
review_kind=
|
||||
if [[ "$mode" == local || ( "$mode" == auto && "$dirty" == true ) ]]; then
|
||||
review_kind=local
|
||||
elif [[ "$mode" == branch || ( "$mode" == auto && -n "$current_branch" && "$current_branch" != "main" ) ]]; then
|
||||
review_kind=branch
|
||||
else
|
||||
echo "no review target: clean main checkout and no forced mode" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$review_kind" == local ]]; then
|
||||
review_cmd=("$codex_bin" review --uncommitted)
|
||||
else
|
||||
review_cmd=("$codex_bin" review --base "$base_ref")
|
||||
fi
|
||||
|
||||
printf 'codex-review target: %s\n' "$review_kind"
|
||||
printf 'branch: %s\n' "${current_branch:-detached}"
|
||||
if [[ -n "$pr_url" ]]; then
|
||||
printf 'pr: %s\n' "$pr_url"
|
||||
fi
|
||||
printf 'review:'
|
||||
printf ' %q' "${review_cmd[@]}"
|
||||
printf '\n'
|
||||
if [[ -n "$parallel_tests" ]]; then
|
||||
printf 'tests: %s\n' "$parallel_tests"
|
||||
fi
|
||||
if [[ "$review_kind" == branch ]]; then
|
||||
printf 'fetch: git fetch origin --quiet\n'
|
||||
fi
|
||||
if [[ -n "$output" ]]; then
|
||||
printf 'output: %s\n' "$output"
|
||||
fi
|
||||
|
||||
if [[ "$dry_run" == true ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$review_kind" == branch ]]; then
|
||||
git fetch origin --quiet || {
|
||||
echo "warning: git fetch origin failed; reviewing with existing refs" >&2
|
||||
}
|
||||
fi
|
||||
|
||||
run_review() {
|
||||
if [[ -n "$output" ]]; then
|
||||
mkdir -p "$(dirname "$output")"
|
||||
"${review_cmd[@]}" 2>&1 | tee "$output"
|
||||
else
|
||||
"${review_cmd[@]}"
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ -z "$parallel_tests" ]]; then
|
||||
run_review
|
||||
exit $?
|
||||
fi
|
||||
|
||||
review_status_file=$(mktemp)
|
||||
tests_status_file=$(mktemp)
|
||||
|
||||
(
|
||||
set +e
|
||||
run_review
|
||||
status=$?
|
||||
printf '%s\n' "$status" > "$review_status_file"
|
||||
) &
|
||||
review_pid=$!
|
||||
|
||||
(
|
||||
set +e
|
||||
bash -lc "$parallel_tests"
|
||||
status=$?
|
||||
printf '%s\n' "$status" > "$tests_status_file"
|
||||
) &
|
||||
tests_pid=$!
|
||||
|
||||
wait "$review_pid" || true
|
||||
wait "$tests_pid" || true
|
||||
|
||||
review_status=$(cat "$review_status_file")
|
||||
tests_status=$(cat "$tests_status_file")
|
||||
rm -f "$review_status_file" "$tests_status_file"
|
||||
|
||||
printf 'codex-review exit: %s\n' "$review_status"
|
||||
printf 'tests exit: %s\n' "$tests_status"
|
||||
|
||||
if [[ "$review_status" != 0 || "$tests_status" != 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,32 +1,23 @@
|
||||
---
|
||||
name: crabbox
|
||||
description: Use the Crabbox wrapper for OpenClaw remote validation across Linux, macOS, Windows, and WSL2, including delegated Blacksmith Testbox proof. Report the actual provider and id.
|
||||
description: Use Crabbox for OpenClaw remote validation across Linux, macOS, Windows, and WSL2. Default to the repo Crabbox config, use brokered AWS for normal broad proof, and keep Blacksmith Testbox as an explicit opt-in or outage diagnostic path.
|
||||
---
|
||||
|
||||
# Crabbox
|
||||
|
||||
Use the Crabbox wrapper when OpenClaw needs remote Linux proof for broad tests,
|
||||
CI-parity checks, secrets, hosted services, Docker/E2E/package lanes, warmed
|
||||
reusable boxes, sync timing, logs/results, cache inspection, or lease cleanup.
|
||||
Use Crabbox when OpenClaw needs remote Linux proof for broad tests, CI-parity
|
||||
checks, secrets, hosted services, Docker/E2E/package lanes, warmed reusable
|
||||
boxes, sync timing, logs/results, cache inspection, or lease cleanup.
|
||||
|
||||
Crabbox is the transport/orchestration surface. The actual backend can be:
|
||||
Default backend: the repo `.crabbox.yaml`, currently brokered AWS. Do not
|
||||
override it to Blacksmith unless the user explicitly asks for Blacksmith proof,
|
||||
the task is specifically about Testbox behavior, or AWS/brokered Crabbox is the
|
||||
broken layer.
|
||||
|
||||
- brokered AWS Crabbox: direct provider, `provider=aws`, lease ids like
|
||||
`cbx_...`, `syncDelegated=false`
|
||||
- Blacksmith Testbox through Crabbox: delegated provider,
|
||||
`provider=blacksmith-testbox`, ids like `tbx_...`, `syncDelegated=true`
|
||||
|
||||
For OpenClaw maintainer broad `pnpm` gates, Blacksmith Testbox through the
|
||||
Crabbox wrapper is acceptable and often preferred when the standing Testbox
|
||||
rules apply. Do not describe those runs as "AWS Crabbox"; report them as
|
||||
Testbox-through-Crabbox with the `tbx_...` id and Actions run.
|
||||
|
||||
Use the repo `.crabbox.yaml` brokered AWS path when the task specifically needs
|
||||
direct AWS Crabbox behavior, persistent direct-provider leases, `--fresh-pr`,
|
||||
`--full-resync`, environment forwarding, capture/download support, or provider
|
||||
comparison. Use `--provider blacksmith-testbox` when the task needs OpenClaw
|
||||
maintainer Testbox proof, prepared CI environment, broad/heavy pnpm gates, or
|
||||
the user asks for Testbox/Blacksmith.
|
||||
Blacksmith Testbox is a delegated fallback, not the default router. If a
|
||||
Blacksmith run queues, fails capacity, fails auth, or cannot allocate, stop
|
||||
after one real attempt and switch to the repo default or report the blocker.
|
||||
Do not retry Blacksmith in a loop.
|
||||
|
||||
## First Checks
|
||||
|
||||
@@ -43,19 +34,10 @@ pnpm crabbox:run -- --help | sed -n '1,120p'
|
||||
|
||||
- OpenClaw scripts prefer `../crabbox/bin/crabbox` when present. The user PATH
|
||||
shim can be stale.
|
||||
- Check `.crabbox.yaml` for direct-provider defaults. Omitting `--provider`
|
||||
means brokered AWS today.
|
||||
- The brokered AWS default is a Linux developer image in `eu-west-1`; the repo
|
||||
config pins hot `eu-west-1a/b/c` placement so Fast Snapshot Restore can apply.
|
||||
If warmup drifts well past the minute-scale path, verify image promotion,
|
||||
region/AZ placement, and FSR state before blaming OpenClaw.
|
||||
- For broad OpenClaw maintainer `pnpm` gates, prefer the repo wrapper with
|
||||
`--provider blacksmith-testbox` or the repo Testbox helpers when the standing
|
||||
Testbox policy applies.
|
||||
- Always report the actual provider and id. `cbx_...` means AWS Crabbox;
|
||||
`tbx_...` means Blacksmith Testbox through Crabbox. If the output only says
|
||||
`blacksmith testbox list`, use `blacksmith testbox list --all` before
|
||||
concluding no box exists.
|
||||
- Check `.crabbox.yaml` for repo defaults and honor them. For normal Linux
|
||||
validation, omit `--provider` so the wrapper uses brokered AWS.
|
||||
- Pass `--provider blacksmith-testbox` only for explicit Blacksmith/Testbox
|
||||
work or a deliberate comparison.
|
||||
- If a warm direct-provider lease smells stale, retry with `--full-resync`
|
||||
(alias `--fresh-sync`) before replacing the lease. This resets the remote
|
||||
workdir, skips the fingerprint fast path, reseeds Git when possible, and
|
||||
@@ -82,41 +64,6 @@ Use these only when the task needs an existing non-Linux host. OpenClaw broad
|
||||
Linux validation uses the repo Crabbox config unless a provider is explicitly
|
||||
requested.
|
||||
|
||||
Native brokered Windows is available for Windows-specific proof. Use the AWS
|
||||
developer image in `us-west-2` on demand; it has the expected OpenClaw developer
|
||||
toolchain and Docker image cache. Keep broad Linux gates on Linux/Testbox unless
|
||||
the bug is Windows-specific:
|
||||
|
||||
```sh
|
||||
../crabbox/bin/crabbox warmup \
|
||||
--provider aws \
|
||||
--target windows \
|
||||
--windows-mode normal \
|
||||
--region us-west-2 \
|
||||
--market on-demand \
|
||||
--timing-json
|
||||
```
|
||||
|
||||
The hydrate workflow assumes Docker should already be baked into Linux images
|
||||
and only installs it as a fallback. Do not add per-run Docker installs to proof
|
||||
commands unless the image probe shows Docker is actually missing.
|
||||
|
||||
When the user explicitly asks for brokered macOS runners, use Crabbox AWS
|
||||
macOS only after confirming the deployed coordinator supports EC2 Mac host
|
||||
lifecycle/image routes and the operator has AWS EC2 Mac Dedicated Host quota
|
||||
and IAM. Prefer `CRABBOX_HOST_ID` for a known Crabbox-managed Dedicated Host,
|
||||
or run the no-spend preflight first:
|
||||
|
||||
```sh
|
||||
crabbox admin hosts quota --provider aws --target macos --region eu-west-1 --type mac2.metal --json
|
||||
crabbox admin hosts allocate --provider aws --target macos --region eu-west-1 --type mac2.metal --dry-run --json
|
||||
CRABBOX_MACOS_TYPES=all scripts/macos-host-region-preflight.sh
|
||||
```
|
||||
|
||||
Do not silently substitute AWS macOS for normal OpenClaw Linux proof. Report
|
||||
paid-host blockers as quota, IAM, coordinator deployment, or host availability
|
||||
instead of falling back to local macOS.
|
||||
|
||||
Crabbox supports static SSH targets:
|
||||
|
||||
```sh
|
||||
@@ -136,10 +83,11 @@ Crabbox supports static SSH targets:
|
||||
with `../crabbox/bin/crabbox run --help`, config/flag tests, and the Crabbox
|
||||
Go test suite.
|
||||
|
||||
## Direct Brokered AWS Backend
|
||||
## Default Brokered AWS Backend
|
||||
|
||||
Use this when the task needs direct AWS Crabbox semantics rather than the
|
||||
prepared Blacksmith Testbox CI environment.
|
||||
Use this for `pnpm check`, `pnpm check:changed`, `pnpm test`,
|
||||
`pnpm test:changed`, Docker/E2E/live/package gates, or anything likely to fan
|
||||
out across many Vitest projects.
|
||||
|
||||
Changed gate:
|
||||
|
||||
@@ -176,9 +124,9 @@ pnpm crabbox:run -- \
|
||||
|
||||
Read the JSON summary. Useful fields:
|
||||
|
||||
- `provider`: `aws`
|
||||
- `provider`: should normally be `aws`
|
||||
- `leaseId`: `cbx_...`
|
||||
- `syncDelegated`: `false`
|
||||
- `syncDelegated`: should normally be `false`
|
||||
- `commandPhases`: populated when the command prints `CRABBOX_PHASE:<name>`
|
||||
- `commandMs` / `totalMs`
|
||||
- `exitCode`
|
||||
@@ -190,41 +138,6 @@ cleanup when a run fails, is interrupted, or the command output is unclear:
|
||||
../crabbox/bin/crabbox list --provider aws
|
||||
```
|
||||
|
||||
## Blacksmith Testbox Through Crabbox
|
||||
|
||||
Use this for OpenClaw maintainer broad/heavy `pnpm` gates when the prepared CI
|
||||
environment is the right proof surface:
|
||||
|
||||
```sh
|
||||
node scripts/crabbox-wrapper.mjs run \
|
||||
--provider blacksmith-testbox \
|
||||
--blacksmith-org openclaw \
|
||||
--blacksmith-workflow .github/workflows/ci-check-testbox.yml \
|
||||
--blacksmith-job check \
|
||||
--blacksmith-ref main \
|
||||
--idle-timeout 90m \
|
||||
--ttl 240m \
|
||||
--timing-json \
|
||||
-- \
|
||||
CI=1 NODE_OPTIONS=--max-old-space-size=4096 OPENCLAW_TEST_PROJECTS_PARALLEL=6 OPENCLAW_VITEST_MAX_WORKERS=1 OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS=900000 OPENCLAW_TESTBOX=1 OPENCLAW_TESTBOX_REMOTE_RUN=1 pnpm check:changed
|
||||
```
|
||||
|
||||
Read the JSON summary and the Testbox line. Useful fields:
|
||||
|
||||
- `provider`: `blacksmith-testbox`
|
||||
- `leaseId`: `tbx_...`
|
||||
- `syncDelegated`: `true`
|
||||
- `syncPhases`: delegated/skipped because Blacksmith owns checkout/sync
|
||||
- Actions run URL/id from the Testbox output
|
||||
- `exitCode`
|
||||
|
||||
`blacksmith testbox list` may hide hydrating or ready boxes. Use:
|
||||
|
||||
```sh
|
||||
blacksmith testbox list --all
|
||||
blacksmith testbox status <tbx_id>
|
||||
```
|
||||
|
||||
## Observability Flags
|
||||
|
||||
Use these on debugging runs before inventing ad hoc logging:
|
||||
@@ -310,13 +223,6 @@ Use the smallest Crabbox lane that proves the reported user path, not just the
|
||||
touched code. Aim for one after-fix E2E proof before commenting, closing, or
|
||||
opening a PR for a user-visible bug.
|
||||
|
||||
When the user says "test in Crabbox", do not simply copy tests to the remote
|
||||
box and run them there. Crabbox is for remote real-scenario proof: copy or
|
||||
install OpenClaw as the user would, run the same setup/update/CLI/Gateway/API
|
||||
call that failed, and capture behavior from that entrypoint. For regressions or
|
||||
bug reports, prove the broken state first when feasible, then run the same
|
||||
scenario after the fix.
|
||||
|
||||
Pick the lane by symptom:
|
||||
|
||||
- Docker/setup/install bug: build a package tarball and run the matching
|
||||
@@ -338,9 +244,8 @@ Pick the lane by symptom:
|
||||
|
||||
Efficient flow:
|
||||
|
||||
1. Reproduce or prove the pre-fix symptom from the real user-facing entrypoint
|
||||
when feasible. If the issue cannot be reproduced, capture the exact command
|
||||
and observed behavior instead.
|
||||
1. Reproduce or prove the pre-fix symptom when feasible. If the issue cannot be
|
||||
reproduced, capture the exact command and observed behavior instead.
|
||||
2. Patch locally and run narrow local tests for edit speed.
|
||||
3. Run one Crabbox E2E command that starts from the user-facing entrypoint:
|
||||
package install, Docker setup, onboarding, channel add, gateway start, or
|
||||
@@ -448,18 +353,18 @@ Common desktop flow:
|
||||
|
||||
```sh
|
||||
../crabbox/bin/crabbox warmup --provider hetzner --desktop --browser --class standard --idle-timeout 60m --ttl 240m
|
||||
../crabbox/bin/crabbox desktop launch --provider hetzner --id <cbx_id-or-slug> --browser --url https://example.com --webvnc --open --take-control
|
||||
../crabbox/bin/crabbox desktop launch --provider hetzner --id <cbx_id-or-slug> --browser --url https://example.com --webvnc --open
|
||||
```
|
||||
|
||||
Useful WebVNC commands:
|
||||
|
||||
```sh
|
||||
../crabbox/bin/crabbox webvnc --provider hetzner --id <cbx_id-or-slug> --open --take-control
|
||||
../crabbox/bin/crabbox webvnc daemon start --provider hetzner --id <cbx_id-or-slug> --open --take-control
|
||||
../crabbox/bin/crabbox webvnc --provider hetzner --id <cbx_id-or-slug> --open
|
||||
../crabbox/bin/crabbox webvnc daemon start --provider hetzner --id <cbx_id-or-slug> --open
|
||||
../crabbox/bin/crabbox webvnc daemon status --provider hetzner --id <cbx_id-or-slug>
|
||||
../crabbox/bin/crabbox webvnc daemon stop --provider hetzner --id <cbx_id-or-slug>
|
||||
../crabbox/bin/crabbox webvnc status --provider hetzner --id <cbx_id-or-slug>
|
||||
../crabbox/bin/crabbox webvnc reset --provider hetzner --id <cbx_id-or-slug> --open --take-control
|
||||
../crabbox/bin/crabbox webvnc reset --provider hetzner --id <cbx_id-or-slug> --open
|
||||
../crabbox/bin/crabbox desktop doctor --provider hetzner --id <cbx_id-or-slug>
|
||||
../crabbox/bin/crabbox desktop click --provider hetzner --id <cbx_id-or-slug> --x 640 --y 420
|
||||
../crabbox/bin/crabbox desktop paste --provider hetzner --id <cbx_id-or-slug> --text "user@example.com"
|
||||
@@ -472,32 +377,6 @@ Useful WebVNC commands:
|
||||
browser/app inside the visible session, bridges the lease into the authenticated
|
||||
WebVNC portal, and opens the portal. Keep browsers windowed for human QA; use
|
||||
`--fullscreen` only for capture/video workflows.
|
||||
For human handoff, include `--take-control` so the opened portal viewer gets
|
||||
keyboard/mouse control automatically instead of landing as an observer.
|
||||
|
||||
Human handoff preflight:
|
||||
|
||||
- Do not assume a visible desktop or launched browser means the repo CLI/app is
|
||||
installed, built, or on the interactive terminal's `PATH`.
|
||||
- Before handing WebVNC to a human tester, prove the expected command from the
|
||||
same kept lease and from a neutral directory such as `~`.
|
||||
- If the handoff needs repo-local code, sync/build/link it explicitly on that
|
||||
lease. Source-tree CLIs often need build output before a symlink works.
|
||||
- Prefer a real `command -v <expected-command> && <expected-command> --version`
|
||||
check over a repo-root-only `pnpm ...` command.
|
||||
|
||||
Generic handoff repair pattern:
|
||||
|
||||
```sh
|
||||
../crabbox/bin/crabbox run --id <cbx_id-or-slug> --full-resync --shell -- \
|
||||
"set -euo pipefail
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build
|
||||
sudo ln -sf \"\$PWD/<cli-entry>\" /usr/local/bin/<expected-command>
|
||||
cd ~
|
||||
command -v <expected-command>
|
||||
<expected-command> --version"
|
||||
```
|
||||
|
||||
## If Crabbox Fails
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
---
|
||||
name: discrawl
|
||||
description: "Discord archive: search, sync freshness, DMs, channel slices, SQL counts, and Discrawl repo work."
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/openclaw/discrawl
|
||||
requires:
|
||||
bins:
|
||||
- discrawl
|
||||
install:
|
||||
- kind: go
|
||||
module: github.com/openclaw/discrawl/cmd/discrawl@latest
|
||||
bins:
|
||||
- discrawl
|
||||
---
|
||||
|
||||
# Discrawl
|
||||
|
||||
Use local Discord archive data before live Discord APIs. Check freshness for recent/current questions:
|
||||
|
||||
```bash
|
||||
discrawl status --json
|
||||
discrawl doctor
|
||||
```
|
||||
|
||||
Refresh only when stale or asked:
|
||||
|
||||
```bash
|
||||
discrawl sync --source wiretap
|
||||
discrawl sync
|
||||
```
|
||||
|
||||
Query with bounded slices:
|
||||
|
||||
```bash
|
||||
DISCRAWL_NO_AUTO_UPDATE=1 discrawl search --limit 20 "query"
|
||||
discrawl messages --channel '#maintainers' --days 7 --all
|
||||
discrawl dms --last 20
|
||||
DISCRAWL_NO_AUTO_UPDATE=1 discrawl --json sql "select count(*) from messages;"
|
||||
```
|
||||
|
||||
Report absolute date spans, channel/DM names, counts, and known gaps. Use read-only SQL for exact counts/rankings. Never use `--unsafe --confirm` unless the user explicitly requests a reviewed DB mutation.
|
||||
|
||||
Boundaries: bot sync needs configured Discord bot credentials. Wiretap reads local Discord Desktop artifacts only; do not extract user tokens, call Discord as the user, or write to Discord storage. Git-share snapshots must not include secrets or `@me` DM rows.
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "Discrawl"
|
||||
short_description: "Search local Discord archives and freshness"
|
||||
default_prompt: "Use $discrawl to search local Discord archives, check freshness, inspect DMs or channel slices, and report exact date spans and source gaps."
|
||||
@@ -1,50 +1,68 @@
|
||||
---
|
||||
name: gitcrawl
|
||||
description: "GitHub archive: issue/PR search, sync freshness, duplicate clusters, gh-shim PR status, and Gitcrawl repo work."
|
||||
description: Use gitcrawl for OpenClaw issue and PR archive search, duplicate discovery, related-thread clustering, and local GitHub mirror freshness checks.
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/openclaw/gitcrawl
|
||||
requires:
|
||||
bins:
|
||||
- gitcrawl
|
||||
install:
|
||||
- kind: go
|
||||
module: github.com/openclaw/gitcrawl/cmd/gitcrawl@latest
|
||||
bins:
|
||||
- gitcrawl
|
||||
---
|
||||
|
||||
# Gitcrawl
|
||||
|
||||
Use local GitHub issue/PR archives before live GitHub search. Check freshness first:
|
||||
Use this skill before live GitHub search when triaging OpenClaw issues or PRs.
|
||||
|
||||
`gitcrawl` is the local candidate-discovery layer. It is fast, includes open and closed threads, and can surface duplicate attempts, related issues, and already-landed fixes. It is not the final source of truth for comments, labels, merges, closes, or current CI.
|
||||
|
||||
## Default Flow
|
||||
|
||||
1. Check local state:
|
||||
|
||||
```bash
|
||||
gitcrawl doctor --json
|
||||
```
|
||||
|
||||
Find candidates:
|
||||
2. Read the target from the local archive:
|
||||
|
||||
```bash
|
||||
gitcrawl threads openclaw/openclaw --numbers <issue-or-pr-number> --include-closed --json
|
||||
gitcrawl neighbors openclaw/openclaw --number <issue-or-pr-number> --limit 12 --json
|
||||
gitcrawl search issues "query" -R openclaw/openclaw --state open --json number,title,url
|
||||
gitcrawl clusters openclaw/openclaw --sort size --min-size 5
|
||||
gitcrawl cluster-detail openclaw/openclaw --id <cluster-id>
|
||||
```
|
||||
|
||||
For PR triage, start cached and go live only before mutation/merge decisions:
|
||||
3. Find related candidates:
|
||||
|
||||
```bash
|
||||
gitcrawl gh pr status <number-or-url> -R openclaw/openclaw --compact
|
||||
gitcrawl gh pr view <number-or-url> -R openclaw/openclaw --json number,title,state,url,isDraft,headRef,headSha
|
||||
gitcrawl gh --live pr status <number-or-url> -R openclaw/openclaw --compact
|
||||
gitcrawl neighbors openclaw/openclaw --number <issue-or-pr-number> --limit 12 --json
|
||||
gitcrawl search openclaw/openclaw --query "<scope or title keywords>" --mode hybrid --limit 20 --json
|
||||
```
|
||||
|
||||
Use live `gh` plus checkout proof before commenting, labeling, closing, reopening, merging, or filing a PR review:
|
||||
4. Inspect relevant clusters:
|
||||
|
||||
```bash
|
||||
gitcrawl cluster-detail openclaw/openclaw --id <cluster-id> --member-limit 20 --body-chars 280 --json
|
||||
```
|
||||
|
||||
5. Verify anything actionable with live GitHub and the checkout:
|
||||
|
||||
```bash
|
||||
gh pr view <number> --json number,title,state,mergedAt,body,files,comments,reviews,statusCheckRollup
|
||||
gh issue view <number> --json number,title,state,body,comments,closedAt
|
||||
```
|
||||
|
||||
Report absolute dates, repo names, issue/PR numbers, cluster ids, and source gaps. Do not close/label from similarity alone; require matching intent plus live verification.
|
||||
## Freshness Rules
|
||||
|
||||
- Treat `gitcrawl` as stale if `doctor` shows no target thread, an old `last_sync_at`, missing embeddings for neighbor/search commands, or a clearly wrong open/closed state.
|
||||
- If stale data blocks the decision, refresh the portable store first:
|
||||
|
||||
```bash
|
||||
gitcrawl init --portable-store git@github.com:openclaw/gitcrawl-store.git --json
|
||||
```
|
||||
|
||||
- Run expensive update commands such as `gitcrawl sync --include-comments` only when the user asked to update the local store or stale data is blocking the decision.
|
||||
- The sync default is all GitHub thread states; pass `--state open`, `--state closed`, or `--state all` only when a task requires a narrower or explicit scope.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Use `gitcrawl` for candidates, clusters, and historical context.
|
||||
- Use `gh`, `gh api`, and the current checkout for live state before commenting, labeling, closing, reopening, merging, or filing a PR review.
|
||||
- Do not close or label based only on `gitcrawl` similarity. Require matching problem intent plus live verification.
|
||||
- If `gitcrawl` is unavailable, say so and fall back to targeted `gh search` rather than blocking normal maintainer work.
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
---
|
||||
name: graincrawl
|
||||
description: "Granola archive: search, sync freshness, notes, transcripts, panels, SQL counts, and Graincrawl repo work."
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/openclaw/graincrawl
|
||||
requires:
|
||||
bins:
|
||||
- graincrawl
|
||||
install:
|
||||
- kind: go
|
||||
module: github.com/vincentkoc/graincrawl/cmd/graincrawl@latest
|
||||
bins:
|
||||
- graincrawl
|
||||
---
|
||||
|
||||
# Graincrawl
|
||||
|
||||
Use local Granola archive data first. Check freshness for recent/current questions:
|
||||
|
||||
```bash
|
||||
graincrawl doctor --json
|
||||
graincrawl status --json
|
||||
```
|
||||
|
||||
Refresh only when stale or asked:
|
||||
|
||||
```bash
|
||||
graincrawl sync --source private-api
|
||||
graincrawl sync --source desktop-cache
|
||||
```
|
||||
|
||||
Query with bounded reads:
|
||||
|
||||
```bash
|
||||
graincrawl search "query"
|
||||
graincrawl notes --json
|
||||
graincrawl note get <id>
|
||||
graincrawl transcripts get <id>
|
||||
graincrawl panels get <id>
|
||||
graincrawl --json sql "select count(*) as notes from notes;"
|
||||
```
|
||||
|
||||
Report absolute date spans, note titles, source gaps, and transcript/panel availability. Use read-only SQL for exact counts/rankings. Before encrypted source debugging, run explicit unlock/secrets checks; do not surprise-prompt Keychain.
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "Graincrawl"
|
||||
short_description: "Search local Granola notes and transcripts"
|
||||
default_prompt: "Use $graincrawl to search local Granola notes, transcripts, and panels, check freshness, and report exact date spans and source gaps."
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
name: notcrawl
|
||||
description: "Notion archive: search, sync freshness, pages/databases, Markdown exports, SQL counts, and Notcrawl repo work."
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/openclaw/notcrawl
|
||||
requires:
|
||||
bins:
|
||||
- notcrawl
|
||||
install:
|
||||
- kind: go
|
||||
module: github.com/vincentkoc/notcrawl/cmd/notcrawl@latest
|
||||
bins:
|
||||
- notcrawl
|
||||
---
|
||||
|
||||
# Notcrawl
|
||||
|
||||
Use local Notion archive data before browsing or live Notion API calls. Check freshness for recent/current questions:
|
||||
|
||||
```bash
|
||||
notcrawl doctor
|
||||
notcrawl status --json
|
||||
```
|
||||
|
||||
Refresh only when stale or asked:
|
||||
|
||||
```bash
|
||||
notcrawl sync --source desktop
|
||||
notcrawl sync --source api
|
||||
```
|
||||
|
||||
Query with bounded reads:
|
||||
|
||||
```bash
|
||||
notcrawl search "query"
|
||||
notcrawl databases
|
||||
notcrawl report
|
||||
notcrawl sql "select count(*) from pages;"
|
||||
```
|
||||
|
||||
Report workspace/teamspace, page/database titles, absolute date spans, counts, and known gaps. Use read-only SQL only; never mutate the archive. API mode requires `NOTION_TOKEN`; do not assume token availability.
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "Notcrawl"
|
||||
short_description: "Search local Notion archives and freshness"
|
||||
default_prompt: "Use $notcrawl to search local Notion pages and databases, check freshness, inspect exports, and report exact date spans and source gaps."
|
||||
@@ -1,64 +0,0 @@
|
||||
---
|
||||
name: openclaw-docker-e2e-authoring
|
||||
description: "Author OpenClaw Docker E2E and live provider Docker lanes."
|
||||
---
|
||||
|
||||
# OpenClaw Docker E2E Authoring
|
||||
|
||||
Use this when adding or changing Docker E2E lanes, release-path Docker tests,
|
||||
or live-provider Docker proof.
|
||||
|
||||
## Lane Choice
|
||||
|
||||
- Deterministic Docker: fake the dependency/server and assert the exact runtime
|
||||
contract crossing the boundary.
|
||||
- Live Docker: use real provider credentials/model only when user-visible
|
||||
behavior needs the real service.
|
||||
- Prefer both when they prove different risks: deterministic for byte/payload
|
||||
routing, live for actual provider behavior.
|
||||
|
||||
## Authoring Rules
|
||||
|
||||
- Test-only helpers live in `test/helpers` or `scripts/e2e/lib/<lane>/`, not
|
||||
`src/**`, unless production imports them.
|
||||
- Package-installed app runs from `/app`; mount only explicit harness/helper
|
||||
paths read-only.
|
||||
- Fake servers should log boundary requests as JSONL and clients should assert
|
||||
the real dependency payload, not just process success.
|
||||
- Add the package script and `scripts/lib/docker-e2e-scenarios.mjs` lane in the
|
||||
same change.
|
||||
- If a lane installs a plugin from npm, default the spec via env so published
|
||||
and local override paths are both testable.
|
||||
|
||||
## Media And Vision
|
||||
|
||||
- Expected answer must exist only in pixels or provider output being tested.
|
||||
- Use neutral filenames, neutral prompts, and no metadata leaks.
|
||||
- Random bitmap/OCR tokens reuse the repo OCR-safe alphabet `24567ACEF` unless
|
||||
the test owns a stronger glyph set.
|
||||
- Make the expected answer unique per run when proving real image
|
||||
understanding.
|
||||
|
||||
## `chat.send` E2E
|
||||
|
||||
- Require `chat.send` to return `status: "started"` and a string `runId`.
|
||||
- Wait for completion with `agent.wait`.
|
||||
- Assert final user-visible text via `chat.history` when event ordering is not
|
||||
the behavior under test.
|
||||
- Keep originating channel/account metadata only when the bug path needs queued
|
||||
inbound/channel context.
|
||||
|
||||
## Verification
|
||||
|
||||
Run the smallest proof that covers the touched lane:
|
||||
|
||||
```bash
|
||||
pnpm exec oxfmt --write <changed files>
|
||||
node --check <new .mjs files>
|
||||
bash -n <new .sh files>
|
||||
node scripts/run-vitest.mjs test/scripts/docker-e2e-plan.test.ts
|
||||
OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:<lane>
|
||||
```
|
||||
|
||||
For real-provider lanes, run the matching live Docker script after deterministic
|
||||
Docker is green. Finish with `$autoreview` before commit/PR.
|
||||
@@ -1,95 +0,0 @@
|
||||
---
|
||||
name: openclaw-mac-release
|
||||
description: "Run or recover OpenClaw macOS release signing, notarization, appcast, and asset promotion."
|
||||
---
|
||||
|
||||
# OpenClaw Mac Release
|
||||
|
||||
Use with `$openclaw-release-maintainer`, `$openclaw-release-ci`, and `$one-password` when stable macOS assets, private mac preflight, notarization, appcast promotion, or mac release recovery is involved.
|
||||
|
||||
## Credentials
|
||||
|
||||
- Canonical ASC item: vault `Molty`, title `API Key - App Store Connect - Personal - Release`.
|
||||
- Fields: `private_key_p8`, `key_id`, `issuer_id`.
|
||||
- Current known good key id: `AKVLXW849T`.
|
||||
- Legacy mirror: vault `Private`, title `API Key - App Store Connect - Personal`; keep it synced for older refs.
|
||||
- Stale/revoked key symptom: `xcrun notarytool submit` fails with `HTTP status code: 401. Unauthenticated`.
|
||||
- Validate candidate ASC credentials with `xcrun notarytool history` before setting GitHub secrets.
|
||||
|
||||
## 1Password
|
||||
|
||||
- Use `$one-password`: all `op` work inside one persistent tmux session, no secret output.
|
||||
- Prefer `OP_SERVICE_ACCOUNT_TOKEN` from `~/.profile` for Molty reads.
|
||||
- Do not assume `MOLTY_OP_SERVICE_ACCOUNT_TOKEN` is alive; it has previously pointed at a deleted service account.
|
||||
- If a service token fails, run status-only checks: token present/length and `op whoami`; never print token values.
|
||||
- If desktop app auth is needed but Touch ID is unavailable, set `OP_BIOMETRIC_UNLOCK_ENABLED=false` for the manual `op account add --signin` path.
|
||||
|
||||
## GitHub Secrets
|
||||
|
||||
Target private repo environment: `openclaw/releases-private`, env `mac-release`.
|
||||
|
||||
Set only after local notary auth validation:
|
||||
|
||||
- `APP_STORE_CONNECT_API_KEY_P8`
|
||||
- `APP_STORE_CONNECT_KEY_ID`
|
||||
- `APP_STORE_CONNECT_ISSUER_ID`
|
||||
|
||||
Do not update these from mixed sources. All three ASC fields must come from the same 1Password item.
|
||||
|
||||
## Workflow Shape
|
||||
|
||||
- Public release branch may carry mac-only packaging fixes after the stable tag/npm are already live.
|
||||
- Use `source_ref=release/YYYY.M.D` for private mac preflight/validation when building that branch variation.
|
||||
- Keep `tag=vYYYY.M.D` pointing at the original stable release commit.
|
||||
- Real mac publish must reuse:
|
||||
- a successful private mac preflight run for the same tag/source SHA
|
||||
- a successful private mac validation run for the same tag/source SHA
|
||||
- If preflight source SHA differs from tag SHA, validation must also use the same `source_ref`; promotion rejects mismatched proof.
|
||||
|
||||
## Notarization
|
||||
|
||||
- OpenClaw uses `scripts/notarize-mac-artifact.sh`.
|
||||
- `xcrun notarytool submit` should use `--no-s3-acceleration`; accelerated upload can surface misleading 401s even when `notarytool history` succeeds.
|
||||
- If signing succeeds but notarization fails immediately with 401, check ASC key freshness first.
|
||||
- If notarization stays in progress for several minutes after key-file write, that is normal Apple wait time; do not edit blindly.
|
||||
|
||||
## Dispatch
|
||||
|
||||
Private preflight:
|
||||
|
||||
```bash
|
||||
gh workflow run openclaw-macos-publish.yml --repo openclaw/releases-private --ref main \
|
||||
-f tag=vYYYY.M.D \
|
||||
-f source_ref=release/YYYY.M.D \
|
||||
-f preflight_only=true \
|
||||
-f smoke_test_only=false \
|
||||
-f allow_late_calver_recovery=false \
|
||||
-f public_release_branch=release/YYYY.M.D
|
||||
```
|
||||
|
||||
Private validation for a branch-variation preflight:
|
||||
|
||||
```bash
|
||||
gh workflow run openclaw-macos-validate.yml --repo openclaw/releases-private --ref main \
|
||||
-f tag=vYYYY.M.D \
|
||||
-f source_ref=release/YYYY.M.D
|
||||
```
|
||||
|
||||
Real publish:
|
||||
|
||||
```bash
|
||||
gh workflow run openclaw-macos-publish.yml --repo openclaw/releases-private --ref main \
|
||||
-f tag=vYYYY.M.D \
|
||||
-f preflight_only=false \
|
||||
-f smoke_test_only=false \
|
||||
-f preflight_run_id=<successful-preflight-run> \
|
||||
-f validate_run_id=<successful-validation-run> \
|
||||
-f allow_late_calver_recovery=false \
|
||||
-f public_release_branch=release/YYYY.M.D
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
- `gh release view vYYYY.M.D --repo openclaw/openclaw` shows zip, dmg, dSYM zip, not draft, not prerelease.
|
||||
- Public `main` `appcast.xml` points at `OpenClaw-YYYY.M.D.zip`.
|
||||
- Appcast entry has `sparkle:version`, `sparkle:shortVersionString`, length, and `sparkle:edSignature`.
|
||||
@@ -24,36 +24,6 @@ gitcrawl search openclaw/openclaw --query "<scope or title keywords>" --mode hyb
|
||||
gitcrawl cluster-detail openclaw/openclaw --id <cluster-id> --member-limit 20 --body-chars 280 --json
|
||||
```
|
||||
|
||||
## Claim specific review targets
|
||||
|
||||
When a maintainer asks Codex to review, triage, fix, or land a specific OpenClaw issue/PR, check assignment before deep work.
|
||||
|
||||
- Identify the requesting maintainer's GitHub login. In this environment, default Peter to `steipete`; if another maintainer is clearly the requester, use that maintainer's bare login.
|
||||
- Read current assignees with live `gh issue view` / `gh pr view`; `gitcrawl` is not enough for assignment state.
|
||||
- If unassigned, assign the requester before deep review. This is allowed for specific requested targets; do not auto-assign broad discovery candidates or shortlists.
|
||||
- If assigned to someone else, say so clearly before analysis and include assignment age:
|
||||
- fresh: assigned within 6h; treat as actively owned unless user explicitly asks to continue or reassign
|
||||
- stale: assigned 6h+ ago; treat as ownership hint, not a hard block; continue only with that caveat
|
||||
- If assigned to requester plus others, mention co-assignees and continue.
|
||||
- If assignment event time is unavailable, say `assigned, time unknown`; treat as assigned, not stale.
|
||||
- Never remove or replace assignees unless explicitly asked.
|
||||
|
||||
Assignment time proof:
|
||||
|
||||
```bash
|
||||
gh api "repos/openclaw/openclaw/issues/<number>/timeline" --paginate \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
--jq '[.[] | select(.event=="assigned") | {assignee:.assignee.login, assigner:.assigner.login, actor:.actor.login, created_at}]'
|
||||
```
|
||||
|
||||
Use the newest `assigned` event for each current assignee. Issue timeline events expose `created_at`; GitHub GraphQL `AssignedEvent.createdAt` is also valid when REST pagination is awkward.
|
||||
|
||||
Claim command for issues or PRs:
|
||||
|
||||
```bash
|
||||
gh api -X POST "repos/openclaw/openclaw/issues/<number>/assignees" -f 'assignees[]=<login>' >/dev/null
|
||||
```
|
||||
|
||||
## Surface opener identity
|
||||
|
||||
- For every reviewed, triaged, closed, or landed issue/PR, show the opener's human name when available, GitHub login, and account age.
|
||||
@@ -247,7 +217,6 @@ gh search issues --repo openclaw/openclaw --match title,body --limit 50 \
|
||||
not correctness findings.
|
||||
- If bot review conversations exist on your PR, address them and resolve them yourself once fixed.
|
||||
- Leave a review conversation unresolved only when reviewer or maintainer judgment is still needed.
|
||||
- Before landing any PR with non-trivial code changes, run `$autoreview` until no accepted/actionable findings remain, unless equivalent manual review already covered it, the change is trivial/docs-only, or the user opts out.
|
||||
- When landing or merging any PR, follow the global `/landpr` process.
|
||||
- Use `scripts/committer "<msg>" <file...>` for scoped commits instead of manual `git add` and `git commit`.
|
||||
- Keep commit messages concise and action-oriented.
|
||||
|
||||
@@ -28,10 +28,7 @@ git status --short --branch
|
||||
git rev-parse HEAD
|
||||
```
|
||||
|
||||
1Password service-account values are the first source for release provider
|
||||
preflight. Inject those exact targeted keys first, then run the verifier; use
|
||||
ambient env only when it was already intentionally injected for this release.
|
||||
The script prints only provider status and HTTP class, never tokens.
|
||||
If env lacks keys, use `$one-password` to inject or set them, then rerun the script. The script prints only provider status and HTTP class, never tokens.
|
||||
|
||||
## Dispatch
|
||||
|
||||
|
||||
@@ -170,13 +170,6 @@ live`; keep it clearly beta and avoid implying stable promotion.
|
||||
CI, validation, or internal release mechanics unless the release is explicitly
|
||||
about those. Peter prefers concrete user wins: features, integrations,
|
||||
workflow improvements, and practical reliability fixes.
|
||||
- Do not feature QA parity, test coverage, release gates, or validation lanes in
|
||||
user-facing launch tweets. Keep them for release notes or maintainer proof
|
||||
unless the operator explicitly asks for validation-focused copy.
|
||||
- Do not feature plugin-author or developer tooling such as SDK helpers,
|
||||
tool-plugin scaffolding, build/validate/init commands, or internal CLI
|
||||
plumbing in general user-facing launch tweets unless the operator explicitly
|
||||
asks for developer-focused copy.
|
||||
- Tone: high-signal, slightly cheeky, confident, not corporate. One joke is
|
||||
enough. Avoid punching down, insulting users, or promising what was not
|
||||
verified.
|
||||
|
||||
@@ -34,10 +34,10 @@ Supports single or multiple alerts. For multiple alerts, process in ascending or
|
||||
For each alert:
|
||||
|
||||
1. **Identify** — `fetch-alert` + `fetch-content` to get metadata and body
|
||||
2. **Decide** — Agent reads the body file, identifies whether plaintext secrets remain, and produces a redacted version only when needed
|
||||
3. **Redact** — `redact-body-if-needed` for issue/PR body; skip for comments (delete directly)
|
||||
2. **Decide** — Agent reads the body file, identifies all secrets, produces redacted version
|
||||
3. **Redact** — `redact-body` for issue/PR body; skip for comments (delete directly)
|
||||
4. **Purge** — `delete-comment` + `recreate-comment` for comments; cannot purge body history
|
||||
5. **Notify** — `notify` posts the right template per location type, unless the current issue/PR body is already redacted
|
||||
5. **Notify** — `notify` posts the right template per location type
|
||||
6. **Resolve** — `resolve` closes the alert
|
||||
7. **Summary** — `summary` prints formatted results
|
||||
|
||||
@@ -81,20 +81,11 @@ The `fetch-content` output includes:
|
||||
The agent reads the body file from `fetch-content` output and:
|
||||
|
||||
1. Identifies ALL secrets in the content (there may be more than the alert flagged)
|
||||
2. Determines whether any plaintext credential remains in the current body
|
||||
3. Replaces each remaining secret with `[REDACTED <secret_type>]` — **no partial values, no prefix/suffix**
|
||||
4. Saves the redacted content to a new temp file
|
||||
2. Replaces each secret with `[REDACTED <secret_type>]` — **no partial values, no prefix/suffix**
|
||||
3. Saves the redacted content to a new temp file
|
||||
|
||||
This is the only step that requires semantic understanding. Everything else is mechanical.
|
||||
|
||||
For `issue_body` and `pull_request_body`: if the current body has already been redacted by the author and no plaintext credential remains, **do not post a public notification comment**. Resolve the alert with a maintainer-only resolution comment such as:
|
||||
|
||||
```bash
|
||||
node secret-scanning.mjs resolve <ALERT_NUMBER> revoked "Current issue/PR body is already redacted; no public notification posted."
|
||||
```
|
||||
|
||||
This avoids creating a fresh public pointer to historical sensitive content.
|
||||
|
||||
## Step 3: Redact
|
||||
|
||||
### For comments (issue_comment / PR comments)
|
||||
@@ -104,11 +95,9 @@ This avoids creating a fresh public pointer to historical sensitive content.
|
||||
### For issue_body / pull_request_body
|
||||
|
||||
```bash
|
||||
node secret-scanning.mjs redact-body-if-needed <issue|pr> <NUMBER> <current-body-file> <redacted-body-file> <result-file>
|
||||
node secret-scanning.mjs redact-body <issue|pr> <NUMBER> <redacted-body-file>
|
||||
```
|
||||
|
||||
Use the `body_file` from `fetch-content` as `<current-body-file>`. The command writes `notify_required` to `<result-file>` and only PATCHes the body when the redacted file differs from the current body.
|
||||
|
||||
## Step 4: Purge Edit History
|
||||
|
||||
### Comments — Delete and Recreate
|
||||
@@ -145,12 +134,10 @@ The recreated comment should follow this format:
|
||||
<redacted original content>
|
||||
```
|
||||
|
||||
### issue_body / pull_request_body — Cannot Purge Edit History
|
||||
### issue_body / pull_request_body — Cannot Purge
|
||||
|
||||
Editing creates an edit history revision with the pre-edit plaintext. This cannot be cleared via API.
|
||||
|
||||
Do not advise authors publicly to delete/recreate issues or close/reopen PRs. That can draw attention to historical content. Keep purge guidance maintainer-only.
|
||||
|
||||
**Output to maintainer terminal only (never in public comments):**
|
||||
|
||||
```
|
||||
@@ -168,13 +155,12 @@ Cannot clean. Notify author to delete branch or force-push (for unmerged PRs).
|
||||
## Step 5: Notify
|
||||
|
||||
```bash
|
||||
node secret-scanning.mjs notify <TARGET> <AUTHOR> <LOCATION_TYPE> <SECRET_TYPES> [REPLY_TO_NODE_ID|BODY_REDACTION_RESULT_FILE]
|
||||
node secret-scanning.mjs notify <TARGET> <AUTHOR> <LOCATION_TYPE> <SECRET_TYPES> [REPLY_TO_NODE_ID]
|
||||
```
|
||||
|
||||
- For non-discussion types, `<TARGET>` is the issue/PR number.
|
||||
- For `discussion_comment`, `<TARGET>` is the `discussion_node_id` returned by `fetch-content`.
|
||||
- For reply-style `discussion_comment` locations, pass the optional `reply_to_node_id` from `fetch-content` so the notification stays in the same thread.
|
||||
- For `issue_body` and `pull_request_body`, pass the `<result-file>` from `redact-body-if-needed`. The script skips notification when `notify_required` is `false` and refuses body notifications without this file.
|
||||
|
||||
Secret types are comma-separated: `"Discord Bot Token,Feishu App Secret"`
|
||||
|
||||
@@ -184,8 +170,6 @@ The script picks the right template:
|
||||
- **body types**: "your issue/PR description … redacted in place"
|
||||
- **commit**: "code you committed"
|
||||
|
||||
For `issue_body` and `pull_request_body`, only notify when the current body still contained plaintext and maintainers redacted it. If the user already redacted the current body, skip this step and resolve silently.
|
||||
|
||||
## Step 6: Resolve
|
||||
|
||||
```bash
|
||||
@@ -194,7 +178,7 @@ node secret-scanning.mjs resolve <ALERT_NUMBER>
|
||||
node secret-scanning.mjs resolve <ALERT_NUMBER> revoked "Custom comment"
|
||||
```
|
||||
|
||||
Resolution is `revoked` by default. As maintainers we cannot control whether users rotate — our responsibility is to remove current plaintext exposure and notify only when public notification is useful. The `revoked` means "this secret should be considered leaked", not "I confirmed it was revoked".
|
||||
Resolution is `revoked` by default. As maintainers we cannot control whether users rotate — our responsibility is to redact + notify. The `revoked` means "this secret should be considered leaked", not "I confirmed it was revoked".
|
||||
|
||||
## Step 7: Summary
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const REPO = "openclaw/openclaw";
|
||||
const REPO_URL = `https://github.com/${REPO}`;
|
||||
@@ -51,34 +50,6 @@ function ghGraphQL(query, options = {}) {
|
||||
return gh(["api", "graphql", "-f", `query=${query}`], options);
|
||||
}
|
||||
|
||||
function isBodyLocationType(locationType) {
|
||||
return locationType === "issue_body" || locationType === "pull_request_body";
|
||||
}
|
||||
|
||||
export function decideBodyRedaction(currentBody, redactedBody) {
|
||||
const bodyChanged = String(currentBody) !== String(redactedBody);
|
||||
return {
|
||||
body_changed: bodyChanged,
|
||||
notify_required: bodyChanged,
|
||||
};
|
||||
}
|
||||
|
||||
export function loadBodyRedactionResult(locationType, resultFile) {
|
||||
if (!isBodyLocationType(locationType)) {
|
||||
return { notify_required: true };
|
||||
}
|
||||
if (!resultFile) {
|
||||
fail("Body notifications require a redaction result file from redact-body-if-needed");
|
||||
}
|
||||
if (!fs.existsSync(resultFile)) fail(`File not found: ${resultFile}`);
|
||||
|
||||
const result = JSON.parse(fs.readFileSync(resultFile, "utf8"));
|
||||
if (typeof result.notify_required !== "boolean") {
|
||||
fail(`Invalid redaction result file: missing boolean notify_required in ${resultFile}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function failOnGraphQLFailure(result, message) {
|
||||
if (result?.gh_failed) {
|
||||
const details = (
|
||||
@@ -499,43 +470,6 @@ function cmdRedactBody(kind, number, bodyFile) {
|
||||
console.log(JSON.stringify({ ok: true, kind, number: Number(number) }));
|
||||
}
|
||||
|
||||
/**
|
||||
* redact-body-if-needed <issue|pr> <number> <current-body-file> <redacted-body-file> <result-file>
|
||||
* PATCH only when the agent-produced redacted body differs from the current body.
|
||||
*/
|
||||
function cmdRedactBodyIfNeeded(kind, number, currentBodyFile, redactedBodyFile, resultFile) {
|
||||
if (!kind || !number || !currentBodyFile || !redactedBodyFile || !resultFile) {
|
||||
fail(
|
||||
"Usage: redact-body-if-needed <issue|pr> <number> <current-body-file> <redacted-body-file> <result-file>",
|
||||
);
|
||||
}
|
||||
if (!fs.existsSync(currentBodyFile)) fail(`File not found: ${currentBodyFile}`);
|
||||
if (!fs.existsSync(redactedBodyFile)) fail(`File not found: ${redactedBodyFile}`);
|
||||
|
||||
const currentBody = fs.readFileSync(currentBodyFile, "utf8");
|
||||
const redactedBody = fs.readFileSync(redactedBodyFile, "utf8");
|
||||
const decision = decideBodyRedaction(currentBody, redactedBody);
|
||||
const result = {
|
||||
ok: true,
|
||||
kind,
|
||||
number: Number(number),
|
||||
...decision,
|
||||
};
|
||||
|
||||
if (decision.body_changed) {
|
||||
const endpoint =
|
||||
kind === "pr" ? `repos/${REPO}/pulls/${number}` : `repos/${REPO}/issues/${number}`;
|
||||
gh(["api", endpoint, "-X", "PATCH", "-F", `body=@${redactedBodyFile}`]);
|
||||
result.redacted = true;
|
||||
} else {
|
||||
result.redacted = false;
|
||||
result.reason = "current_body_already_redacted";
|
||||
}
|
||||
|
||||
fs.writeFileSync(resultFile, `${JSON.stringify(result, null, 2)}\n`, { mode: 0o600 });
|
||||
console.log(JSON.stringify(result));
|
||||
}
|
||||
|
||||
/**
|
||||
* delete-comment <comment-id>
|
||||
* Delete a comment (and all its edit history).
|
||||
@@ -621,17 +555,6 @@ function cmdNotify(target, author, locationType, secretTypes, replyToNodeId) {
|
||||
|
||||
const types = secretTypes.split(",").map((s) => s.trim());
|
||||
const typeList = types.map((t, i) => `${i + 1}. **${t}**`).join("\n");
|
||||
const redactionResult = loadBodyRedactionResult(locationType, replyToNodeId);
|
||||
if (isBodyLocationType(locationType) && !redactionResult.notify_required) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
skipped: true,
|
||||
reason: "current_body_already_redacted",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let locationDesc;
|
||||
let actionDesc;
|
||||
@@ -835,13 +758,12 @@ function cmdSummary(jsonFile) {
|
||||
|
||||
// ─── Dispatch ───────────────────────────────────────────────────────────────
|
||||
|
||||
const args = [];
|
||||
const [command, ...args] = process.argv.slice(2);
|
||||
|
||||
export const commands = {
|
||||
const commands = {
|
||||
"fetch-alert": () => cmdFetchAlert(args[0]),
|
||||
"fetch-content": () => cmdFetchContent(args[0]),
|
||||
"redact-body": () => cmdRedactBody(args[0], args[1], args[2]),
|
||||
"redact-body-if-needed": () => cmdRedactBodyIfNeeded(args[0], args[1], args[2], args[3], args[4]),
|
||||
"delete-comment": () => cmdDeleteComment(args[0]),
|
||||
"delete-discussion-comment": () => cmdDeleteDiscussionComment(args[0]),
|
||||
"recreate-comment": () => cmdRecreateComment(args[0], args[1]),
|
||||
@@ -852,37 +774,26 @@ export const commands = {
|
||||
summary: () => cmdSummary(args[0]),
|
||||
};
|
||||
|
||||
function main(argv = process.argv.slice(2)) {
|
||||
const [command, ...commandArgs] = argv;
|
||||
args.length = 0;
|
||||
args.push(...commandArgs);
|
||||
|
||||
if (!command || !commands[command]) {
|
||||
console.error(
|
||||
[
|
||||
"Usage: node secret-scanning.mjs <command> [args]",
|
||||
"",
|
||||
"Commands:",
|
||||
" fetch-alert <number> Fetch alert metadata + locations",
|
||||
" fetch-content '<location-json>' Fetch content for a location",
|
||||
" redact-body <issue|pr> <n> <file> PATCH body with redacted file",
|
||||
" redact-body-if-needed <issue|pr> <n> <current-file> <redacted-file> <result-file> PATCH body only if redaction changed it",
|
||||
" delete-comment <comment-id> Delete a comment",
|
||||
" delete-discussion-comment <node-id> Delete a discussion comment (GraphQL)",
|
||||
" recreate-comment <issue-n> <file> Create replacement comment",
|
||||
" recreate-discussion-comment <disc-node-id> <file> [reply-to-node-id] Create discussion comment (GraphQL)",
|
||||
" notify <target> <author> <type> <types> [reply-to-node-id|body-result-file] Post notification",
|
||||
" resolve <n> [resolution] [comment] Close alert",
|
||||
" list-open List open alerts",
|
||||
" summary <json-file> Print formatted summary",
|
||||
].join("\n"),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
commands[command]();
|
||||
if (!command || !commands[command]) {
|
||||
console.error(
|
||||
[
|
||||
"Usage: node secret-scanning.mjs <command> [args]",
|
||||
"",
|
||||
"Commands:",
|
||||
" fetch-alert <number> Fetch alert metadata + locations",
|
||||
" fetch-content '<location-json>' Fetch content for a location",
|
||||
" redact-body <issue|pr> <n> <file> PATCH body with redacted file",
|
||||
" delete-comment <comment-id> Delete a comment",
|
||||
" delete-discussion-comment <node-id> Delete a discussion comment (GraphQL)",
|
||||
" recreate-comment <issue-n> <file> Create replacement comment",
|
||||
" recreate-discussion-comment <disc-node-id> <file> [reply-to-node-id] Create discussion comment (GraphQL)",
|
||||
" notify <target> <author> <type> <types> [reply-to-node-id] Post notification",
|
||||
" resolve <n> [resolution] [comment] Close alert",
|
||||
" list-open List open alerts",
|
||||
" summary <json-file> Print formatted summary",
|
||||
].join("\n"),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main();
|
||||
}
|
||||
commands[command]();
|
||||
|
||||
@@ -24,11 +24,8 @@ Prove the touched surface first. Do not reflexively run the whole suite.
|
||||
- normal source checkout, one failing file: `pnpm test <path-or-filter> -- --reporter=verbose`
|
||||
- Codex worktree or linked/sparse checkout, one/few explicit files: `node scripts/run-vitest.mjs <path-or-filter>`
|
||||
- Codex worktree or linked/sparse checkout, changed gates or anything broad:
|
||||
use the Crabbox wrapper with the provider that matches the proof surface.
|
||||
For maintainer heavy `pnpm` gates, that is usually delegated Blacksmith
|
||||
Testbox through Crabbox, e.g. `node scripts/crabbox-wrapper.mjs run
|
||||
--provider blacksmith-testbox ... -- pnpm check:changed`. For direct AWS
|
||||
Crabbox proof, omit `--provider` and let `.crabbox.yaml` choose AWS.
|
||||
`node scripts/crabbox-wrapper.mjs run ... --shell -- "pnpm check:changed"`
|
||||
and let `.crabbox.yaml` choose the provider
|
||||
- workflow-only: `git diff --check`, workflow syntax/lint (`actionlint` when available)
|
||||
- docs-only: `pnpm docs:list`, docs formatter/lint only if docs tooling changed or requested
|
||||
2. Reproduce narrowly before fixing.
|
||||
@@ -49,18 +46,13 @@ Prove the touched surface first. Do not reflexively run the whole suite.
|
||||
`node scripts/run-vitest.mjs` for tiny local proof, `node
|
||||
scripts/crabbox-wrapper.mjs` for Testbox, and `git commit --no-verify` only
|
||||
after the relevant remote or node-wrapper proof is already clean.
|
||||
- For remote proof, use the Crabbox wrapper first, but name the actual backend.
|
||||
Direct AWS Crabbox uses `provider=aws` and `cbx_...` ids. Delegated
|
||||
Blacksmith Testbox through Crabbox uses `provider=blacksmith-testbox`,
|
||||
`syncDelegated=true`, and `tbx_...` ids. Both satisfy "remote proof" when the
|
||||
requested proof surface allows either.
|
||||
- Do not infer "no Testbox is running" from plain `blacksmith testbox list`.
|
||||
Use `blacksmith testbox list --all` or `blacksmith testbox status <tbx_id>`
|
||||
before reporting cloud state.
|
||||
- Reuse only an id/slug created in this operator session unless explicitly
|
||||
coordinating with another lane. If Testbox queues, fails capacity, or cannot
|
||||
allocate, report the blocker or switch to direct AWS Crabbox only when that
|
||||
still proves the requested surface.
|
||||
- For remote proof, use Crabbox first and omit `--provider` unless a specific
|
||||
provider is being tested. The repo Crabbox config routes normal broad proof to
|
||||
brokered AWS. Blacksmith Testbox is explicit opt-in; if it queues, fails
|
||||
capacity, or cannot allocate, retry once through the default Crabbox route or
|
||||
report the Testbox blocker. Reuse only an id/slug created in this operator
|
||||
session; `blacksmith testbox list` is diagnostics only, not a shared work
|
||||
queue.
|
||||
|
||||
## Local Test Shortcuts
|
||||
|
||||
@@ -131,8 +123,6 @@ gh run view <run-id> --job <job-id> --log
|
||||
- Check exact SHA. Ignore newer unrelated `main` unless asked.
|
||||
- For cancelled same-branch runs, confirm whether a newer run superseded it.
|
||||
- Fetch full logs only for failed or relevant jobs.
|
||||
- Prefer `gh run view <run-id> --json jobs` over PR rollup while debugging; rollup can be stale/noisy.
|
||||
- For `prompt:snapshots:check` failures, treat Linux Node 24 as CI truth. If macOS passes but CI drifts, reproduce in a Linux Node 24 container or Testbox, commit that generated output, then rerun.
|
||||
|
||||
## GitHub Release Workflows
|
||||
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
---
|
||||
name: slacrawl
|
||||
description: "Slack archive: search, sync freshness, threads/DMs, SQL counts, and Slacrawl repo work."
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/openclaw/slacrawl
|
||||
requires:
|
||||
bins:
|
||||
- slacrawl
|
||||
install:
|
||||
- kind: go
|
||||
module: github.com/vincentkoc/slacrawl/cmd/slacrawl@latest
|
||||
bins:
|
||||
- slacrawl
|
||||
---
|
||||
|
||||
# Slacrawl
|
||||
|
||||
Use local Slack archive data first. Check freshness for recent/current questions:
|
||||
|
||||
```bash
|
||||
slacrawl doctor
|
||||
slacrawl status --json
|
||||
```
|
||||
|
||||
Refresh only when stale or asked:
|
||||
|
||||
```bash
|
||||
slacrawl sync --source desktop
|
||||
slacrawl sync --source api --latest-only
|
||||
```
|
||||
|
||||
Query with bounded slices:
|
||||
|
||||
```bash
|
||||
slacrawl search --limit 20 "query"
|
||||
slacrawl messages --since 7d --limit 50
|
||||
slacrawl sql "select count(*) from messages;"
|
||||
```
|
||||
|
||||
Report workspace/channel names, absolute date spans, counts, and token/source limits. Use read-only SQL for exact counts/rankings. API sync and full thread/DM hydration require Slack tokens; do not assume they exist.
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "Slacrawl"
|
||||
short_description: "Search local Slack archives and freshness"
|
||||
default_prompt: "Use $slacrawl to search local Slack archives, check freshness, inspect channel or DM slices, and report exact date spans and token/source limits."
|
||||
@@ -17,8 +17,7 @@ artifact bundle. The runner leases the shared burner account from Convex.
|
||||
Run from the OpenClaw repo and branch under test:
|
||||
|
||||
```bash
|
||||
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
|
||||
"$proof_cmd" start \
|
||||
pnpm qa:telegram-user:crabbox -- start \
|
||||
--tdlib-url http://artifacts.openclaw.ai/tdlib-v1.8.0-linux-x64.tgz \
|
||||
--output-dir .artifacts/qa-e2e/telegram-user-crabbox/pr-review
|
||||
```
|
||||
@@ -40,8 +39,7 @@ For deterministic visual repros, put the exact mock-model reply in a file and
|
||||
pass it to `start`:
|
||||
|
||||
```bash
|
||||
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
|
||||
"$proof_cmd" start \
|
||||
pnpm qa:telegram-user:crabbox -- start \
|
||||
--tdlib-url http://artifacts.openclaw.ai/tdlib-v1.8.0-linux-x64.tgz \
|
||||
--mock-response-file .artifacts/qa-e2e/telegram-user-crabbox/reply.txt \
|
||||
--output-dir .artifacts/qa-e2e/telegram-user-crabbox/pr-review
|
||||
@@ -57,16 +55,15 @@ For visual proof, first send or identify a bottom marker message, then open the
|
||||
group/topic directly by message id:
|
||||
|
||||
```bash
|
||||
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
|
||||
"$proof_cmd" view \
|
||||
pnpm qa:telegram-user:crabbox -- view \
|
||||
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json \
|
||||
--message-id <message-id>
|
||||
```
|
||||
|
||||
This uses Telegram Desktop directly with `tg://privatepost`, not `xdg-open`.
|
||||
It also resizes Telegram to `650x1000` at the tested desktop position so
|
||||
the crop can isolate the chat pane even if Telegram keeps a split/sidebar
|
||||
layout. Do not press Escape after this; Escape can close the selected chat.
|
||||
Telegram switches to single-chat mode with no left chat list or right info
|
||||
pane. Do not press Escape after this; Escape can close the selected chat.
|
||||
|
||||
Bottom behavior matters:
|
||||
|
||||
@@ -74,14 +71,13 @@ Bottom behavior matters:
|
||||
later messages appear live in the recording
|
||||
- deep-linking to an older message does not auto-scroll to new arrivals; link
|
||||
again to the newest/final marker instead of clicking the down-arrow
|
||||
- the cropped GIF intentionally uses the chat pane, not the whole desktop or
|
||||
whole Telegram window
|
||||
- `650px` is the largest tested clean width; `660px` switches Telegram back to
|
||||
split/sidebar layout
|
||||
|
||||
Send as the real Telegram user:
|
||||
|
||||
```bash
|
||||
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
|
||||
"$proof_cmd" send \
|
||||
pnpm qa:telegram-user:crabbox -- send \
|
||||
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json \
|
||||
--text /status
|
||||
```
|
||||
@@ -91,8 +87,7 @@ For slash commands, omit the bot username; the runner targets the SUT bot.
|
||||
Run arbitrary commands on the Crabbox:
|
||||
|
||||
```bash
|
||||
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
|
||||
"$proof_cmd" run \
|
||||
pnpm qa:telegram-user:crabbox -- run \
|
||||
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json \
|
||||
-- bash -lc 'source /tmp/openclaw-telegram-user-crabbox/env.sh && python3 /tmp/openclaw-telegram-user-crabbox/user-driver.py transcript --limit 20 --json'
|
||||
```
|
||||
@@ -111,16 +106,14 @@ python3 /tmp/openclaw-telegram-user-crabbox/user-driver.py probe --text '@{sut}
|
||||
Capture the current desktop without ending the session:
|
||||
|
||||
```bash
|
||||
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
|
||||
"$proof_cmd" screenshot \
|
||||
pnpm qa:telegram-user:crabbox -- screenshot \
|
||||
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json
|
||||
```
|
||||
|
||||
Check lease state and get the WebVNC command:
|
||||
|
||||
```bash
|
||||
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
|
||||
"$proof_cmd" status \
|
||||
pnpm qa:telegram-user:crabbox -- status \
|
||||
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json
|
||||
```
|
||||
|
||||
@@ -129,8 +122,7 @@ proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-pr
|
||||
Always finish or explicitly keep the box:
|
||||
|
||||
```bash
|
||||
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
|
||||
"$proof_cmd" finish \
|
||||
pnpm qa:telegram-user:crabbox -- finish \
|
||||
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json \
|
||||
--preview-crop telegram-window
|
||||
```
|
||||
@@ -158,8 +150,7 @@ Attach only the useful visual artifact to the PR unless logs are needed. The
|
||||
runner is GIF-only by default:
|
||||
|
||||
```bash
|
||||
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
|
||||
"$proof_cmd" publish \
|
||||
pnpm qa:telegram-user:crabbox -- publish \
|
||||
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json \
|
||||
--pr <pr-number> \
|
||||
--summary 'Telegram real-user Crabbox session motion GIF'
|
||||
@@ -198,8 +189,7 @@ experiments unless those artifacts are explicitly needed.
|
||||
For a fast one-shot check, use:
|
||||
|
||||
```bash
|
||||
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
|
||||
"$proof_cmd" --text /status
|
||||
pnpm qa:telegram-user:crabbox -- --text /status
|
||||
```
|
||||
|
||||
This is a start/send/finish shortcut. Prefer the held session for PR review,
|
||||
|
||||
@@ -6,10 +6,6 @@ capacity:
|
||||
strategy: most-available
|
||||
fallback: on-demand-after-120s
|
||||
hints: true
|
||||
availabilityZones:
|
||||
- eu-west-1a
|
||||
- eu-west-1b
|
||||
- eu-west-1c
|
||||
regions:
|
||||
- eu-west-1
|
||||
- eu-west-2
|
||||
|
||||
4
.github/actions/setup-node-env/action.yml
vendored
4
.github/actions/setup-node-env/action.yml
vendored
@@ -40,7 +40,6 @@ runs:
|
||||
id: pnpm-cache
|
||||
uses: ./.github/actions/setup-pnpm-store-cache
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
pnpm-version: ${{ inputs.pnpm-version }}
|
||||
cache-key-suffix: ${{ inputs.cache-key-suffix }}
|
||||
|
||||
@@ -59,15 +58,14 @@ runs:
|
||||
if command -v bun &>/dev/null; then bun -v; fi
|
||||
|
||||
- name: Capture node path
|
||||
if: inputs.install-deps == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
node_bin="$(dirname "$(node -p 'process.execPath')")"
|
||||
if command -v cygpath >/dev/null 2>&1; then
|
||||
node_bin="$(cygpath -u "$node_bin")"
|
||||
fi
|
||||
# zizmor: ignore[github-env] node_bin comes from trusted actions/setup-node output in this composite action.
|
||||
echo "NODE_BIN=$node_bin" >> "$GITHUB_ENV"
|
||||
echo "$node_bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install dependencies
|
||||
if: inputs.install-deps == 'true'
|
||||
|
||||
@@ -5,10 +5,6 @@ inputs:
|
||||
description: pnpm version to activate via corepack.
|
||||
required: false
|
||||
default: "11.0.8"
|
||||
node-version:
|
||||
description: Expected Node.js version already installed by actions/setup-node.
|
||||
required: false
|
||||
default: "24.x"
|
||||
cache-key-suffix:
|
||||
description: Suffix appended to the cache key.
|
||||
required: false
|
||||
@@ -45,85 +41,12 @@ runs:
|
||||
env:
|
||||
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0"
|
||||
PNPM_VERSION: ${{ inputs.pnpm-version }}
|
||||
REQUESTED_NODE_VERSION: ${{ inputs.node-version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "$PNPM_VERSION" =~ ^[0-9]+(\.[0-9]+){1,2}([.-][0-9A-Za-z.-]+)?$ ]]; then
|
||||
echo "::error::Invalid pnpm-version input: '$PNPM_VERSION'"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
requested_node="${REQUESTED_NODE_VERSION:-${NODE_VERSION:-}}"
|
||||
requested_node="${requested_node#v}"
|
||||
|
||||
node_version_matches() {
|
||||
local actual="$1"
|
||||
local requested="$2"
|
||||
if [[ -z "$requested" ]]; then
|
||||
return 0
|
||||
fi
|
||||
case "$requested" in
|
||||
*x)
|
||||
[[ "${actual%%.*}" == "${requested%%.*}" ]]
|
||||
;;
|
||||
*.*.*)
|
||||
[[ "$actual" == "$requested" ]]
|
||||
;;
|
||||
*.*)
|
||||
[[ "$actual" == "$requested".* ]]
|
||||
;;
|
||||
*)
|
||||
[[ "${actual%%.*}" == "$requested" ]]
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
active_node_version="$(node -p 'process.versions.node' 2>/dev/null || true)"
|
||||
if ! node_version_matches "$active_node_version" "$requested_node"; then
|
||||
node_roots=()
|
||||
for root in \
|
||||
"${RUNNER_TOOL_CACHE:-}" \
|
||||
"${AGENT_TOOLSDIRECTORY:-}" \
|
||||
"${ACTIONS_RUNNER_TOOL_CACHE:-}" \
|
||||
"/opt/hostedtoolcache" \
|
||||
"/home/runner/_work/_tool" \
|
||||
"/Users/runner/hostedtoolcache" \
|
||||
"/c/hostedtoolcache/windows"
|
||||
do
|
||||
if [[ -d "$root/node" ]]; then
|
||||
node_roots+=("$root/node")
|
||||
elif [[ "$(basename "$root")" == "node" && -d "$root" ]]; then
|
||||
node_roots+=("$root")
|
||||
fi
|
||||
done
|
||||
|
||||
node_bin=""
|
||||
for node_root in "${node_roots[@]}"; do
|
||||
while IFS= read -r candidate; do
|
||||
candidate_version="$("$candidate" -p 'process.versions.node' 2>/dev/null || true)"
|
||||
if node_version_matches "$candidate_version" "$requested_node"; then
|
||||
node_bin="$candidate"
|
||||
break 2
|
||||
fi
|
||||
done < <(find "$node_root" \( -name node -o -name node.exe \) -type f 2>/dev/null | sort -r)
|
||||
done
|
||||
|
||||
if [[ -n "$node_bin" ]]; then
|
||||
echo "Using Node $("$node_bin" -p 'process.versions.node') from $node_bin"
|
||||
export PATH="$(dirname "$node_bin"):$PATH"
|
||||
hash -r
|
||||
fi
|
||||
fi
|
||||
|
||||
active_node_version="$(node -p 'process.versions.node' 2>/dev/null || true)"
|
||||
if ! node_version_matches "$active_node_version" "$requested_node"; then
|
||||
echo "::error::Expected Node '${requested_node}', but active node is '${active_node_version:-missing}' at $(command -v node || true)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
node -v
|
||||
command -v node
|
||||
command -v corepack
|
||||
corepack enable
|
||||
for attempt in 1 2 3; do
|
||||
if corepack prepare "pnpm@$PNPM_VERSION" --activate; then
|
||||
|
||||
@@ -16,15 +16,8 @@ Hard limits:
|
||||
- Do not finish with tiny, cropped-wrong, off-bottom, or sidebar-heavy GIFs.
|
||||
- Do not invent a generic proof. The proof must match the PR behavior.
|
||||
- Do not force GIFs for internal-only, workflow-only, test-only, docs-only, or
|
||||
otherwise non-visual PRs. A no-visual-proof manifest is a successful workflow
|
||||
outcome when GIFs would be misleading, but it is not proof that the PR passed.
|
||||
- Do not skip Telegram-visible PRs just because the proof needs a specific
|
||||
message, mock response, media attachment, command, button, reaction, stop
|
||||
timing, approval prompt, or progress/final delivery sequence. First write a
|
||||
concrete proof plan and try the standard harness path.
|
||||
- Keep public-facing manifest summaries short and user-domain. Do not mention
|
||||
harness internals, mock-provider limits, secret/trust boundaries, local paths,
|
||||
transcript seeding, or workflow implementation details in the summary.
|
||||
otherwise non-visual PRs. A no-visual-proof manifest is a successful outcome
|
||||
when GIFs would be misleading.
|
||||
|
||||
Inputs are provided as environment variables:
|
||||
|
||||
@@ -46,21 +39,12 @@ Required workflow:
|
||||
2. Inspect the PR with `gh pr view "$MANTIS_PR_NUMBER"` and
|
||||
`gh pr diff "$MANTIS_PR_NUMBER"`.
|
||||
3. Decide whether the PR has a visibly reproducible Telegram Desktop
|
||||
before/after. Treat these as visible until proven otherwise: message text
|
||||
formatting/content, progress drafts, native drafts, final delivery, media or
|
||||
document delivery, inline buttons, approval prompts, stop/abort behavior,
|
||||
reactions/status indicators, guest/inline responses, TTS/voice/audio
|
||||
delivery, and routing changes whose result is visible in the chat. For those
|
||||
PRs, define the exact Telegram stimulus and expected main/PR visual delta
|
||||
before deciding to skip.
|
||||
|
||||
If the PR does not have a Telegram-visible before/after, write
|
||||
before/after. If it does not, write
|
||||
`${MANTIS_OUTPUT_DIR}/mantis-evidence.json` with `comparison.pass: true`, no
|
||||
artifacts, and a summary that starts with
|
||||
`Mantis did not generate before/after GIFs because`. Include a short
|
||||
public reason, such as `the PR changes internal session bookkeeping rather
|
||||
than Telegram-visible behavior`. Use this manifest shape and do not create
|
||||
worktrees or start Crabbox for this case:
|
||||
`Mantis did not generate before/after GIFs because`. Include the concrete
|
||||
reason in the summary. Use this manifest shape and do not create worktrees
|
||||
or start Crabbox for this case:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -89,15 +73,6 @@ than Telegram-visible behavior`. Use this manifest shape and do not create
|
||||
}
|
||||
```
|
||||
|
||||
If the PR appears visual but proof is blocked by Telegram Desktop session
|
||||
state, authorization, credentials, Crabbox, missing Telegram client support,
|
||||
unavailable media/provider setup, or another capture-infrastructure issue,
|
||||
do not describe it as a no-visual PR. Write a manifest with
|
||||
`comparison.pass: false`, skipped lanes, no artifacts, and a summary that
|
||||
starts with `Mantis could not capture Telegram Desktop proof because`. The
|
||||
publisher will keep that out of PR comments so the failure stays in the
|
||||
workflow logs and artifacts.
|
||||
|
||||
4. Decide what Telegram message, mock model response, command, callback, button,
|
||||
media, or sequence best proves the PR. Use `MANTIS_INSTRUCTIONS` as extra
|
||||
maintainer guidance, not as a replacement for reading the PR.
|
||||
@@ -119,10 +94,8 @@ than Telegram-visible behavior`. Use this manifest shape and do not create
|
||||
`$OPENCLAW_TELEGRAM_USER_DRIVER_SCRIPT`, the workflow-provided `crabbox`
|
||||
binary, and the workflow-provided local `ffmpeg`/`ffprobe`; do not generate,
|
||||
install, or patch replacement proof tooling during the run. Use the same
|
||||
proof idea for baseline and candidate. Let `start` return or fail on its
|
||||
own; do not kill it while Crabbox is still waiting for bootstrap. Use a long
|
||||
command timeout for `start`, `send`, `view`, and `finish`. You may iterate
|
||||
and rerun if the visual result is not convincing.
|
||||
proof idea for baseline and candidate. You may iterate and rerun if the
|
||||
visual result is not convincing.
|
||||
7. Open Telegram Desktop directly to the newest relevant message with the
|
||||
runner `view` command before finishing each recording. Keep the chat scrolled
|
||||
to the bottom so new proof messages appear in-frame.
|
||||
@@ -161,6 +134,4 @@ Expected final state:
|
||||
`Main` and `This PR`.
|
||||
- No-visual-proof manifests contain no artifacts and have `comparison.pass:
|
||||
true`.
|
||||
- Capture-infrastructure failure manifests contain no artifacts and have
|
||||
`comparison.pass: false`.
|
||||
- The worktree can be dirty only under `.artifacts/`.
|
||||
|
||||
7
.github/labeler.yml
vendored
7
.github/labeler.yml
vendored
@@ -101,9 +101,7 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/qa-lab/**"
|
||||
- "qa/scenarios/**"
|
||||
- "docs/concepts/qa-e2e-automation.md"
|
||||
- "docs/concepts/personal-agent-benchmark-pack.md"
|
||||
- "docs/channels/qa-channel.md"
|
||||
"channel: signal":
|
||||
- changed-files:
|
||||
@@ -286,11 +284,6 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/oc-path/**"
|
||||
"extensions: policy":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/policy/**"
|
||||
- "docs/cli/policy.md"
|
||||
"extensions: open-prose":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
|
||||
8
.github/pull_request_template.md
vendored
8
.github/pull_request_template.md
vendored
@@ -5,16 +5,10 @@ Describe the problem and fix in 2–5 bullets:
|
||||
If this PR fixes a plugin beta-release blocker, title it `fix(<plugin-id>): beta blocker - <summary>` and link the matching `Beta blocker: <plugin-name> - <summary>` issue labeled `beta-blocker`. Contributors cannot label PRs, so the title is the PR-side signal for maintainers and automation.
|
||||
|
||||
- Problem:
|
||||
- Solution:
|
||||
- Why it matters:
|
||||
- What changed:
|
||||
- What did NOT change (scope boundary):
|
||||
|
||||
## Motivation
|
||||
|
||||
Explain why this change should exist now. Link it to the user pain, failure mode, maintainer need, or product goal. If this is purely mechanical, write `N/A`.
|
||||
|
||||
-
|
||||
|
||||
## Change Type (select all)
|
||||
|
||||
- [ ] Bug fix
|
||||
|
||||
401
.github/workflows/ci.yml
vendored
401
.github/workflows/ci.yml
vendored
@@ -20,8 +20,6 @@ on:
|
||||
- "docs/**"
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
|
||||
paths-ignore:
|
||||
- "CHANGELOG.md"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -40,7 +38,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
outputs:
|
||||
checkout_revision: ${{ steps.checkout_ref.outputs.sha }}
|
||||
@@ -60,11 +58,14 @@ jobs:
|
||||
plugin_contracts_matrix: ${{ steps.manifest.outputs.plugin_contracts_matrix }}
|
||||
channel_contracts_matrix: ${{ steps.manifest.outputs.channel_contracts_matrix }}
|
||||
run_checks: ${{ steps.manifest.outputs.run_checks }}
|
||||
checks_matrix: ${{ steps.manifest.outputs.checks_matrix }}
|
||||
run_checks_node_core_nondist: ${{ steps.manifest.outputs.run_checks_node_core_nondist }}
|
||||
checks_node_core_nondist_matrix: ${{ steps.manifest.outputs.checks_node_core_nondist_matrix }}
|
||||
run_checks_node_core_dist: ${{ steps.manifest.outputs.run_checks_node_core_dist }}
|
||||
checks_node_core_dist_matrix: ${{ steps.manifest.outputs.checks_node_core_dist_matrix }}
|
||||
run_check: ${{ steps.manifest.outputs.run_check }}
|
||||
run_check_additional: ${{ steps.manifest.outputs.run_check_additional }}
|
||||
run_build_smoke: ${{ steps.manifest.outputs.run_build_smoke }}
|
||||
run_check_docs: ${{ steps.manifest.outputs.run_check_docs }}
|
||||
run_control_ui_i18n: ${{ steps.manifest.outputs.run_control_ui_i18n }}
|
||||
run_checks_windows: ${{ steps.manifest.outputs.run_checks_windows }}
|
||||
@@ -131,7 +132,6 @@ jobs:
|
||||
OPENCLAW_CI_RUN_CONTROL_UI_I18N: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_control_ui_i18n || 'false' }}
|
||||
OPENCLAW_CI_CHECKOUT_REVISION: ${{ steps.checkout_ref.outputs.sha }}
|
||||
OPENCLAW_CI_REPOSITORY: ${{ github.repository }}
|
||||
OPENCLAW_CI_EVENT_NAME: ${{ github.event_name }}
|
||||
run: |
|
||||
node --input-type=module <<'EOF'
|
||||
import { appendFileSync } from "node:fs";
|
||||
@@ -173,7 +173,6 @@ jobs:
|
||||
const isCanonicalRepository = process.env.OPENCLAW_CI_REPOSITORY === "openclaw/openclaw";
|
||||
const docsOnly = parseBoolean(process.env.OPENCLAW_CI_DOCS_ONLY);
|
||||
const docsChanged = parseBoolean(process.env.OPENCLAW_CI_DOCS_CHANGED);
|
||||
const eventName = process.env.OPENCLAW_CI_EVENT_NAME ?? "";
|
||||
const runNode = parseBoolean(process.env.OPENCLAW_CI_RUN_NODE) && !docsOnly;
|
||||
const runNodeFastOnly =
|
||||
runNode && parseBoolean(process.env.OPENCLAW_CI_RUN_NODE_FAST_ONLY);
|
||||
@@ -198,7 +197,7 @@ jobs:
|
||||
const checksFastCoreTasks = [];
|
||||
if (runNodeFull) {
|
||||
checksFastCoreTasks.push(
|
||||
{ check_name: "checks-fast-bundled-protocol", runtime: "node", task: "bundled-protocol" },
|
||||
{ check_name: "checks-fast-bundled", runtime: "node", task: "bundled" },
|
||||
);
|
||||
} else {
|
||||
if (runNodeFastCiRouting) {
|
||||
@@ -247,12 +246,21 @@ jobs:
|
||||
runNodeFull ? createChannelContractTestShards() : [],
|
||||
),
|
||||
run_checks: runNodeFull,
|
||||
checks_matrix: createMatrix(
|
||||
runNodeFull
|
||||
? [
|
||||
{ check_name: "checks-node-channels", runtime: "node", task: "channels" },
|
||||
]
|
||||
: [],
|
||||
),
|
||||
run_checks_node_core_nondist: nodeTestNonDistShards.length > 0,
|
||||
checks_node_core_nondist_matrix: createMatrix(nodeTestNonDistShards),
|
||||
run_checks_node_core_dist: nodeTestDistShards.length > 0,
|
||||
checks_node_core_dist_matrix: createMatrix(nodeTestDistShards),
|
||||
run_check: runNodeFull,
|
||||
run_check_additional: runNodeFull,
|
||||
run_check_docs: docsChanged && eventName !== "push",
|
||||
run_build_smoke: runNodeFull,
|
||||
run_check_docs: docsChanged,
|
||||
run_control_ui_i18n: runControlUiI18n,
|
||||
run_skills_python_job: runSkillsPython,
|
||||
run_checks_windows: runWindows,
|
||||
@@ -287,13 +295,13 @@ jobs:
|
||||
}
|
||||
EOF
|
||||
|
||||
# Run dependency-free security checks in parallel with scope detection so the
|
||||
# Run the fast security/SCM checks in parallel with scope detection so the
|
||||
# main Node jobs do not have to wait for Python/pre-commit setup.
|
||||
security-fast:
|
||||
security-scm-fast:
|
||||
permissions:
|
||||
contents: read
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
PRE_COMMIT_HOME: .cache/pre-commit-security-fast
|
||||
@@ -382,6 +390,22 @@ jobs:
|
||||
printf 'Auditing workflow files:\n%s\n' "${workflow_files[@]}"
|
||||
pre-commit run --config "${PRE_COMMIT_CONFIG_PATH:-.pre-commit-config.yaml}" zizmor --files "${workflow_files[@]}"
|
||||
|
||||
security-dependency-audit:
|
||||
permissions:
|
||||
contents: read
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.target_ref || github.sha }}
|
||||
fetch-depth: 1
|
||||
fetch-tags: false
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -391,6 +415,35 @@ jobs:
|
||||
- name: Audit production dependencies
|
||||
run: node scripts/pre-commit/pnpm-audit-prod.mjs --audit-level=high
|
||||
|
||||
security-fast:
|
||||
permissions: {}
|
||||
needs: [security-scm-fast, security-dependency-audit]
|
||||
if: ${{ !cancelled() && always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Verify fast security jobs
|
||||
env:
|
||||
DEPENDENCY_AUDIT_RESULT: ${{ needs.security-dependency-audit.result }}
|
||||
SCM_RESULT: ${{ needs.security-scm-fast.result }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
failed=0
|
||||
|
||||
for result in \
|
||||
"security-scm-fast=${SCM_RESULT}" \
|
||||
"security-dependency-audit=${DEPENDENCY_AUDIT_RESULT}"
|
||||
do
|
||||
job="${result%%=*}"
|
||||
status="${result#*=}"
|
||||
if [ "$status" != "success" ]; then
|
||||
echo "::error::${job} ended with ${status}"
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
|
||||
exit "$failed"
|
||||
|
||||
# Build dist once for Node-relevant changes and share it with downstream jobs.
|
||||
# Keep this overlapping with the fast correctness lanes so green PRs get heavy
|
||||
# test/build feedback sooner instead of waiting behind a full `check` pass.
|
||||
@@ -588,15 +641,6 @@ jobs:
|
||||
echo "${name}-result=${results[$name]}" >> "$GITHUB_OUTPUT"
|
||||
done
|
||||
|
||||
failures=0
|
||||
for name in channels core-support-boundary gateway-watch; do
|
||||
if [ "${results[$name]}" = "failure" ]; then
|
||||
echo "::error title=${name} failed::${name} failed"
|
||||
failures=1
|
||||
fi
|
||||
done
|
||||
exit "$failures"
|
||||
|
||||
- name: Upload gateway watch regression artifacts
|
||||
if: always() && needs.preflight.outputs.run_check_additional == 'true'
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -678,9 +722,14 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$TASK" in
|
||||
bundled-protocol)
|
||||
bundled)
|
||||
pnpm test:bundled
|
||||
pnpm protocol:check
|
||||
;;
|
||||
contracts-channels)
|
||||
pnpm test:contracts:channels
|
||||
;;
|
||||
contracts-plugins)
|
||||
pnpm test:contracts:plugins
|
||||
;;
|
||||
contracts-plugins-ci-routing)
|
||||
pnpm test:contracts:plugins
|
||||
@@ -779,6 +828,28 @@ jobs:
|
||||
EOF
|
||||
OPENCLAW_VITEST_INCLUDE_FILE="$include_file" pnpm test:contracts:plugins
|
||||
|
||||
checks-fast-plugin-contracts:
|
||||
permissions:
|
||||
contents: read
|
||||
name: checks-fast-contracts-plugins
|
||||
needs: [preflight, checks-fast-plugin-contracts-shard]
|
||||
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_plugin_contracts_shards == 'true' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Verify plugin contract shards
|
||||
env:
|
||||
SHARD_RESULT: ${{ needs.checks-fast-plugin-contracts-shard.result }}
|
||||
run: |
|
||||
if [ "$SHARD_RESULT" = "cancelled" ]; then
|
||||
echo "Plugin contract shards were cancelled, usually because a newer commit superseded this run." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$SHARD_RESULT" != "success" ]; then
|
||||
echo "Plugin contract shards failed: $SHARD_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
checks-fast-channel-contracts-shard:
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -863,6 +934,125 @@ jobs:
|
||||
EOF
|
||||
OPENCLAW_VITEST_INCLUDE_FILE="$include_file" pnpm test:contracts:channels
|
||||
|
||||
checks-fast-channel-contracts:
|
||||
permissions:
|
||||
contents: read
|
||||
name: checks-fast-contracts-channels
|
||||
needs: [preflight, checks-fast-channel-contracts-shard]
|
||||
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_checks_fast == 'true' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Verify channel contract shards
|
||||
env:
|
||||
SHARD_RESULT: ${{ needs.checks-fast-channel-contracts-shard.result }}
|
||||
run: |
|
||||
if [ "$SHARD_RESULT" = "cancelled" ]; then
|
||||
echo "Channel contract shards were cancelled, usually because a newer commit superseded this run." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$SHARD_RESULT" != "success" ]; then
|
||||
echo "Channel contract shards failed: $SHARD_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
checks-fast-protocol:
|
||||
permissions:
|
||||
contents: read
|
||||
name: "checks-fast-protocol"
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_checks_fast == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
shell: bash
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
workdir="$GITHUB_WORKSPACE"
|
||||
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
|
||||
|
||||
reset_checkout_dir() {
|
||||
mkdir -p "$workdir"
|
||||
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
}
|
||||
|
||||
checkout_attempt() {
|
||||
local attempt="$1"
|
||||
|
||||
reset_checkout_dir
|
||||
git init "$workdir" >/dev/null
|
||||
git config --global --add safe.directory "$workdir"
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
-c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
|
||||
git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA" || return 1
|
||||
test -f "$workdir/.github/actions/setup-node-env/action.yml" || return 1
|
||||
echo "checkout attempt ${attempt}/5 succeeded"
|
||||
}
|
||||
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if checkout_attempt "$attempt"; then
|
||||
exit 0
|
||||
fi
|
||||
echo "checkout attempt ${attempt}/5 failed"
|
||||
sleep $((attempt * 5))
|
||||
done
|
||||
|
||||
echo "checkout failed after 5 attempts" >&2
|
||||
exit 1
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
|
||||
- name: Run protocol check
|
||||
run: pnpm protocol:check
|
||||
|
||||
checks:
|
||||
permissions:
|
||||
contents: read
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight, build-artifacts]
|
||||
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_checks == 'true' && needs.build-artifacts.result == 'success' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.preflight.outputs.checks_matrix) }}
|
||||
steps:
|
||||
- name: Verify ${{ matrix.task }} (${{ matrix.runtime }})
|
||||
env:
|
||||
TASK: ${{ matrix.task }}
|
||||
CHANNELS_RESULT: ${{ needs.build-artifacts.outputs['channels-result'] }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$TASK" in
|
||||
channels)
|
||||
if [ "$CHANNELS_RESULT" != "success" ]; then
|
||||
echo "Channel tests failed in build-artifacts: $CHANNELS_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported checks task: $TASK" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
checks-node-compat:
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -923,7 +1113,7 @@ jobs:
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: "22.19.0"
|
||||
node-version: "22.18.0"
|
||||
cache-key-suffix: "node22-pnpm11"
|
||||
install-bun: "false"
|
||||
|
||||
@@ -1050,6 +1240,63 @@ jobs:
|
||||
}
|
||||
EOF
|
||||
|
||||
checks-node-core-test-dist-shard:
|
||||
permissions:
|
||||
contents: read
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight, build-artifacts]
|
||||
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_checks_node_core_dist == 'true' && needs.build-artifacts.result == 'success' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.preflight.outputs.checks_node_core_dist_matrix) }}
|
||||
steps:
|
||||
- name: Verify Node test shard
|
||||
env:
|
||||
CORE_SUPPORT_BOUNDARY_RESULT: ${{ needs.build-artifacts.outputs['core-support-boundary-result'] }}
|
||||
SHARD_NAME: ${{ matrix.shard_name }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$SHARD_NAME" in
|
||||
core-support-boundary)
|
||||
if [ "$CORE_SUPPORT_BOUNDARY_RESULT" != "success" ]; then
|
||||
echo "Core support boundary shard failed in build-artifacts: $CORE_SUPPORT_BOUNDARY_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported built-artifact shard: $SHARD_NAME" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
checks-node-core-test:
|
||||
permissions:
|
||||
contents: read
|
||||
name: checks-node-core
|
||||
needs: [preflight, checks-node-core-test-nondist-shard, checks-node-core-test-dist-shard]
|
||||
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_checks == 'true' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Verify node test shards
|
||||
env:
|
||||
DIST_SHARD_RESULT: ${{ needs.checks-node-core-test-dist-shard.result }}
|
||||
NONDIST_SHARD_RESULT: ${{ needs.checks-node-core-test-nondist-shard.result }}
|
||||
RUN_DIST_SHARDS: ${{ needs.preflight.outputs.run_checks_node_core_dist }}
|
||||
RUN_NONDIST_SHARDS: ${{ needs.preflight.outputs.run_checks_node_core_nondist }}
|
||||
run: |
|
||||
if [ "$RUN_NONDIST_SHARDS" = "true" ] && [ "$NONDIST_SHARD_RESULT" != "success" ]; then
|
||||
echo "Node non-dist test shards failed: $NONDIST_SHARD_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$RUN_DIST_SHARDS" = "true" ] && [ "$DIST_SHARD_RESULT" != "success" ]; then
|
||||
echo "Node dist test shards failed: $DIST_SHARD_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Types, lint, and format check shards.
|
||||
check-shard:
|
||||
permissions:
|
||||
@@ -1063,9 +1310,9 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- check_name: check-guards
|
||||
task: guards
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
- check_name: check-preflight-guards
|
||||
task: preflight-guards
|
||||
runner: ubuntu-24.04
|
||||
- check_name: check-prod-types
|
||||
task: prod-types
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
@@ -1074,10 +1321,16 @@ jobs:
|
||||
runner: blacksmith-16vcpu-ubuntu-2404
|
||||
- check_name: check-dependencies
|
||||
task: dependencies
|
||||
runner: blacksmith-8vcpu-ubuntu-2404
|
||||
runner: ubuntu-24.04
|
||||
- check_name: check-policy-guards
|
||||
task: policy-guards
|
||||
runner: ubuntu-24.04
|
||||
- check_name: check-test-types
|
||||
task: test-types
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
- check_name: check-strict-smoke
|
||||
task: strict-smoke
|
||||
runner: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
shell: bash
|
||||
@@ -1140,18 +1393,12 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$TASK" in
|
||||
guards)
|
||||
preflight-guards)
|
||||
pnpm check:no-conflict-markers
|
||||
pnpm tool-display:check
|
||||
pnpm check:host-env-policy:swift
|
||||
pnpm dup:check:coverage
|
||||
pnpm deps:patches:check
|
||||
pnpm lint:webhook:no-low-level-body-read
|
||||
pnpm lint:auth:no-pairing-store-group
|
||||
pnpm lint:auth:pairing-account-scope
|
||||
pnpm check:import-cycles
|
||||
# build-artifacts already runs the tsdown/runtime build for the same Node-relevant changes.
|
||||
pnpm build:plugin-sdk:strict-smoke
|
||||
;;
|
||||
prod-types)
|
||||
pnpm tsgo:prod
|
||||
@@ -1168,9 +1415,19 @@ jobs:
|
||||
pnpm deadcode:ci
|
||||
fi
|
||||
;;
|
||||
policy-guards)
|
||||
pnpm lint:webhook:no-low-level-body-read
|
||||
pnpm lint:auth:no-pairing-store-group
|
||||
pnpm lint:auth:pairing-account-scope
|
||||
pnpm check:import-cycles
|
||||
;;
|
||||
test-types)
|
||||
pnpm check:test-types
|
||||
;;
|
||||
strict-smoke)
|
||||
# build-artifacts already runs the tsdown/runtime build for the same Node-relevant changes.
|
||||
pnpm build:plugin-sdk:strict-smoke
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported check task: $TASK" >&2
|
||||
exit 1
|
||||
@@ -1185,6 +1442,24 @@ jobs:
|
||||
path: .artifacts/deadcode
|
||||
if-no-files-found: ignore
|
||||
|
||||
check:
|
||||
permissions:
|
||||
contents: read
|
||||
name: "check"
|
||||
needs: [preflight, check-shard]
|
||||
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check == 'true' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Verify check shards
|
||||
env:
|
||||
SHARD_RESULT: ${{ needs.check-shard.result }}
|
||||
run: |
|
||||
if [ "$SHARD_RESULT" != "success" ]; then
|
||||
echo "Check shards failed: $SHARD_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
check-additional-shard:
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -1200,9 +1475,15 @@ jobs:
|
||||
- check_name: check-additional-boundaries-a
|
||||
group: boundaries
|
||||
boundary_shard: 1/4
|
||||
- check_name: check-additional-boundaries-bcd
|
||||
- check_name: check-additional-boundaries-b
|
||||
group: boundaries
|
||||
boundary_shard: 2/4,3/4,4/4
|
||||
boundary_shard: 2/4
|
||||
- check_name: check-additional-boundaries-c
|
||||
group: boundaries
|
||||
boundary_shard: 3/4
|
||||
- check_name: check-additional-boundaries-d
|
||||
group: boundaries
|
||||
boundary_shard: 4/4
|
||||
- check_name: check-additional-extension-channels
|
||||
group: extension-channels
|
||||
- check_name: check-additional-extension-bundled
|
||||
@@ -1356,13 +1637,59 @@ jobs:
|
||||
|
||||
exit "$failures"
|
||||
|
||||
check-additional:
|
||||
permissions:
|
||||
contents: read
|
||||
name: "check-additional"
|
||||
needs: [preflight, check-additional-shard, build-artifacts]
|
||||
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check_additional == 'true' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Verify additional check shards
|
||||
env:
|
||||
SHARD_RESULT: ${{ needs.check-additional-shard.result }}
|
||||
BUILD_ARTIFACTS_RESULT: ${{ needs.build-artifacts.result }}
|
||||
GATEWAY_RESULT: ${{ needs.build-artifacts.outputs.gateway-watch-result }}
|
||||
run: |
|
||||
if [ "$SHARD_RESULT" != "success" ]; then
|
||||
echo "Additional check shards failed: $SHARD_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$BUILD_ARTIFACTS_RESULT" != "success" ]; then
|
||||
echo "Build artifact job failed: $BUILD_ARTIFACTS_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$GATEWAY_RESULT" != "success" ]; then
|
||||
echo "Gateway topology check failed: $GATEWAY_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build-smoke:
|
||||
permissions:
|
||||
contents: read
|
||||
name: "build-smoke"
|
||||
needs: [preflight, build-artifacts]
|
||||
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_build_smoke == 'true' && (github.event_name != 'push' || needs.build-artifacts.result == 'success') }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Verify build smoke
|
||||
env:
|
||||
BUILD_ARTIFACTS_RESULT: ${{ needs.build-artifacts.result }}
|
||||
run: |
|
||||
if [ "$BUILD_ARTIFACTS_RESULT" != "success" ]; then
|
||||
echo "Build smoke checks failed in build-artifacts: $BUILD_ARTIFACTS_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate docs (format, lint, broken links) only when docs files changed.
|
||||
check-docs:
|
||||
permissions:
|
||||
contents: read
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_check_docs == 'true'
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -1436,7 +1763,7 @@ jobs:
|
||||
contents: read
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_skills_python_job == 'true'
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
@@ -138,7 +138,7 @@ jobs:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENCLAW_DOCS_I18N_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
OPENCLAW_CONTROL_UI_I18N_PROVIDER: ${{ secrets.ANTHROPIC_API_KEY != '' && 'anthropic' || 'openai' }}
|
||||
OPENCLAW_CONTROL_UI_I18N_MODEL: ${{ secrets.ANTHROPIC_API_KEY != '' && 'claude-opus-4-7' || vars.OPENCLAW_CI_OPENAI_MODEL_BARE }}
|
||||
OPENCLAW_CONTROL_UI_I18N_MODEL: ${{ secrets.ANTHROPIC_API_KEY != '' && 'claude-opus-4-6' || vars.OPENCLAW_CI_OPENAI_MODEL_BARE }}
|
||||
OPENCLAW_CONTROL_UI_I18N_THINKING: low
|
||||
OPENCLAW_CONTROL_UI_I18N_AUTH_OPTIONAL: "1"
|
||||
LOCALE: ${{ matrix.locale }}
|
||||
|
||||
11
.github/workflows/crabbox-hydrate.yml
vendored
11
.github/workflows/crabbox-hydrate.yml
vendored
@@ -62,20 +62,17 @@ jobs:
|
||||
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
|
||||
sudo ln -sf "$pnpm_bin" /usr/local/bin/pnpm
|
||||
|
||||
- name: Ensure Docker is running
|
||||
- name: Ensure Docker is available
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "docker not found; installing fallback engine"
|
||||
curl -fsSL https://get.docker.com | sudo sh
|
||||
fi
|
||||
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
sudo systemctl start docker || true
|
||||
elif command -v service >/dev/null 2>&1; then
|
||||
sudo service docker start || true
|
||||
sudo systemctl start docker
|
||||
fi
|
||||
|
||||
if [ -S /var/run/docker.sock ]; then
|
||||
@@ -85,10 +82,6 @@ jobs:
|
||||
sudo chmod 666 /var/run/docker.sock
|
||||
fi
|
||||
|
||||
docker version
|
||||
docker buildx version || true
|
||||
docker compose version || true
|
||||
|
||||
- name: Hydrate provider env helper
|
||||
shell: bash
|
||||
env:
|
||||
|
||||
4
.github/workflows/docker-release.yml
vendored
4
.github/workflows/docker-release.yml
vendored
@@ -155,7 +155,7 @@ jobs:
|
||||
cache-from: type=gha,scope=docker-release-amd64
|
||||
cache-to: type=gha,mode=max,scope=docker-release-amd64
|
||||
build-args: |
|
||||
OPENCLAW_EXTENSIONS=diagnostics-otel,codex
|
||||
OPENCLAW_EXTENSIONS=diagnostics-otel
|
||||
tags: ${{ steps.tags.outputs.value }}
|
||||
labels: ${{ steps.labels.outputs.value }}
|
||||
sbom: true
|
||||
@@ -253,7 +253,7 @@ jobs:
|
||||
cache-from: type=gha,scope=docker-release-arm64
|
||||
cache-to: type=gha,mode=max,scope=docker-release-arm64
|
||||
build-args: |
|
||||
OPENCLAW_EXTENSIONS=diagnostics-otel,codex
|
||||
OPENCLAW_EXTENSIONS=diagnostics-otel
|
||||
tags: ${{ steps.tags.outputs.value }}
|
||||
labels: ${{ steps.labels.outputs.value }}
|
||||
sbom: true
|
||||
|
||||
2
.github/workflows/docs-sync-publish.yml
vendored
2
.github/workflows/docs-sync-publish.yml
vendored
@@ -43,7 +43,7 @@ jobs:
|
||||
if: env.OPENCLAW_DOCS_SYNC_TOKEN != ''
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24.x"
|
||||
node-version: "22.18.0"
|
||||
|
||||
- name: Clone publish repo
|
||||
if: env.OPENCLAW_DOCS_SYNC_TOKEN != ''
|
||||
|
||||
11
.github/workflows/docs.yml
vendored
11
.github/workflows/docs.yml
vendored
@@ -6,7 +6,6 @@ on:
|
||||
paths:
|
||||
- "**/*.md"
|
||||
- "docs/**"
|
||||
- "!CHANGELOG.md"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -36,15 +35,5 @@ jobs:
|
||||
with:
|
||||
install-bun: "false"
|
||||
|
||||
- name: Checkout ClawHub docs source
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: openclaw/clawhub
|
||||
path: clawhub-source
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check docs
|
||||
env:
|
||||
OPENCLAW_DOCS_SYNC_CLAWHUB_REPO: ${{ github.workspace }}/clawhub-source
|
||||
run: pnpm check:docs
|
||||
|
||||
103
.github/workflows/full-release-validation.yml
vendored
103
.github/workflows/full-release-validation.yml
vendored
@@ -88,11 +88,6 @@ on:
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
codex_plugin_spec:
|
||||
description: Optional Codex plugin install spec for live Docker package checks; blank derives from release_package_spec or packs the selected ref
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
npm_telegram_provider_mode:
|
||||
description: Provider mode for the package Telegram E2E lane
|
||||
required: false
|
||||
@@ -113,7 +108,7 @@ permissions:
|
||||
|
||||
concurrency:
|
||||
group: full-release-validation-${{ inputs.ref }}-${{ inputs.rerun_group }}
|
||||
cancel-in-progress: ${{ (inputs.ref == 'main' && inputs.rerun_group == 'all') || startsWith(inputs.ref, 'tideclaw/alpha/') }}
|
||||
cancel-in-progress: ${{ inputs.ref == 'main' && inputs.rerun_group == 'all' }}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
@@ -156,7 +151,6 @@ jobs:
|
||||
RELEASE_PACKAGE_SPEC: ${{ inputs.release_package_spec }}
|
||||
EVIDENCE_PACKAGE_SPEC: ${{ inputs.evidence_package_spec }}
|
||||
PACKAGE_ACCEPTANCE_PACKAGE_SPEC: ${{ inputs.package_acceptance_package_spec }}
|
||||
CODEX_PLUGIN_SPEC: ${{ inputs.codex_plugin_spec }}
|
||||
RELEASE_PROFILE: ${{ inputs.release_profile }}
|
||||
RUN_RELEASE_SOAK: ${{ inputs.run_release_soak || inputs.release_profile == 'full' }}
|
||||
RERUN_GROUP: ${{ inputs.rerun_group }}
|
||||
@@ -214,43 +208,14 @@ jobs:
|
||||
else
|
||||
echo "- Package Acceptance package spec: SHA-built release artifact"
|
||||
fi
|
||||
if [[ -n "${CODEX_PLUGIN_SPEC// }" ]]; then
|
||||
echo "- Codex plugin spec: \`${CODEX_PLUGIN_SPEC}\`"
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
docker_runtime_assets_preflight:
|
||||
name: Verify Docker runtime-assets prune path
|
||||
needs: [resolve_target]
|
||||
if: inputs.rerun_group == 'all'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout target SHA
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.resolve_target.outputs.sha }}
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Verify Docker runtime-assets prune path
|
||||
env:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
timeout --foreground --kill-after=30s 35m docker build \
|
||||
--target runtime-assets \
|
||||
--build-arg OPENCLAW_EXTENSIONS="matrix" \
|
||||
.
|
||||
|
||||
normal_ci:
|
||||
name: Run normal full CI
|
||||
needs: [resolve_target, docker_runtime_assets_preflight]
|
||||
if: ${{ always() && needs.resolve_target.result == 'success' && contains(fromJSON('["all","ci"]'), inputs.rerun_group) && (inputs.rerun_group != 'all' || needs.docker_runtime_assets_preflight.result == 'success') }}
|
||||
needs: [resolve_target]
|
||||
if: contains(fromJSON('["all","ci"]'), inputs.rerun_group)
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: ${{ inputs.release_profile != 'minimum' && 240 || 60 }}
|
||||
timeout-minutes: ${{ inputs.release_profile == 'full' && 240 || 60 }}
|
||||
outputs:
|
||||
run_id: ${{ steps.dispatch.outputs.run_id }}
|
||||
url: ${{ steps.dispatch.outputs.url }}
|
||||
@@ -347,10 +312,10 @@ jobs:
|
||||
|
||||
plugin_prerelease:
|
||||
name: Run plugin prerelease validation
|
||||
needs: [resolve_target, docker_runtime_assets_preflight]
|
||||
if: ${{ always() && needs.resolve_target.result == 'success' && contains(fromJSON('["all","plugin-prerelease"]'), inputs.rerun_group) && (inputs.rerun_group != 'all' || needs.docker_runtime_assets_preflight.result == 'success') }}
|
||||
needs: [resolve_target]
|
||||
if: contains(fromJSON('["all","plugin-prerelease"]'), inputs.rerun_group)
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: ${{ inputs.release_profile == 'full' && 300 || inputs.release_profile == 'stable' && 240 || 60 }}
|
||||
timeout-minutes: ${{ inputs.release_profile == 'full' && 300 || 60 }}
|
||||
outputs:
|
||||
run_id: ${{ steps.dispatch.outputs.run_id }}
|
||||
url: ${{ steps.dispatch.outputs.url }}
|
||||
@@ -447,10 +412,10 @@ jobs:
|
||||
|
||||
release_checks:
|
||||
name: Run release/live/Docker/QA validation
|
||||
needs: [resolve_target, docker_runtime_assets_preflight]
|
||||
if: ${{ always() && needs.resolve_target.result == 'success' && contains(fromJSON('["all","release-checks","install-smoke","cross-os","live-e2e","package","qa","qa-parity","qa-live"]'), inputs.rerun_group) && (inputs.rerun_group != 'all' || needs.docker_runtime_assets_preflight.result == 'success') }}
|
||||
needs: [resolve_target]
|
||||
if: contains(fromJSON('["all","release-checks","install-smoke","cross-os","live-e2e","package","qa","qa-parity","qa-live"]'), inputs.rerun_group)
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: ${{ inputs.release_profile != 'minimum' && 240 || 60 }}
|
||||
timeout-minutes: ${{ inputs.release_profile == 'full' && 240 || 60 }}
|
||||
outputs:
|
||||
run_id: ${{ steps.dispatch.outputs.run_id }}
|
||||
url: ${{ steps.dispatch.outputs.url }}
|
||||
@@ -472,7 +437,6 @@ jobs:
|
||||
CROSS_OS_SUITE_FILTER: ${{ inputs.cross_os_suite_filter }}
|
||||
RELEASE_PACKAGE_SPEC: ${{ inputs.release_package_spec }}
|
||||
PACKAGE_ACCEPTANCE_PACKAGE_SPEC: ${{ inputs.package_acceptance_package_spec }}
|
||||
CODEX_PLUGIN_SPEC: ${{ inputs.codex_plugin_spec }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -568,9 +532,6 @@ jobs:
|
||||
if [[ -n "${PACKAGE_ACCEPTANCE_PACKAGE_SPEC// }" ]]; then
|
||||
echo "- Package Acceptance package spec: \`${PACKAGE_ACCEPTANCE_PACKAGE_SPEC}\`"
|
||||
fi
|
||||
if [[ -n "${CODEX_PLUGIN_SPEC// }" ]]; then
|
||||
echo "- Codex plugin spec: \`${CODEX_PLUGIN_SPEC}\`"
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
child_rerun_group="$RERUN_GROUP"
|
||||
@@ -599,16 +560,13 @@ jobs:
|
||||
if [[ -n "${PACKAGE_ACCEPTANCE_PACKAGE_SPEC// }" ]]; then
|
||||
args+=(-f package_acceptance_package_spec="$PACKAGE_ACCEPTANCE_PACKAGE_SPEC")
|
||||
fi
|
||||
if [[ -n "${CODEX_PLUGIN_SPEC// }" ]]; then
|
||||
args+=(-f codex_plugin_spec="$CODEX_PLUGIN_SPEC")
|
||||
fi
|
||||
|
||||
dispatch_and_wait openclaw-release-checks.yml "${args[@]}"
|
||||
|
||||
prepare_release_package:
|
||||
name: Prepare release package artifact
|
||||
needs: [resolve_target, docker_runtime_assets_preflight]
|
||||
if: ${{ always() && needs.resolve_target.result == 'success' && inputs.npm_telegram_package_spec == '' && inputs.release_package_spec == '' && inputs.rerun_group == 'all' && inputs.release_profile == 'full' && needs.docker_runtime_assets_preflight.result == 'success' }}
|
||||
needs: [resolve_target]
|
||||
if: ${{ inputs.npm_telegram_package_spec == '' && inputs.release_package_spec == '' && inputs.rerun_group == 'all' && inputs.release_profile == 'full' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
@@ -680,7 +638,6 @@ jobs:
|
||||
name: Run package Telegram E2E
|
||||
needs: [resolve_target, prepare_release_package]
|
||||
if: ${{ always() && contains(fromJSON('["all","npm-telegram"]'), inputs.rerun_group) && (inputs.npm_telegram_package_spec != '' || inputs.release_package_spec != '' || (inputs.rerun_group == 'all' && inputs.release_profile == 'full')) }}
|
||||
continue-on-error: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: ${{ inputs.release_profile == 'full' && 120 || 60 }}
|
||||
outputs:
|
||||
@@ -777,7 +734,7 @@ jobs:
|
||||
|
||||
summary:
|
||||
name: Verify full validation
|
||||
needs: [resolve_target, docker_runtime_assets_preflight, normal_ci, plugin_prerelease, release_checks, npm_telegram]
|
||||
needs: [resolve_target, normal_ci, plugin_prerelease, release_checks, npm_telegram]
|
||||
if: always()
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
@@ -793,8 +750,6 @@ jobs:
|
||||
PLUGIN_PRERELEASE_RESULT: ${{ needs.plugin_prerelease.result }}
|
||||
RELEASE_CHECKS_RESULT: ${{ needs.release_checks.result }}
|
||||
NPM_TELEGRAM_RESULT: ${{ needs.npm_telegram.result }}
|
||||
DOCKER_RUNTIME_ASSETS_PREFLIGHT_RESULT: ${{ needs.docker_runtime_assets_preflight.result }}
|
||||
RERUN_GROUP: ${{ inputs.rerun_group }}
|
||||
TARGET_SHA: ${{ needs.resolve_target.outputs.sha }}
|
||||
CHILD_WORKFLOW_REF: ${{ github.ref_name }}
|
||||
run: |
|
||||
@@ -842,7 +797,6 @@ jobs:
|
||||
echo
|
||||
echo "| Child | Result | Minutes | Head SHA | Run |"
|
||||
echo "| --- | --- | ---: | --- | --- |"
|
||||
echo "| \`docker_runtime_assets_preflight\` | \`${DOCKER_RUNTIME_ASSETS_PREFLIGHT_RESULT}\` | | current workflow | |"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
append_child_row() {
|
||||
@@ -978,54 +932,29 @@ jobs:
|
||||
}
|
||||
|
||||
failed=0
|
||||
normal_ci_required=0
|
||||
plugin_prerelease_required=0
|
||||
release_checks_required=0
|
||||
if [[ "$RERUN_GROUP" == "all" && "$DOCKER_RUNTIME_ASSETS_PREFLIGHT_RESULT" != "success" ]]; then
|
||||
echo "::error::Docker runtime-assets preflight ended with ${DOCKER_RUNTIME_ASSETS_PREFLIGHT_RESULT}."
|
||||
failed=1
|
||||
elif [[ "$RERUN_GROUP" == "all" ]]; then
|
||||
normal_ci_required=1
|
||||
plugin_prerelease_required=1
|
||||
release_checks_required=1
|
||||
else
|
||||
case "$RERUN_GROUP" in
|
||||
ci)
|
||||
normal_ci_required=1
|
||||
;;
|
||||
plugin-prerelease)
|
||||
plugin_prerelease_required=1
|
||||
;;
|
||||
release-checks|install-smoke|cross-os|live-e2e|package|qa|qa-parity|qa-live)
|
||||
release_checks_required=1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
append_child_overview
|
||||
|
||||
if [[ "$NORMAL_CI_RESULT" == "skipped" && -z "${NORMAL_CI_RUN_ID// }" ]]; then
|
||||
check_child "normal_ci" "" "$normal_ci_required" || failed=1
|
||||
check_child "normal_ci" "" 0 || failed=1
|
||||
else
|
||||
check_child "normal_ci" "$NORMAL_CI_RUN_ID" 1 || failed=1
|
||||
fi
|
||||
|
||||
if [[ "$PLUGIN_PRERELEASE_RESULT" == "skipped" && -z "${PLUGIN_PRERELEASE_RUN_ID// }" ]]; then
|
||||
check_child "plugin_prerelease" "" "$plugin_prerelease_required" || failed=1
|
||||
check_child "plugin_prerelease" "" 0 || failed=1
|
||||
else
|
||||
check_child "plugin_prerelease" "$PLUGIN_PRERELEASE_RUN_ID" 1 || failed=1
|
||||
fi
|
||||
|
||||
if [[ "$RELEASE_CHECKS_RESULT" == "skipped" && -z "${RELEASE_CHECKS_RUN_ID// }" ]]; then
|
||||
check_child "release_checks" "" "$release_checks_required" || failed=1
|
||||
check_child "release_checks" "" 0 || failed=1
|
||||
else
|
||||
check_child "release_checks" "$RELEASE_CHECKS_RUN_ID" 1 || failed=1
|
||||
fi
|
||||
|
||||
if [[ "$NPM_TELEGRAM_RESULT" == "skipped" && -z "${NPM_TELEGRAM_RUN_ID// }" ]]; then
|
||||
check_child "npm_telegram" "" 0 || failed=1
|
||||
elif [[ "$CHILD_WORKFLOW_REF" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
check_child "npm_telegram" "$NPM_TELEGRAM_RUN_ID" 0 || echo "::warning::npm_telegram is advisory for Tideclaw alpha validation."
|
||||
else
|
||||
check_child "npm_telegram" "$NPM_TELEGRAM_RUN_ID" 1 || failed=1
|
||||
fi
|
||||
|
||||
1
.github/workflows/labeler.yml
vendored
1
.github/workflows/labeler.yml
vendored
@@ -760,7 +760,6 @@ jobs:
|
||||
core.info(`Processed ${processed} pull requests.`);
|
||||
|
||||
label-issues:
|
||||
if: github.event_name == 'issues'
|
||||
permissions:
|
||||
issues: write
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
11
.github/workflows/mantis-discord-smoke.yml
vendored
11
.github/workflows/mantis-discord-smoke.yml
vendored
@@ -33,11 +33,8 @@ 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@v8
|
||||
with:
|
||||
script: |
|
||||
@@ -51,18 +48,14 @@ jobs:
|
||||
const permission = data.permission;
|
||||
core.info(`Actor ${context.actor} permission: ${permission}`);
|
||||
if (!allowed.has(permission)) {
|
||||
core.notice(
|
||||
core.setFailed(
|
||||
`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 }}
|
||||
@@ -168,7 +161,7 @@ jobs:
|
||||
|
||||
- name: Upload Mantis artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mantis-discord-smoke-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: .artifacts/qa-e2e/mantis/
|
||||
|
||||
@@ -46,17 +46,15 @@ jobs:
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
(
|
||||
contains(github.event.comment.body, '@openclaw-mantis') ||
|
||||
contains(github.event.comment.body, '/openclaw-mantis')
|
||||
contains(github.event.comment.body, '@Mantis') ||
|
||||
contains(github.event.comment.body, '@mantis') ||
|
||||
contains(github.event.comment.body, '/mantis')
|
||||
)
|
||||
)
|
||||
}}
|
||||
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@v8
|
||||
with:
|
||||
script: |
|
||||
@@ -70,18 +68,14 @@ jobs:
|
||||
const permission = data.permission;
|
||||
core.info(`Actor ${context.actor} permission: ${permission}`);
|
||||
if (!allowed.has(permission)) {
|
||||
core.notice(
|
||||
core.setFailed(
|
||||
`Workflow requires write/maintain/admin access. Actor "${context.actor}" has "${permission}".`,
|
||||
);
|
||||
core.setOutput("authorized", "false");
|
||||
return;
|
||||
}
|
||||
core.setOutput("authorized", "true");
|
||||
|
||||
resolve_request:
|
||||
name: Resolve Mantis request
|
||||
needs: authorize_actor
|
||||
if: needs.authorize_actor.outputs.authorized == 'true'
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
outputs:
|
||||
baseline_ref: ${{ steps.resolve.outputs.baseline_ref }}
|
||||
@@ -127,7 +121,7 @@ jobs:
|
||||
|
||||
const normalized = body.toLowerCase();
|
||||
const requested =
|
||||
(normalized.includes("@openclaw-mantis") || normalized.includes("/openclaw-mantis")) &&
|
||||
(normalized.includes("@mantis") || normalized.includes("/mantis")) &&
|
||||
normalized.includes("discord") &&
|
||||
normalized.includes("status") &&
|
||||
normalized.includes("reaction");
|
||||
@@ -348,8 +342,8 @@ jobs:
|
||||
--repo-root "$repo_root" \
|
||||
--output-dir "$output_dir" \
|
||||
--provider-mode live-frontier \
|
||||
--model openai/gpt-5.5 \
|
||||
--alt-model openai/gpt-5.5 \
|
||||
--model openai/gpt-5.4 \
|
||||
--alt-model openai/gpt-5.4 \
|
||||
--fast \
|
||||
--credential-source convex \
|
||||
--credential-role ci \
|
||||
@@ -528,7 +522,7 @@ jobs:
|
||||
- name: Upload Mantis status reaction artifacts
|
||||
id: upload_artifact
|
||||
if: ${{ always() && steps.run_mantis.outputs.output_dir != '' }}
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mantis-discord-status-reactions-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_mantis.outputs.output_dir }}
|
||||
@@ -573,44 +567,3 @@ jobs:
|
||||
--artifact-url "$ARTIFACT_URL" \
|
||||
--run-url "https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
|
||||
--request-source "$REQUEST_SOURCE"
|
||||
|
||||
clear_issue_comment_reaction:
|
||||
name: Clear Mantis command reaction
|
||||
needs: [resolve_request, validate_refs, run_status_reactions]
|
||||
if: ${{ always() && github.event_name == 'issue_comment' && needs.resolve_request.outputs.request_source == 'issue_comment' }}
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Remove workflow eyes reaction
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const commentId = context.payload.comment?.id;
|
||||
if (!commentId) {
|
||||
core.info("No issue comment id found; skipping reaction cleanup.");
|
||||
return;
|
||||
}
|
||||
|
||||
const reactions = await github.paginate(github.rest.reactions.listForIssueComment, {
|
||||
owner,
|
||||
repo,
|
||||
comment_id: commentId,
|
||||
per_page: 100,
|
||||
});
|
||||
const eyes = reactions.filter(
|
||||
(reaction) => reaction.content === "eyes" && reaction.user?.login === "github-actions[bot]",
|
||||
);
|
||||
for (const reaction of eyes) {
|
||||
await github.rest.reactions.deleteForIssueComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: commentId,
|
||||
reaction_id: reaction.id,
|
||||
});
|
||||
core.info(`Removed eyes reaction ${reaction.id} from comment ${commentId}.`);
|
||||
}
|
||||
if (eyes.length === 0) {
|
||||
core.info(`No workflow eyes reaction found on comment ${commentId}.`);
|
||||
}
|
||||
|
||||
@@ -46,17 +46,15 @@ jobs:
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
(
|
||||
contains(github.event.comment.body, '@openclaw-mantis') ||
|
||||
contains(github.event.comment.body, '/openclaw-mantis')
|
||||
contains(github.event.comment.body, '@Mantis') ||
|
||||
contains(github.event.comment.body, '@mantis') ||
|
||||
contains(github.event.comment.body, '/mantis')
|
||||
)
|
||||
)
|
||||
}}
|
||||
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@v8
|
||||
with:
|
||||
script: |
|
||||
@@ -70,18 +68,14 @@ jobs:
|
||||
const permission = data.permission;
|
||||
core.info(`Actor ${context.actor} permission: ${permission}`);
|
||||
if (!allowed.has(permission)) {
|
||||
core.notice(
|
||||
core.setFailed(
|
||||
`Workflow requires write/maintain/admin access. Actor "${context.actor}" has "${permission}".`,
|
||||
);
|
||||
core.setOutput("authorized", "false");
|
||||
return;
|
||||
}
|
||||
core.setOutput("authorized", "true");
|
||||
|
||||
resolve_request:
|
||||
name: Resolve Mantis request
|
||||
needs: authorize_actor
|
||||
if: needs.authorize_actor.outputs.authorized == 'true'
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
outputs:
|
||||
baseline_ref: ${{ steps.resolve.outputs.baseline_ref }}
|
||||
@@ -127,7 +121,7 @@ jobs:
|
||||
|
||||
const normalized = body.toLowerCase();
|
||||
const requested =
|
||||
(normalized.includes("@openclaw-mantis") || normalized.includes("/openclaw-mantis")) &&
|
||||
(normalized.includes("@mantis") || normalized.includes("/mantis")) &&
|
||||
normalized.includes("discord") &&
|
||||
normalized.includes("thread") &&
|
||||
(normalized.includes("attachment") ||
|
||||
@@ -536,7 +530,7 @@ jobs:
|
||||
- name: Upload Mantis thread attachment artifacts
|
||||
id: upload_artifact
|
||||
if: ${{ always() && steps.run_mantis.outputs.output_dir != '' }}
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mantis-discord-thread-attachment-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_mantis.outputs.output_dir }}
|
||||
@@ -595,44 +589,3 @@ jobs:
|
||||
run: |
|
||||
echo "Mantis comparison failed." >&2
|
||||
exit 1
|
||||
|
||||
clear_issue_comment_reaction:
|
||||
name: Clear Mantis command reaction
|
||||
needs: [resolve_request, validate_candidate, run_thread_attachment]
|
||||
if: ${{ always() && github.event_name == 'issue_comment' && needs.resolve_request.outputs.request_source == 'issue_comment' }}
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Remove workflow eyes reaction
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const commentId = context.payload.comment?.id;
|
||||
if (!commentId) {
|
||||
core.info("No issue comment id found; skipping reaction cleanup.");
|
||||
return;
|
||||
}
|
||||
|
||||
const reactions = await github.paginate(github.rest.reactions.listForIssueComment, {
|
||||
owner,
|
||||
repo,
|
||||
comment_id: commentId,
|
||||
per_page: 100,
|
||||
});
|
||||
const eyes = reactions.filter(
|
||||
(reaction) => reaction.content === "eyes" && reaction.user?.login === "github-actions[bot]",
|
||||
);
|
||||
for (const reaction of eyes) {
|
||||
await github.rest.reactions.deleteForIssueComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: commentId,
|
||||
reaction_id: reaction.id,
|
||||
});
|
||||
core.info(`Removed eyes reaction ${reaction.id} from comment ${commentId}.`);
|
||||
}
|
||||
if (eyes.length === 0) {
|
||||
core.info(`No workflow eyes reaction found on comment ${commentId}.`);
|
||||
}
|
||||
|
||||
15
.github/workflows/mantis-slack-desktop-smoke.yml
vendored
15
.github/workflows/mantis-slack-desktop-smoke.yml
vendored
@@ -64,11 +64,8 @@ jobs:
|
||||
authorize_actor:
|
||||
name: Authorize workflow actor
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
authorized: ${{ steps.permission.outputs.authorized }}
|
||||
steps:
|
||||
- name: Require maintainer-level repository access
|
||||
id: permission
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
@@ -82,18 +79,14 @@ jobs:
|
||||
const permission = data.permission;
|
||||
core.info(`Actor ${context.actor} permission: ${permission}`);
|
||||
if (!allowed.has(permission)) {
|
||||
core.notice(
|
||||
core.setFailed(
|
||||
`Workflow requires write/maintain/admin access. Actor "${context.actor}" has "${permission}".`,
|
||||
);
|
||||
core.setOutput("authorized", "false");
|
||||
return;
|
||||
}
|
||||
core.setOutput("authorized", "true");
|
||||
|
||||
validate_ref:
|
||||
name: Validate candidate ref
|
||||
needs: authorize_actor
|
||||
if: needs.authorize_actor.outputs.authorized == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
candidate_revision: ${{ steps.validate.outputs.candidate_revision }}
|
||||
@@ -281,8 +274,8 @@ jobs:
|
||||
--credential-role ci \
|
||||
--provider-mode live-frontier \
|
||||
--hydrate-mode "$HYDRATE_MODE" \
|
||||
--model openai/gpt-5.5 \
|
||||
--alt-model openai/gpt-5.5 \
|
||||
--model openai/gpt-5.4 \
|
||||
--alt-model openai/gpt-5.4 \
|
||||
--fast \
|
||||
--scenario "$SCENARIO_ID" \
|
||||
"${keep_args[@]}" \
|
||||
@@ -359,7 +352,7 @@ jobs:
|
||||
- name: Upload Mantis Slack desktop artifacts
|
||||
id: upload_artifact
|
||||
if: ${{ always() && steps.run_mantis.outputs.output_dir != '' }}
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mantis-slack-desktop-smoke-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_mantis.outputs.output_dir }}
|
||||
|
||||
287
.github/workflows/mantis-telegram-desktop-proof.yml
vendored
287
.github/workflows/mantis-telegram-desktop-proof.yml
vendored
@@ -3,8 +3,6 @@ name: Mantis Telegram Desktop Proof
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers] maintainer-owned Mantis label trigger; trusted base workflow validates refs before checkout/use
|
||||
types: [labeled]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
@@ -27,14 +25,6 @@ on:
|
||||
description: Optional existing Crabbox desktop lease id or slug to reuse
|
||||
required: false
|
||||
type: string
|
||||
publish_artifact_name:
|
||||
description: Optional existing proof artifact name to publish without recapturing
|
||||
required: false
|
||||
type: string
|
||||
publish_run_id:
|
||||
description: Workflow run id that owns publish_artifact_name; required with publish_artifact_name
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -57,11 +47,6 @@ jobs:
|
||||
if: >-
|
||||
${{
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.action == 'labeled' &&
|
||||
github.event.label.name == 'mantis: telegram-visible-proof'
|
||||
) ||
|
||||
(
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
@@ -73,20 +58,11 @@ jobs:
|
||||
)
|
||||
}}
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
authorized: ${{ steps.permission.outputs.authorized }}
|
||||
steps:
|
||||
- name: Require maintainer-level repository access
|
||||
id: permission
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
if (context.eventName === "pull_request_target") {
|
||||
core.info(`Accepted Mantis label trigger from ${context.actor}.`);
|
||||
core.setOutput("authorized", "true");
|
||||
return;
|
||||
}
|
||||
|
||||
const allowed = new Set(["admin", "maintain", "write"]);
|
||||
const { owner, repo } = context.repo;
|
||||
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
@@ -97,18 +73,14 @@ jobs:
|
||||
const permission = data.permission;
|
||||
core.info(`Actor ${context.actor} permission: ${permission}`);
|
||||
if (!allowed.has(permission)) {
|
||||
core.notice(
|
||||
core.setFailed(
|
||||
`Workflow requires write/maintain/admin access. Actor "${context.actor}" has "${permission}".`,
|
||||
);
|
||||
core.setOutput("authorized", "false");
|
||||
return;
|
||||
}
|
||||
core.setOutput("authorized", "true");
|
||||
|
||||
resolve_request:
|
||||
name: Resolve Mantis request
|
||||
needs: authorize_actor
|
||||
if: needs.authorize_actor.outputs.authorized == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
baseline_ref: ${{ steps.resolve.outputs.baseline_ref }}
|
||||
@@ -116,11 +88,8 @@ jobs:
|
||||
crabbox_provider: ${{ steps.resolve.outputs.crabbox_provider }}
|
||||
instructions: ${{ steps.resolve.outputs.instructions }}
|
||||
lease_id: ${{ steps.resolve.outputs.lease_id }}
|
||||
publish_artifact_name: ${{ steps.resolve.outputs.publish_artifact_name }}
|
||||
publish_run_id: ${{ steps.resolve.outputs.publish_run_id }}
|
||||
pr_number: ${{ steps.resolve.outputs.pr_number }}
|
||||
request_source: ${{ steps.resolve.outputs.request_source }}
|
||||
should_run: ${{ steps.resolve.outputs.should_run }}
|
||||
steps:
|
||||
- name: Resolve refs and target PR
|
||||
id: resolve
|
||||
@@ -136,70 +105,31 @@ jobs:
|
||||
|
||||
const inputs = context.payload.inputs ?? {};
|
||||
const prNumber =
|
||||
eventName === "workflow_dispatch"
|
||||
? inputs.pr_number
|
||||
: eventName === "pull_request_target"
|
||||
? String(context.payload.pull_request?.number ?? "")
|
||||
: String(context.payload.issue?.number ?? "");
|
||||
eventName === "workflow_dispatch" ? inputs.pr_number : String(context.payload.issue?.number ?? "");
|
||||
if (!prNumber) {
|
||||
core.setFailed("Mantis Telegram desktop proof requires a pull request.");
|
||||
return;
|
||||
}
|
||||
|
||||
const body =
|
||||
eventName === "workflow_dispatch"
|
||||
? inputs.instructions || ""
|
||||
: eventName === "issue_comment"
|
||||
? context.payload.comment?.body || ""
|
||||
: "";
|
||||
if (eventName === "issue_comment") {
|
||||
const normalized = body.toLowerCase();
|
||||
const requestedDesktopProof =
|
||||
(normalized.includes("@openclaw-mantis") || normalized.includes("/openclaw-mantis")) &&
|
||||
(normalized.includes("desktop proof") ||
|
||||
normalized.includes("desktop-proof") ||
|
||||
normalized.includes("telegram desktop") ||
|
||||
normalized.includes("native telegram") ||
|
||||
normalized.includes("visible proof") ||
|
||||
normalized.includes("visible-proof") ||
|
||||
normalized.includes("telegram-visible-proof"));
|
||||
if (!requestedDesktopProof) {
|
||||
core.notice("Comment mentioned Mantis but did not request Telegram desktop proof.");
|
||||
setOutput("should_run", "false");
|
||||
setOutput("baseline_ref", "");
|
||||
setOutput("candidate_ref", "");
|
||||
setOutput("pr_number", "");
|
||||
setOutput("instructions", "");
|
||||
setOutput("crabbox_provider", "");
|
||||
setOutput("lease_id", "");
|
||||
setOutput("publish_artifact_name", "");
|
||||
setOutput("publish_run_id", "");
|
||||
setOutput("request_source", "unsupported_issue_comment");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const { owner, repo } = context.repo;
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: Number(prNumber),
|
||||
});
|
||||
const body = eventName === "workflow_dispatch" ? inputs.instructions || "" : context.payload.comment?.body || "";
|
||||
const provider = inputs.crabbox_provider || "aws";
|
||||
if (!["aws", "hetzner"].includes(provider)) {
|
||||
core.setFailed(`Unsupported Crabbox provider for Mantis Telegram desktop proof: ${provider}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setOutput("should_run", "true");
|
||||
setOutput("baseline_ref", pr.base.sha);
|
||||
setOutput("candidate_ref", pr.head.sha);
|
||||
setOutput("pr_number", String(pr.number));
|
||||
setOutput("instructions", body);
|
||||
setOutput("crabbox_provider", provider);
|
||||
setOutput("lease_id", inputs.crabbox_lease_id || "");
|
||||
setOutput("publish_artifact_name", inputs.publish_artifact_name || "");
|
||||
setOutput("publish_run_id", inputs.publish_run_id || "");
|
||||
setOutput("request_source", eventName);
|
||||
|
||||
if (eventName === "issue_comment") {
|
||||
@@ -214,7 +144,6 @@ jobs:
|
||||
validate_refs:
|
||||
name: Validate selected refs
|
||||
needs: resolve_request
|
||||
if: needs.resolve_request.outputs.should_run == 'true' && needs.resolve_request.outputs.publish_artifact_name == ''
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
baseline_revision: ${{ steps.validate.outputs.baseline_revision }}
|
||||
@@ -293,7 +222,6 @@ jobs:
|
||||
run_telegram_desktop_proof:
|
||||
name: Run agentic native Telegram proof
|
||||
needs: [resolve_request, validate_refs]
|
||||
if: needs.resolve_request.outputs.should_run == 'true' && needs.resolve_request.outputs.publish_artifact_name == ''
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 360
|
||||
environment: qa-live-shared
|
||||
@@ -308,36 +236,16 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
current_created="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" --jq .created_at)"
|
||||
stale_before="$(date -u -d '8 hours ago' +%Y-%m-%dT%H:%M:%SZ)"
|
||||
run_has_active_jobs() {
|
||||
local run_id="$1"
|
||||
local run_state="$2"
|
||||
if [[ "$run_state" != "in_progress" ]]; then
|
||||
return 0
|
||||
fi
|
||||
local active_jobs
|
||||
active_jobs="$(gh run view "$run_id" --repo "$GITHUB_REPOSITORY" --json jobs --jq '[.jobs[] | select(.status == "queued" or .status == "in_progress" or .status == "waiting" or .status == "pending" or .status == "requested")] | length')"
|
||||
[[ "$active_jobs" != "0" ]]
|
||||
}
|
||||
while true; do
|
||||
candidates="$(
|
||||
blockers="$(
|
||||
for workflow in mantis-telegram-desktop-proof.yml mantis-telegram-live.yml; do
|
||||
for status in queued in_progress waiting pending requested; do
|
||||
gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --status "$status" --limit 100 --json databaseId,status,createdAt,url \
|
||||
| jq -r \
|
||||
--argjson current_id "$GITHUB_RUN_ID" \
|
||||
--arg current_created "$current_created" \
|
||||
--arg stale_before "$stale_before" \
|
||||
'.[] | select(.databaseId != $current_id) | select(.createdAt >= $stale_before) | select(.createdAt < $current_created or (.createdAt == $current_created and .databaseId < $current_id)) | "\(.createdAt)\t#\(.databaseId)\t\(.status)\t\(.url)"'
|
||||
done
|
||||
gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --limit 100 --json databaseId,status,createdAt,url \
|
||||
| jq -r \
|
||||
--argjson current_id "$GITHUB_RUN_ID" \
|
||||
--arg current_created "$current_created" \
|
||||
'.[] | select(.databaseId != $current_id) | select(.createdAt < $current_created or (.createdAt == $current_created and .databaseId < $current_id)) | select(.status == "queued" or .status == "in_progress" or .status == "waiting" or .status == "pending" or .status == "requested") | "\(.createdAt)\t#\(.databaseId)\t\(.status)\t\(.url)"'
|
||||
done | sort -u
|
||||
)"
|
||||
blockers=""
|
||||
while IFS=$'\t' read -r created run_id run_state url; do
|
||||
if [[ -n "$run_id" ]] && run_has_active_jobs "${run_id#\#}" "$run_state"; then
|
||||
blockers+="${created}"$'\t'"${run_id}"$'\t'"${run_state}"$'\t'"${url}"$'\n'
|
||||
fi
|
||||
done <<<"$candidates"
|
||||
if [[ -z "$blockers" ]]; then
|
||||
break
|
||||
fi
|
||||
@@ -426,7 +334,7 @@ jobs:
|
||||
printf '%s\n' 'Defaults env_keep += "BASELINE_REF BASELINE_SHA CANDIDATE_REF CANDIDATE_SHA"'
|
||||
printf '%s\n' 'Defaults env_keep += "CRABBOX_ACCESS_CLIENT_ID CRABBOX_ACCESS_CLIENT_SECRET CRABBOX_COORDINATOR CRABBOX_COORDINATOR_TOKEN CRABBOX_LEASE_ID CRABBOX_PROVIDER"'
|
||||
printf '%s\n' 'Defaults env_keep += "GH_TOKEN MANTIS_CANDIDATE_TRUST MANTIS_INSTRUCTIONS MANTIS_OUTPUT_DIR MANTIS_PR_NUMBER"'
|
||||
printf '%s\n' 'Defaults env_keep += "OPENCLAW_BUILD_PRIVATE_QA OPENCLAW_ENABLE_PRIVATE_QA_CLI OPENCLAW_QA_CONVEX_SECRET_CI OPENCLAW_QA_CONVEX_SITE_URL OPENCLAW_QA_CREDENTIAL_OWNER_ID OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN"'
|
||||
printf '%s\n' 'Defaults env_keep += "OPENCLAW_BUILD_PRIVATE_QA OPENCLAW_ENABLE_PRIVATE_QA_CLI OPENCLAW_QA_CONVEX_SECRET_CI OPENCLAW_QA_CONVEX_SITE_URL OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN"'
|
||||
printf '%s\n' 'Defaults env_keep += "OPENCLAW_TELEGRAM_USER_CRABBOX_BIN OPENCLAW_TELEGRAM_USER_CRABBOX_PROVIDER OPENCLAW_TELEGRAM_USER_DRIVER_SCRIPT OPENCLAW_TELEGRAM_USER_PROOF_CMD"'
|
||||
} | sudo tee /etc/sudoers.d/mantis-codex-env >/dev/null
|
||||
sudo chmod 0440 /etc/sudoers.d/mantis-codex-env
|
||||
@@ -462,7 +370,6 @@ jobs:
|
||||
MANTIS_PR_NUMBER: ${{ needs.resolve_request.outputs.pr_number }}
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
|
||||
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
|
||||
OPENCLAW_QA_CREDENTIAL_OWNER_ID: mantis-telegram-desktop-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR: ${{ secrets.OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR }}
|
||||
OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN: ${{ secrets.OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN }}
|
||||
OPENCLAW_TELEGRAM_USER_CRABBOX_BIN: /usr/local/bin/crabbox
|
||||
@@ -479,48 +386,6 @@ jobs:
|
||||
codex-home: /tmp/mantis-codex-home-${{ github.run_id }}
|
||||
safety-strategy: unprivileged-user
|
||||
codex-user: codex
|
||||
allow-bot-users: clawsweeper[bot]
|
||||
|
||||
- name: Release leaked Telegram proof leases
|
||||
if: ${{ always() }}
|
||||
env:
|
||||
CRABBOX_PROVIDER: ${{ needs.resolve_request.outputs.crabbox_provider }}
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
|
||||
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! -d .artifacts/qa-e2e ]]; then
|
||||
exit 0
|
||||
fi
|
||||
status=0
|
||||
mapfile -d '' session_files < <(sudo find .artifacts/qa-e2e -path '*/telegram-user-crabbox/*/session.json' -type f -print0)
|
||||
for session_file in "${session_files[@]}"; do
|
||||
lease_file="${session_file%/session.json}/.session/lease.json"
|
||||
if [[ ! -f "$lease_file" ]]; then
|
||||
continue
|
||||
fi
|
||||
if ! sudo -u codex env \
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI="$OPENCLAW_QA_CONVEX_SECRET_CI" \
|
||||
OPENCLAW_QA_CONVEX_SITE_URL="$OPENCLAW_QA_CONVEX_SITE_URL" \
|
||||
OPENCLAW_TELEGRAM_USER_CRABBOX_BIN=/usr/local/bin/crabbox \
|
||||
OPENCLAW_TELEGRAM_USER_CRABBOX_PROVIDER="$CRABBOX_PROVIDER" \
|
||||
node --import tsx "$GITHUB_WORKSPACE/scripts/e2e/telegram-user-crabbox-proof.ts" \
|
||||
finish --session "$session_file" --preview-crop telegram-window; then
|
||||
status=1
|
||||
fi
|
||||
done
|
||||
mapfile -d '' lease_files < <(sudo find .artifacts/qa-e2e -path '*/telegram-user-crabbox/*/.session/lease.json' -type f -print0)
|
||||
for lease_file in "${lease_files[@]}"; do
|
||||
if ! sudo -u codex env \
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI="$OPENCLAW_QA_CONVEX_SECRET_CI" \
|
||||
OPENCLAW_QA_CONVEX_SITE_URL="$OPENCLAW_QA_CONVEX_SITE_URL" \
|
||||
node --import tsx "$GITHUB_WORKSPACE/scripts/e2e/telegram-user-credential.ts" \
|
||||
release --lease-file "$lease_file"; then
|
||||
status=1
|
||||
fi
|
||||
done
|
||||
exit "$status"
|
||||
|
||||
- name: Inspect Mantis evidence manifest
|
||||
id: inspect
|
||||
@@ -541,7 +406,7 @@ jobs:
|
||||
- name: Upload Mantis Telegram desktop artifacts
|
||||
id: upload_artifact
|
||||
if: ${{ always() && steps.inspect.outputs.output_dir != '' }}
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mantis-telegram-desktop-proof-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.inspect.outputs.output_dir }}
|
||||
@@ -601,133 +466,3 @@ jobs:
|
||||
run: |
|
||||
echo "Mantis Telegram desktop proof failed: comparison=${COMPARISON_STATUS:-unset}." >&2
|
||||
exit 1
|
||||
|
||||
publish_existing_telegram_desktop_proof:
|
||||
name: Publish existing native Telegram proof
|
||||
needs: resolve_request
|
||||
if: needs.resolve_request.outputs.should_run == 'true' && needs.resolve_request.outputs.publish_artifact_name != ''
|
||||
runs-on: ubuntu-24.04
|
||||
environment: qa-live-shared
|
||||
steps:
|
||||
- name: Checkout harness ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Download existing proof artifact
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PUBLISH_ARTIFACT_NAME: ${{ needs.resolve_request.outputs.publish_artifact_name }}
|
||||
PUBLISH_RUN_ID: ${{ needs.resolve_request.outputs.publish_run_id }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${PUBLISH_RUN_ID:-}" ]]; then
|
||||
echo "publish_run_id is required when publish_artifact_name is set." >&2
|
||||
exit 1
|
||||
fi
|
||||
run_id="$PUBLISH_RUN_ID"
|
||||
gh run download "$run_id" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--name "$PUBLISH_ARTIFACT_NAME" \
|
||||
--dir "$MANTIS_OUTPUT_DIR"
|
||||
|
||||
artifacts_json="$(
|
||||
gh api \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/artifacts"
|
||||
)"
|
||||
artifact_id="$(jq -r --arg name "$PUBLISH_ARTIFACT_NAME" '.artifacts[] | select(.name == $name) | .id' <<<"$artifacts_json" | head -n 1)"
|
||||
if [[ -z "$artifact_id" || "$artifact_id" == "null" ]]; then
|
||||
echo "Could not resolve artifact id for '${PUBLISH_ARTIFACT_NAME}' in run ${run_id}." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "PUBLISH_RUN_ID=${run_id}" >> "$GITHUB_ENV"
|
||||
echo "PUBLISH_ARTIFACT_URL=https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}/artifacts/${artifact_id}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Create Mantis GitHub App token
|
||||
id: mantis_app_token
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
app-id: ${{ secrets.MANTIS_GITHUB_APP_ID }}
|
||||
private-key: ${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: ${{ github.event.repository.name }}
|
||||
permission-issues: write
|
||||
permission-pull-requests: write
|
||||
|
||||
- name: Comment PR with inline QA evidence
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.mantis_app_token.outputs.token }}
|
||||
MANTIS_ARTIFACT_R2_ACCESS_KEY_ID: ${{ secrets.MANTIS_ARTIFACT_R2_ACCESS_KEY_ID }}
|
||||
MANTIS_ARTIFACT_R2_BUCKET: openclaw-crabbox-artifacts
|
||||
MANTIS_ARTIFACT_R2_ENDPOINT: ${{ vars.MANTIS_ARTIFACT_R2_ENDPOINT }}
|
||||
MANTIS_ARTIFACT_R2_PUBLIC_BASE_URL: https://artifacts.openclaw.ai
|
||||
MANTIS_ARTIFACT_R2_REGION: auto
|
||||
MANTIS_ARTIFACT_R2_SECRET_ACCESS_KEY: ${{ secrets.MANTIS_ARTIFACT_R2_SECRET_ACCESS_KEY }}
|
||||
REQUEST_SOURCE: ${{ needs.resolve_request.outputs.request_source }}
|
||||
TARGET_PR: ${{ needs.resolve_request.outputs.pr_number }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
root="$MANTIS_OUTPUT_DIR"
|
||||
if [[ ! -f "$root/mantis-evidence.json" ]]; then
|
||||
echo "Downloaded artifact does not contain ${root}/mantis-evidence.json." >&2
|
||||
exit 1
|
||||
fi
|
||||
node scripts/mantis/publish-pr-evidence.mjs \
|
||||
--manifest "$root/mantis-evidence.json" \
|
||||
--target-pr "$TARGET_PR" \
|
||||
--artifact-root "mantis/telegram-desktop/pr-${TARGET_PR}/published-${PUBLISH_RUN_ID}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \
|
||||
--marker "<!-- mantis-telegram-desktop-proof -->" \
|
||||
--artifact-url "$PUBLISH_ARTIFACT_URL" \
|
||||
--run-url "https://github.com/${GITHUB_REPOSITORY}/actions/runs/${PUBLISH_RUN_ID}" \
|
||||
--request-source "$REQUEST_SOURCE"
|
||||
|
||||
clear_issue_comment_reaction:
|
||||
name: Clear Mantis command reaction
|
||||
needs: [resolve_request, validate_refs, run_telegram_desktop_proof]
|
||||
if: ${{ always() && github.event_name == 'issue_comment' && needs.resolve_request.outputs.request_source == 'issue_comment' }}
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Remove workflow eyes reaction
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const commentId = context.payload.comment?.id;
|
||||
if (!commentId) {
|
||||
core.info("No issue comment id found; skipping reaction cleanup.");
|
||||
return;
|
||||
}
|
||||
|
||||
const reactions = await github.paginate(github.rest.reactions.listForIssueComment, {
|
||||
owner,
|
||||
repo,
|
||||
comment_id: commentId,
|
||||
per_page: 100,
|
||||
});
|
||||
const eyes = reactions.filter(
|
||||
(reaction) => reaction.content === "eyes" && reaction.user?.login === "github-actions[bot]",
|
||||
);
|
||||
for (const reaction of eyes) {
|
||||
await github.rest.reactions.deleteForIssueComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: commentId,
|
||||
reaction_id: reaction.id,
|
||||
});
|
||||
core.info(`Removed eyes reaction ${reaction.id} from comment ${commentId}.`);
|
||||
}
|
||||
if (eyes.length === 0) {
|
||||
core.info(`No workflow eyes reaction found on comment ${commentId}.`);
|
||||
}
|
||||
|
||||
104
.github/workflows/mantis-telegram-live.yml
vendored
104
.github/workflows/mantis-telegram-live.yml
vendored
@@ -56,17 +56,15 @@ jobs:
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
(
|
||||
contains(github.event.comment.body, '@openclaw-mantis') ||
|
||||
contains(github.event.comment.body, '/openclaw-mantis')
|
||||
contains(github.event.comment.body, '@Mantis') ||
|
||||
contains(github.event.comment.body, '@mantis') ||
|
||||
contains(github.event.comment.body, '/mantis')
|
||||
)
|
||||
)
|
||||
}}
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
authorized: ${{ steps.permission.outputs.authorized }}
|
||||
steps:
|
||||
- name: Require maintainer-level repository access
|
||||
id: permission
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
@@ -80,18 +78,14 @@ jobs:
|
||||
const permission = data.permission;
|
||||
core.info(`Actor ${context.actor} permission: ${permission}`);
|
||||
if (!allowed.has(permission)) {
|
||||
core.notice(
|
||||
core.setFailed(
|
||||
`Workflow requires write/maintain/admin access. Actor "${context.actor}" has "${permission}".`,
|
||||
);
|
||||
core.setOutput("authorized", "false");
|
||||
return;
|
||||
}
|
||||
core.setOutput("authorized", "true");
|
||||
|
||||
resolve_request:
|
||||
name: Resolve Mantis request
|
||||
needs: authorize_actor
|
||||
if: needs.authorize_actor.outputs.authorized == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
candidate_ref: ${{ steps.resolve.outputs.candidate_ref }}
|
||||
@@ -139,18 +133,9 @@ jobs:
|
||||
}
|
||||
|
||||
const normalized = body.toLowerCase();
|
||||
const requestedDesktopProof =
|
||||
normalized.includes("desktop proof") ||
|
||||
normalized.includes("desktop-proof") ||
|
||||
normalized.includes("telegram desktop") ||
|
||||
normalized.includes("native telegram") ||
|
||||
normalized.includes("visible proof") ||
|
||||
normalized.includes("visible-proof") ||
|
||||
normalized.includes("telegram-visible-proof");
|
||||
const requested =
|
||||
(normalized.includes("@openclaw-mantis") || normalized.includes("/openclaw-mantis")) &&
|
||||
normalized.includes("telegram") &&
|
||||
!requestedDesktopProof;
|
||||
(normalized.includes("@mantis") || normalized.includes("/mantis")) &&
|
||||
normalized.includes("telegram");
|
||||
if (!requested) {
|
||||
core.notice("Comment mentioned Mantis but did not request Telegram live QA.");
|
||||
setOutput("should_run", "false");
|
||||
@@ -272,36 +257,16 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
current_created="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" --jq .created_at)"
|
||||
stale_before="$(date -u -d '8 hours ago' +%Y-%m-%dT%H:%M:%SZ)"
|
||||
run_has_active_jobs() {
|
||||
local run_id="$1"
|
||||
local run_state="$2"
|
||||
if [[ "$run_state" != "in_progress" ]]; then
|
||||
return 0
|
||||
fi
|
||||
local active_jobs
|
||||
active_jobs="$(gh run view "$run_id" --repo "$GITHUB_REPOSITORY" --json jobs --jq '[.jobs[] | select(.status == "queued" or .status == "in_progress" or .status == "waiting" or .status == "pending" or .status == "requested")] | length')"
|
||||
[[ "$active_jobs" != "0" ]]
|
||||
}
|
||||
while true; do
|
||||
candidates="$(
|
||||
blockers="$(
|
||||
for workflow in mantis-telegram-desktop-proof.yml mantis-telegram-live.yml; do
|
||||
for status in queued in_progress waiting pending requested; do
|
||||
gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --status "$status" --limit 100 --json databaseId,status,createdAt,url \
|
||||
| jq -r \
|
||||
--argjson current_id "$GITHUB_RUN_ID" \
|
||||
--arg current_created "$current_created" \
|
||||
--arg stale_before "$stale_before" \
|
||||
'.[] | select(.databaseId != $current_id) | select(.createdAt >= $stale_before) | select(.createdAt < $current_created or (.createdAt == $current_created and .databaseId < $current_id)) | "\(.createdAt)\t#\(.databaseId)\t\(.status)\t\(.url)"'
|
||||
done
|
||||
gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --limit 100 --json databaseId,status,createdAt,url \
|
||||
| jq -r \
|
||||
--argjson current_id "$GITHUB_RUN_ID" \
|
||||
--arg current_created "$current_created" \
|
||||
'.[] | select(.databaseId != $current_id) | select(.createdAt < $current_created or (.createdAt == $current_created and .databaseId < $current_id)) | select(.status == "queued" or .status == "in_progress" or .status == "waiting" or .status == "pending" or .status == "requested") | "\(.createdAt)\t#\(.databaseId)\t\(.status)\t\(.url)"'
|
||||
done | sort -u
|
||||
)"
|
||||
blockers=""
|
||||
while IFS=$'\t' read -r created run_id run_state url; do
|
||||
if [[ -n "$run_id" ]] && run_has_active_jobs "${run_id#\#}" "$run_state"; then
|
||||
blockers+="${created}"$'\t'"${run_id}"$'\t'"${run_state}"$'\t'"${url}"$'\n'
|
||||
fi
|
||||
done <<<"$candidates"
|
||||
if [[ -z "$blockers" ]]; then
|
||||
break
|
||||
fi
|
||||
@@ -414,7 +379,7 @@ jobs:
|
||||
output_rel=".artifacts/qa-e2e/mantis/telegram-live"
|
||||
root="$candidate_repo/$output_rel"
|
||||
echo "output_dir=${root}" >> "$GITHUB_OUTPUT"
|
||||
model="${OPENCLAW_CI_OPENAI_MODEL:-openai/gpt-5.5}"
|
||||
model="${OPENCLAW_CI_OPENAI_MODEL:-openai/gpt-5.4}"
|
||||
|
||||
scenario_args=()
|
||||
if [[ -n "${SCENARIO_INPUT// }" ]]; then
|
||||
@@ -499,7 +464,7 @@ jobs:
|
||||
- name: Upload Mantis Telegram artifacts
|
||||
id: upload_artifact
|
||||
if: ${{ always() && steps.run_mantis.outputs.output_dir != '' }}
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mantis-telegram-live-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_mantis.outputs.output_dir }}
|
||||
@@ -560,44 +525,3 @@ jobs:
|
||||
run: |
|
||||
echo "Mantis Telegram live failed: comparison=${COMPARISON_STATUS:-unset} telegram_exit=${TELEGRAM_EXIT:-unset}." >&2
|
||||
exit 1
|
||||
|
||||
clear_issue_comment_reaction:
|
||||
name: Clear Mantis command reaction
|
||||
needs: [resolve_request, validate_ref, run_telegram_live]
|
||||
if: ${{ always() && github.event_name == 'issue_comment' && needs.resolve_request.outputs.request_source == 'issue_comment' }}
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Remove workflow eyes reaction
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const commentId = context.payload.comment?.id;
|
||||
if (!commentId) {
|
||||
core.info("No issue comment id found; skipping reaction cleanup.");
|
||||
return;
|
||||
}
|
||||
|
||||
const reactions = await github.paginate(github.rest.reactions.listForIssueComment, {
|
||||
owner,
|
||||
repo,
|
||||
comment_id: commentId,
|
||||
per_page: 100,
|
||||
});
|
||||
const eyes = reactions.filter(
|
||||
(reaction) => reaction.content === "eyes" && reaction.user?.login === "github-actions[bot]",
|
||||
);
|
||||
for (const reaction of eyes) {
|
||||
await github.rest.reactions.deleteForIssueComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: commentId,
|
||||
reaction_id: reaction.id,
|
||||
});
|
||||
core.info(`Removed eyes reaction ${reaction.id} from comment ${commentId}.`);
|
||||
}
|
||||
if (eyes.length === 0) {
|
||||
core.info(`No workflow eyes reaction found on comment ${commentId}.`);
|
||||
}
|
||||
|
||||
13
.github/workflows/npm-telegram-beta-e2e.yml
vendored
13
.github/workflows/npm-telegram-beta-e2e.yml
vendored
@@ -40,18 +40,8 @@ on:
|
||||
description: Optional comma-separated Telegram scenario ids
|
||||
required: false
|
||||
type: string
|
||||
advisory:
|
||||
description: Treat package Telegram failures as advisory for the caller
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
workflow_call:
|
||||
inputs:
|
||||
advisory:
|
||||
description: Treat package Telegram failures as advisory for the caller
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
package_spec:
|
||||
description: Published OpenClaw package spec to test when no artifact is supplied
|
||||
required: true
|
||||
@@ -110,7 +100,6 @@ jobs:
|
||||
run_package_telegram_e2e:
|
||||
name: Run package Telegram E2E
|
||||
runs-on: blacksmith-32vcpu-ubuntu-2404
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
timeout-minutes: 60
|
||||
environment: qa-live-shared
|
||||
permissions:
|
||||
@@ -270,7 +259,7 @@ jobs:
|
||||
|
||||
- name: Upload npm Telegram E2E artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: npm-telegram-beta-e2e-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: .artifacts/qa-e2e/
|
||||
|
||||
@@ -86,18 +86,8 @@ on:
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
advisory:
|
||||
description: Treat failures as advisory for the caller
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
workflow_call:
|
||||
inputs:
|
||||
advisory:
|
||||
description: Treat failures as advisory for the caller
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
ref:
|
||||
description: Public OpenClaw ref to validate (tag, branch, or full commit SHA)
|
||||
required: true
|
||||
@@ -196,12 +186,11 @@ env:
|
||||
PNPM_VERSION: "11.0.8"
|
||||
OPENCLAW_REPOSITORY: openclaw/openclaw
|
||||
TSX_VERSION: "4.21.0"
|
||||
OPENCLAW_CROSS_OS_OPENAI_MODEL: ${{ inputs.openai_model || vars.OPENCLAW_CROSS_OS_OPENAI_MODEL || 'openai/gpt-5.5' }}
|
||||
OPENCLAW_CROSS_OS_OPENAI_MODEL: ${{ inputs.openai_model || vars.OPENCLAW_CROSS_OS_OPENAI_MODEL || 'openai/gpt-5.4' }}
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
runs-on: ubuntu-24.04
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
outputs:
|
||||
baseline_file_name: ${{ steps.baseline_metadata.outputs.file_name }}
|
||||
baseline_spec: ${{ steps.baseline.outputs.value }}
|
||||
@@ -524,7 +513,6 @@ jobs:
|
||||
cross_os_release_checks:
|
||||
name: "${{ matrix.display_name }} / ${{ matrix.suite_label }}"
|
||||
needs: prepare
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.prepare.outputs.matrix) }}
|
||||
|
||||
@@ -68,11 +68,6 @@ on:
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
codex_plugin_spec:
|
||||
description: Optional Codex plugin install spec for the live package lane; blank packs extensions/codex from the selected ref
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
include_live_suites:
|
||||
description: Whether to run live-provider coverage
|
||||
required: false
|
||||
@@ -102,18 +97,8 @@ on:
|
||||
- beta
|
||||
- stable
|
||||
- full
|
||||
advisory:
|
||||
description: Treat failures as advisory for the caller
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
workflow_call:
|
||||
inputs:
|
||||
advisory:
|
||||
description: Treat failures as advisory for the caller
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
ref:
|
||||
description: Ref, tag, or SHA to validate
|
||||
required: true
|
||||
@@ -178,11 +163,6 @@ on:
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
codex_plugin_spec:
|
||||
description: Optional Codex plugin install spec for the live package lane; blank packs extensions/codex from the selected ref
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
include_live_suites:
|
||||
description: Whether to run live-provider coverage
|
||||
required: false
|
||||
@@ -331,6 +311,9 @@ jobs:
|
||||
set -euo pipefail
|
||||
trusted_reason=""
|
||||
|
||||
git fetch --no-tags origin '+refs/heads/*:refs/remotes/origin/*'
|
||||
git fetch --tags origin '+refs/tags/*:refs/tags/*'
|
||||
|
||||
# Resolve here instead of in actions/checkout so short SHAs work too.
|
||||
if ! selected_sha="$(git rev-parse --verify "${INPUT_REF}^{commit}")"; then
|
||||
echo "Ref '${INPUT_REF}' could not be resolved to a commit." >&2
|
||||
@@ -472,7 +455,6 @@ jobs:
|
||||
validate_release_live_cache:
|
||||
needs: validate_selected_ref
|
||||
if: inputs.include_live_suites && !inputs.live_models_only && (inputs.live_suite_filter == '' || inputs.live_suite_filter == 'live-cache')
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
@@ -523,7 +505,6 @@ jobs:
|
||||
validate_repo_e2e:
|
||||
needs: validate_selected_ref
|
||||
if: inputs.include_repo_e2e && inputs.live_suite_filter == ''
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: ${{ inputs.release_test_profile == 'full' && 90 || 60 }}
|
||||
env:
|
||||
@@ -553,7 +534,6 @@ jobs:
|
||||
validate_special_e2e:
|
||||
needs: validate_selected_ref
|
||||
if: inputs.include_repo_e2e && (inputs.live_suite_filter == '' || inputs.live_suite_filter == 'openshell-e2e')
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: ${{ matrix.timeout_minutes }}
|
||||
strategy:
|
||||
@@ -628,7 +608,6 @@ jobs:
|
||||
needs: [validate_selected_ref, prepare_docker_e2e_image]
|
||||
if: inputs.include_release_path_suites && inputs.docker_lanes == ''
|
||||
name: Docker E2E (${{ matrix.label }})
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: ${{ matrix.timeout_minutes }}
|
||||
strategy:
|
||||
@@ -641,7 +620,7 @@ jobs:
|
||||
profiles: stable full
|
||||
- chunk_id: package-update-openai
|
||||
label: package/update OpenAI install
|
||||
timeout_minutes: 45
|
||||
timeout_minutes: 20
|
||||
profiles: beta minimum stable full
|
||||
- chunk_id: package-update-anthropic
|
||||
label: package/update Anthropic install
|
||||
@@ -744,7 +723,6 @@ jobs:
|
||||
OPENCLAW_DOCKER_E2E_REPO_ROOT: ${{ github.workspace }}
|
||||
OPENCLAW_DOCKER_E2E_SELECTED_SHA: ${{ needs.validate_selected_ref.outputs.selected_sha }}
|
||||
OPENCLAW_DOCKER_ALL_RELEASE_PROFILE: ${{ inputs.release_test_profile }}
|
||||
OPENCLAW_CODEX_NPM_PLUGIN_SPEC: ${{ inputs.codex_plugin_spec }}
|
||||
OPENCLAW_CURRENT_PACKAGE_TGZ: .artifacts/docker-e2e-package/openclaw-current.tgz
|
||||
OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC: ${{ inputs.published_upgrade_survivor_baseline }}
|
||||
OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS: ${{ inputs.published_upgrade_survivor_baselines }}
|
||||
@@ -898,7 +876,6 @@ jobs:
|
||||
plan_docker_lane_groups:
|
||||
needs: validate_selected_ref
|
||||
if: inputs.docker_lanes != ''
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-4vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
@@ -926,7 +903,6 @@ jobs:
|
||||
needs: [validate_selected_ref, prepare_docker_e2e_image, plan_docker_lane_groups]
|
||||
if: inputs.docker_lanes != ''
|
||||
name: Docker E2E targeted lanes (${{ matrix.group.label }})
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
@@ -985,7 +961,6 @@ jobs:
|
||||
OPENCLAW_DOCKER_E2E_PACKAGE_ARTIFACT_NAME: ${{ inputs.package_artifact_name || 'docker-e2e-package' }}
|
||||
OPENCLAW_DOCKER_E2E_REPO_ROOT: ${{ github.workspace }}
|
||||
OPENCLAW_DOCKER_E2E_SELECTED_SHA: ${{ needs.validate_selected_ref.outputs.selected_sha }}
|
||||
OPENCLAW_CODEX_NPM_PLUGIN_SPEC: ${{ inputs.codex_plugin_spec }}
|
||||
OPENCLAW_CURRENT_PACKAGE_TGZ: .artifacts/docker-e2e-package/openclaw-current.tgz
|
||||
OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC: ${{ inputs.published_upgrade_survivor_baseline }}
|
||||
OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS: ${{ matrix.group.published_upgrade_survivor_baselines || inputs.published_upgrade_survivor_baselines }}
|
||||
@@ -1137,7 +1112,6 @@ jobs:
|
||||
needs: [validate_selected_ref, prepare_docker_e2e_image]
|
||||
if: inputs.include_openwebui && !inputs.include_release_path_suites && inputs.docker_lanes == ''
|
||||
name: Docker E2E (openwebui)
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
@@ -1265,7 +1239,6 @@ jobs:
|
||||
prepare_docker_e2e_image:
|
||||
needs: validate_selected_ref
|
||||
if: inputs.include_release_path_suites || inputs.include_openwebui || inputs.docker_lanes != ''
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: ${{ inputs.release_test_profile == 'full' && 90 || 60 }}
|
||||
permissions:
|
||||
@@ -1510,7 +1483,6 @@ jobs:
|
||||
prepare_live_test_image:
|
||||
needs: validate_selected_ref
|
||||
if: inputs.include_live_suites && (inputs.live_suite_filter == '' || startsWith(inputs.live_suite_filter, 'live-') || startsWith(inputs.live_suite_filter, 'docker-live-models'))
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
@@ -1584,7 +1556,6 @@ jobs:
|
||||
name: Docker live models (${{ matrix.provider_label }})
|
||||
needs: [validate_selected_ref, prepare_live_test_image]
|
||||
if: inputs.include_live_suites && inputs.live_model_providers == '' && (inputs.live_suite_filter == '' || inputs.live_suite_filter == 'docker-live-models')
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
@@ -1737,7 +1708,6 @@ jobs:
|
||||
name: Docker live models (selected providers)
|
||||
needs: [validate_selected_ref, prepare_live_test_image]
|
||||
if: inputs.include_live_suites && inputs.live_model_providers != '' && (inputs.live_suite_filter == '' || inputs.live_suite_filter == 'docker-live-models')
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
@@ -1913,7 +1883,6 @@ jobs:
|
||||
validate_live_provider_suites:
|
||||
needs: validate_selected_ref
|
||||
if: inputs.include_live_suites && !inputs.live_models_only && (inputs.live_suite_filter == '' || (startsWith(inputs.live_suite_filter, 'native-live-') && !startsWith(inputs.live_suite_filter, 'native-live-extensions-media') && inputs.live_suite_filter != 'native-live-extensions-a-k'))
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: ${{ matrix.timeout_minutes }}
|
||||
strategy:
|
||||
@@ -1942,7 +1911,7 @@ jobs:
|
||||
- suite_id: native-live-src-gateway-profiles-anthropic-opus
|
||||
suite_group: native-live-src-gateway-profiles-anthropic
|
||||
label: Native live gateway profiles Anthropic Opus
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=anthropic OPENCLAW_LIVE_GATEWAY_MODELS=anthropic/claude-opus-4-7 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=anthropic OPENCLAW_LIVE_GATEWAY_MODELS=anthropic/claude-opus-4-7,anthropic/claude-opus-4-6 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
|
||||
timeout_minutes: 30
|
||||
profile_env_only: false
|
||||
advisory: true
|
||||
@@ -2235,7 +2204,6 @@ jobs:
|
||||
name: Docker live suites (${{ matrix.label }})
|
||||
needs: [validate_selected_ref, prepare_live_test_image]
|
||||
if: inputs.include_live_suites && !inputs.live_models_only && (inputs.live_suite_filter == '' || startsWith(inputs.live_suite_filter, 'live-'))
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: ${{ matrix.timeout_minutes }}
|
||||
strategy:
|
||||
@@ -2455,7 +2423,6 @@ jobs:
|
||||
name: Live media suites (${{ matrix.label }})
|
||||
needs: validate_selected_ref
|
||||
if: inputs.include_live_suites && !inputs.live_models_only && (inputs.live_suite_filter == '' || startsWith(inputs.live_suite_filter, 'native-live-extensions-media') || inputs.live_suite_filter == 'native-live-extensions-a-k')
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }}
|
||||
container:
|
||||
image: ghcr.io/openclaw/openclaw-live-media-runner:ubuntu-24.04
|
||||
|
||||
172
.github/workflows/openclaw-npm-release.yml
vendored
172
.github/workflows/openclaw-npm-release.yml
vendored
@@ -20,10 +20,6 @@ on:
|
||||
description: Successful Full Release Validation run id for this tag/SHA, required for real publish
|
||||
required: false
|
||||
type: string
|
||||
release_publish_run_id:
|
||||
description: Approved OpenClaw Release Publish workflow run id
|
||||
required: false
|
||||
type: string
|
||||
npm_dist_tag:
|
||||
description: npm dist-tag to publish to
|
||||
required: true
|
||||
@@ -36,7 +32,7 @@ on:
|
||||
|
||||
concurrency:
|
||||
group: openclaw-npm-release-${{ github.event_name == 'workflow_dispatch' && format('{0}-{1}', inputs.tag, inputs.npm_dist_tag) || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'workflow_dispatch' && inputs.preflight_only && inputs.npm_dist_tag == 'alpha' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
@@ -92,28 +88,6 @@ jobs:
|
||||
ref: ${{ inputs.tag }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate Tideclaw alpha preflight target
|
||||
if: startsWith(github.ref, 'refs/heads/tideclaw/alpha/')
|
||||
env:
|
||||
RELEASE_REF: ${{ inputs.tag }}
|
||||
WORKFLOW_REF: ${{ github.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "${RELEASE_REF}" == *"-alpha."* && ! "${RELEASE_REF}" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||
echo "Tideclaw alpha preflight runs must target an alpha prerelease tag or SHA." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
echo "Tideclaw alpha preflight runs must run from tideclaw/alpha/YYYY-MM-DD-HHMMZ." >&2
|
||||
exit 1
|
||||
fi
|
||||
alpha_branch="${WORKFLOW_REF#refs/heads/}"
|
||||
git fetch --no-tags origin "+refs/heads/${alpha_branch}:refs/remotes/origin/${alpha_branch}"
|
||||
if ! git merge-base --is-ancestor HEAD "refs/remotes/origin/${alpha_branch}"; then
|
||||
echo "Alpha preflight target must be reachable from ${alpha_branch}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
@@ -217,7 +191,7 @@ jobs:
|
||||
id: packed_tarball
|
||||
env:
|
||||
OPENCLAW_PREPACK_PREPARED: "1"
|
||||
RELEASE_REF: ${{ inputs.tag }}
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
|
||||
DEPENDENCY_EVIDENCE_DIR: ${{ steps.dependency_evidence.outputs.dir }}
|
||||
run: |
|
||||
@@ -285,11 +259,6 @@ jobs:
|
||||
fi
|
||||
RELEASE_SHA="$(git rev-parse HEAD)"
|
||||
PACKAGE_VERSION="$(node -p "require('./package.json').version")"
|
||||
if [[ "${RELEASE_REF}" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||
RELEASE_TAG="v${PACKAGE_VERSION}"
|
||||
else
|
||||
RELEASE_TAG="${RELEASE_REF}"
|
||||
fi
|
||||
TARBALL_NAME="$(basename "$PACK_PATH")"
|
||||
TARBALL_SHA256="$(sha256sum "$PACK_PATH" | awk '{print $1}')"
|
||||
ARTIFACT_DIR="$RUNNER_TEMP/openclaw-npm-preflight"
|
||||
@@ -321,7 +290,6 @@ jobs:
|
||||
);
|
||||
NODE
|
||||
echo "dir=$ARTIFACT_DIR" >> "$GITHUB_OUTPUT"
|
||||
echo "release_tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify prepared npm tarball install
|
||||
env:
|
||||
@@ -344,14 +312,6 @@ jobs:
|
||||
path: ${{ steps.dependency_evidence.outputs.dir }}
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload dependency release evidence tag alias
|
||||
if: ${{ steps.packed_tarball.outputs.release_tag != inputs.tag }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: openclaw-release-dependency-evidence-${{ steps.packed_tarball.outputs.release_tag }}
|
||||
path: ${{ steps.dependency_evidence.outputs.dir }}
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload prepared npm publish bundle
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@@ -359,34 +319,19 @@ jobs:
|
||||
path: ${{ steps.packed_tarball.outputs.dir }}
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload prepared npm publish bundle tag alias
|
||||
if: ${{ steps.packed_tarball.outputs.release_tag != inputs.tag }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: openclaw-npm-preflight-${{ steps.packed_tarball.outputs.release_tag }}
|
||||
path: ${{ steps.packed_tarball.outputs.dir }}
|
||||
if-no-files-found: error
|
||||
|
||||
validate_publish_request:
|
||||
if: ${{ !inputs.preflight_only }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Require trusted workflow ref for publish
|
||||
- name: Require main or release workflow ref for publish
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
|
||||
WORKFLOW_REF: ${{ github.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tideclaw_alpha_publish=false
|
||||
if [[ "${RELEASE_TAG}" == *"-alpha."* && "${RELEASE_NPM_DIST_TAG}" == "alpha" && "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
tideclaw_alpha_publish=true
|
||||
fi
|
||||
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]] && [[ "${tideclaw_alpha_publish}" != "true" ]]; then
|
||||
echo "Real publish runs must be dispatched from main, release/YYYY.M.D, or a Tideclaw alpha branch for alpha prereleases. Use preflight_only=true for other branch validation."
|
||||
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]]; then
|
||||
echo "Real publish runs must be dispatched from main or release/YYYY.M.D. Use preflight_only=true for other branch validation."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -394,7 +339,6 @@ jobs:
|
||||
env:
|
||||
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
|
||||
FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }}
|
||||
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${PREFLIGHT_RUN_ID}" ]]; then
|
||||
@@ -405,32 +349,6 @@ jobs:
|
||||
echo "Real publish requires full_release_validation_run_id from a successful Full Release Validation run." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" && "${GITHUB_ACTOR}" == "github-actions[bot]" ]]; then
|
||||
echo "Workflow-dispatched real publish requires release_publish_run_id from the approved OpenClaw Release Publish workflow." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate release publish approval run
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
|
||||
EXPECTED_WORKFLOW_BRANCH: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" ]]; then
|
||||
if [[ "${GITHUB_ACTOR}" == "github-actions[bot]" ]]; then
|
||||
echo "OpenClaw npm publish dispatched by another workflow must include release_publish_run_id." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Direct OpenClaw npm publish; relying on this workflow's npm-release environment approval."
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${GITHUB_ACTOR}" != "github-actions[bot]" ]]; then
|
||||
echo "OpenClaw npm publish must be dispatched by the OpenClaw Release Publish workflow, not directly by ${GITHUB_ACTOR}." >&2
|
||||
exit 1
|
||||
fi
|
||||
RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
|
||||
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "OpenClaw Release Publish"], ["headBranch", process.env.EXPECTED_WORKFLOW_BRANCH], ["event", "workflow_dispatch"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } if (run.status !== "in_progress") { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must still be in_progress, got ${run.status ?? "<missing>"}.`); process.exit(1); } if (run.conclusion) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} already concluded ${run.conclusion}.`); process.exit(1); } console.log(`Using release publish approval run ${process.env.RELEASE_PUBLISH_RUN_ID}: ${run.url}`);'
|
||||
|
||||
publish_openclaw_npm:
|
||||
# KEEP THE REAL RELEASE/PUBLISH PATH ON A GITHUB-HOSTED RUNNER.
|
||||
@@ -469,28 +387,6 @@ jobs:
|
||||
ref: refs/tags/${{ inputs.tag }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate Tideclaw alpha publish target
|
||||
if: startsWith(github.ref, 'refs/heads/tideclaw/alpha/')
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
WORKFLOW_REF: ${{ github.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "${RELEASE_TAG}" == *"-alpha."* ]]; then
|
||||
echo "Tideclaw alpha publish runs must target an alpha prerelease tag." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
echo "Tideclaw alpha publish runs must run from tideclaw/alpha/YYYY-MM-DD-HHMMZ." >&2
|
||||
exit 1
|
||||
fi
|
||||
alpha_branch="${WORKFLOW_REF#refs/heads/}"
|
||||
git fetch --no-tags origin "+refs/heads/${alpha_branch}:refs/remotes/origin/${alpha_branch}"
|
||||
if ! git merge-base --is-ancestor HEAD "refs/remotes/origin/${alpha_branch}"; then
|
||||
echo "Alpha publish tag must be reachable from ${alpha_branch}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
@@ -531,57 +427,13 @@ jobs:
|
||||
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "Full Release Validation"], ["headBranch", process.env.EXPECTED_WORKFLOW_BRANCH], ["event", "workflow_dispatch"], ["status", "completed"], ["conclusion", "success"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced full release validation run ${process.env.FULL_RELEASE_VALIDATION_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } console.log(`Using full release validation run ${process.env.FULL_RELEASE_VALIDATION_RUN_ID}: ${run.url}`);'
|
||||
|
||||
- name: Download prepared npm tarball
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
download_preflight_artifact() {
|
||||
local preferred_name fallback_name
|
||||
preferred_name="openclaw-npm-preflight-${RELEASE_TAG}"
|
||||
rm -rf preflight-tarball
|
||||
mkdir -p preflight-tarball
|
||||
|
||||
download_named_artifact() {
|
||||
local artifact_name="$1"
|
||||
for attempt in 1 2 3; do
|
||||
if gh run download "${PREFLIGHT_RUN_ID}" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--name "${artifact_name}" \
|
||||
--dir preflight-tarball; then
|
||||
return 0
|
||||
fi
|
||||
if [[ "$attempt" != "3" ]]; then
|
||||
echo "::warning::Artifact download for ${artifact_name} failed on attempt ${attempt}; retrying."
|
||||
sleep $((attempt * 10))
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
if download_named_artifact "${preferred_name}"; then
|
||||
echo "Downloaded ${preferred_name}."
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "::warning::${preferred_name} not found; checking run artifacts for a single compatible preflight artifact."
|
||||
mapfile -t matches < <(gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/runs/${PREFLIGHT_RUN_ID}/artifacts" \
|
||||
--jq '.artifacts[] | select(.expired != true) | .name' |
|
||||
grep '^openclaw-npm-preflight-' || true)
|
||||
if [[ "${#matches[@]}" != "1" ]]; then
|
||||
echo "Expected ${preferred_name}, or exactly one openclaw-npm-preflight-* fallback artifact in run ${PREFLIGHT_RUN_ID}." >&2
|
||||
printf 'Available preflight candidates:\n' >&2
|
||||
printf -- '- %s\n' "${matches[@]:-<none>}" >&2
|
||||
exit 1
|
||||
fi
|
||||
fallback_name="${matches[0]}"
|
||||
download_named_artifact "${fallback_name}"
|
||||
echo "Downloaded fallback preflight artifact ${fallback_name}."
|
||||
}
|
||||
|
||||
download_preflight_artifact
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: openclaw-npm-preflight-${{ inputs.tag }}
|
||||
path: preflight-tarball
|
||||
repository: ${{ github.repository }}
|
||||
run-id: ${{ inputs.preflight_run_id }}
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
- name: Download full release validation manifest
|
||||
uses: actions/download-artifact@v8
|
||||
|
||||
29
.github/workflows/openclaw-performance.yml
vendored
29
.github/workflows/openclaw-performance.yml
vendored
@@ -30,8 +30,8 @@ on:
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
live_openai_candidate:
|
||||
description: Run the live OpenAI GPT 5.5 agent-turn lane
|
||||
live_gpt54:
|
||||
description: Run the live OpenAI GPT 5.4 agent-turn lane
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
@@ -57,7 +57,7 @@ env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
OCM_VERSION: v0.2.15
|
||||
KOVA_REPOSITORY: openclaw/Kova
|
||||
PERFORMANCE_MODEL_ID: gpt-5.5
|
||||
PERFORMANCE_MODEL_ID: gpt-5.4
|
||||
|
||||
jobs:
|
||||
kova:
|
||||
@@ -82,8 +82,8 @@ jobs:
|
||||
deep_profile: "true"
|
||||
live: "false"
|
||||
include_filters: "scenario:fresh-install scenario:gateway-performance scenario:agent-cold-warm-message"
|
||||
- lane: live-openai-candidate
|
||||
title: Kova live OpenAI GPT 5.5 agent turn
|
||||
- lane: live-gpt54
|
||||
title: Kova live OpenAI GPT 5.4 agent turn
|
||||
auth: live
|
||||
repeat: "1"
|
||||
deep_profile: "false"
|
||||
@@ -119,9 +119,9 @@ jobs:
|
||||
run_lane=false
|
||||
reason="deep_profile input is false"
|
||||
fi
|
||||
if [[ "$LANE_ID" == "live-openai-candidate" && "${{ github.event_name }}" != "schedule" && "${{ inputs.live_openai_candidate || 'false' }}" != "true" ]]; then
|
||||
if [[ "$LANE_ID" == "live-gpt54" && "${{ github.event_name }}" != "schedule" && "${{ inputs.live_gpt54 || 'false' }}" != "true" ]]; then
|
||||
run_lane=false
|
||||
reason="live_openai_candidate input is false"
|
||||
reason="live_gpt54 input is false"
|
||||
fi
|
||||
echo "run=$run_lane" >> "$GITHUB_OUTPUT"
|
||||
if [[ "$run_lane" != "true" ]]; then
|
||||
@@ -200,7 +200,7 @@ jobs:
|
||||
chmod 0755 "$HOME/.local/bin/kova"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Pin Kova OpenAI model to GPT 5.5
|
||||
- name: Pin Kova OpenAI model to GPT 5.4
|
||||
if: steps.lane.outputs.run == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -244,7 +244,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${OPENAI_API_KEY:-}" ]]; then
|
||||
echo "OPENAI_API_KEY is not configured; live GPT 5.5 lane will be skipped." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "OPENAI_API_KEY is not configured; live GPT 5.4 lane will be skipped." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
kova setup --ci --json
|
||||
@@ -468,7 +468,7 @@ jobs:
|
||||
|
||||
- name: Upload Kova artifacts
|
||||
if: ${{ always() && steps.lane.outputs.run == 'true' }}
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: openclaw-performance-${{ matrix.lane }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
@@ -561,14 +561,7 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$attempt" == "5" ]]; then
|
||||
{
|
||||
echo "### Clawgrit report publish skipped"
|
||||
echo
|
||||
echo "Kova artifacts were uploaded, but publishing the optional clawgrit report failed after ${attempt} attempts."
|
||||
echo "Check the \`CLAWGRIT_REPORTS_TOKEN\` secret or the reports repository permissions."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "::warning::Kova artifacts uploaded, but optional clawgrit report publish failed after ${attempt} attempts."
|
||||
exit 0
|
||||
exit 1
|
||||
fi
|
||||
sleep $((attempt * 2))
|
||||
git -C "$reports_root" fetch --depth=1 origin main
|
||||
|
||||
285
.github/workflows/openclaw-release-checks.yml
vendored
285
.github/workflows/openclaw-release-checks.yml
vendored
@@ -78,15 +78,10 @@ on:
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
codex_plugin_spec:
|
||||
description: Optional Codex plugin install spec for live Docker package checks; blank derives from release_package_spec or packs the selected ref
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: openclaw-release-checks-${{ inputs.expected_sha || inputs.ref }}-${{ inputs.rerun_group }}
|
||||
cancel-in-progress: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
@@ -117,23 +112,14 @@ jobs:
|
||||
qa_live_slack_enabled: ${{ steps.inputs.outputs.qa_live_slack_enabled }}
|
||||
release_package_spec: ${{ steps.inputs.outputs.release_package_spec }}
|
||||
package_acceptance_package_spec: ${{ steps.inputs.outputs.package_acceptance_package_spec }}
|
||||
codex_plugin_spec: ${{ steps.inputs.outputs.codex_plugin_spec }}
|
||||
steps:
|
||||
- name: Require trusted workflow ref for release checks
|
||||
- name: Require main or release workflow ref for release checks
|
||||
env:
|
||||
RELEASE_REF: ${{ inputs.ref }}
|
||||
WORKFLOW_REF: ${{ github.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tideclaw_alpha_check=false
|
||||
if [[ "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
workflow_branch="${WORKFLOW_REF#refs/heads/}"
|
||||
if [[ "${RELEASE_REF}" == *"-alpha."* || "${RELEASE_REF}" =~ ^[0-9a-fA-F]{40}$ || "${RELEASE_REF}" == "${workflow_branch}" || "${RELEASE_REF}" == "refs/heads/${workflow_branch}" ]]; then
|
||||
tideclaw_alpha_check=true
|
||||
fi
|
||||
fi
|
||||
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release-ci/[0-9a-f]{12}-[0-9]+$ ]] && [[ "${tideclaw_alpha_check}" != "true" ]]; then
|
||||
echo "Release checks must be dispatched from main, release/YYYY.M.D, a Full Release Validation release-ci/<sha>-<timestamp> ref, or a Tideclaw alpha branch for alpha prereleases." >&2
|
||||
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release-ci/[0-9a-f]{12}-[0-9]+$ ]]; then
|
||||
echo "Release checks must be dispatched from main, release/YYYY.M.D, or a Full Release Validation release-ci/<sha>-<timestamp> ref so workflow logic and secrets stay controlled." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -233,25 +219,6 @@ jobs:
|
||||
fi
|
||||
echo "sha=${selected_sha,,}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate Tideclaw alpha target matches workflow branch
|
||||
if: startsWith(github.ref, 'refs/heads/tideclaw/alpha/')
|
||||
working-directory: workflow
|
||||
env:
|
||||
SELECTED_SHA: ${{ steps.ref.outputs.sha }}
|
||||
WORKFLOW_REF: ${{ github.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
echo "Tideclaw alpha release checks must run from tideclaw/alpha/YYYY-MM-DD-HHMMZ." >&2
|
||||
exit 1
|
||||
fi
|
||||
alpha_branch="${WORKFLOW_REF#refs/heads/}"
|
||||
git fetch --no-tags origin "+refs/heads/${alpha_branch}:refs/remotes/origin/${alpha_branch}"
|
||||
if ! git merge-base --is-ancestor "${SELECTED_SHA}" "refs/remotes/origin/${alpha_branch}"; then
|
||||
echo "Alpha release target ${SELECTED_SHA} must be reachable from ${alpha_branch}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Capture selected inputs
|
||||
id: inputs
|
||||
env:
|
||||
@@ -268,7 +235,6 @@ jobs:
|
||||
RELEASE_QA_SLACK_LIVE_CI_ENABLED: ${{ vars.OPENCLAW_RELEASE_QA_SLACK_LIVE_CI_ENABLED || 'false' }}
|
||||
RELEASE_PACKAGE_SPEC_INPUT: ${{ inputs.release_package_spec }}
|
||||
RELEASE_PACKAGE_ACCEPTANCE_PACKAGE_SPEC_INPUT: ${{ inputs.package_acceptance_package_spec }}
|
||||
RELEASE_CODEX_PLUGIN_SPEC_INPUT: ${{ inputs.codex_plugin_spec }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
qa_live_matrix_enabled=true
|
||||
@@ -314,10 +280,6 @@ jobs:
|
||||
if [[ "$release_profile" == "full" ]]; then
|
||||
run_release_soak=true
|
||||
fi
|
||||
codex_plugin_spec="$RELEASE_CODEX_PLUGIN_SPEC_INPUT"
|
||||
if [[ -z "${codex_plugin_spec// }" && "$RELEASE_PACKAGE_SPEC_INPUT" =~ ^openclaw@(.+)$ ]]; then
|
||||
codex_plugin_spec="npm:@openclaw/codex@${BASH_REMATCH[1]}"
|
||||
fi
|
||||
|
||||
filter="$(printf '%s' "$RELEASE_LIVE_SUITE_FILTER_INPUT" | tr '[:upper:]' '[:lower:]')"
|
||||
if [[ -n "${filter// }" ]]; then
|
||||
@@ -398,7 +360,6 @@ jobs:
|
||||
printf 'qa_live_slack_enabled=%s\n' "$qa_live_slack_enabled"
|
||||
printf 'release_package_spec=%s\n' "$RELEASE_PACKAGE_SPEC_INPUT"
|
||||
printf 'package_acceptance_package_spec=%s\n' "$RELEASE_PACKAGE_ACCEPTANCE_PACKAGE_SPEC_INPUT"
|
||||
printf 'codex_plugin_spec=%s\n' "$codex_plugin_spec"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Summarize validated ref
|
||||
@@ -415,7 +376,6 @@ jobs:
|
||||
RELEASE_CROSS_OS_SUITE_FILTER: ${{ inputs.cross_os_suite_filter }}
|
||||
RELEASE_PACKAGE_SPEC: ${{ inputs.release_package_spec }}
|
||||
PACKAGE_ACCEPTANCE_PACKAGE_SPEC: ${{ inputs.package_acceptance_package_spec }}
|
||||
CODEX_PLUGIN_SPEC: ${{ steps.inputs.outputs.codex_plugin_spec }}
|
||||
run: |
|
||||
{
|
||||
echo "## Release checks"
|
||||
@@ -445,11 +405,6 @@ jobs:
|
||||
else
|
||||
echo "- Package Acceptance package spec: prepared release artifact"
|
||||
fi
|
||||
if [[ -n "${CODEX_PLUGIN_SPEC// }" ]]; then
|
||||
echo "- Codex plugin spec: \`${CODEX_PLUGIN_SPEC}\`"
|
||||
else
|
||||
echo "- Codex plugin spec: packed from selected ref"
|
||||
fi
|
||||
if [[ "$RUN_RELEASE_SOAK" == "true" ]]; then
|
||||
echo "- This run will execute blocking release validation plus exhaustive live/Docker soak coverage."
|
||||
else
|
||||
@@ -552,7 +507,6 @@ jobs:
|
||||
permissions: read-all
|
||||
uses: ./.github/workflows/openclaw-cross-os-release-checks-reusable.yml
|
||||
with:
|
||||
advisory: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }}
|
||||
ref: ${{ needs.resolve_target.outputs.revision }}
|
||||
provider: ${{ needs.resolve_target.outputs.provider }}
|
||||
mode: ${{ needs.resolve_target.outputs.mode }}
|
||||
@@ -561,7 +515,7 @@ jobs:
|
||||
candidate_file_name: openclaw-current.tgz
|
||||
candidate_version: ${{ needs.prepare_release_package.outputs.package_version }}
|
||||
candidate_source_sha: ${{ needs.prepare_release_package.outputs.source_sha }}
|
||||
openai_model: openai/gpt-5.5
|
||||
openai_model: openai/gpt-5.4
|
||||
ubuntu_runner: ubuntu-24.04
|
||||
windows_runner: windows-2025
|
||||
macos_runner: macos-26
|
||||
@@ -584,7 +538,6 @@ jobs:
|
||||
pull-requests: read
|
||||
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
|
||||
with:
|
||||
advisory: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }}
|
||||
ref: ${{ needs.resolve_target.outputs.revision }}
|
||||
include_repo_e2e: true
|
||||
include_release_path_suites: false
|
||||
@@ -650,7 +603,6 @@ jobs:
|
||||
pull-requests: read
|
||||
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
|
||||
with:
|
||||
advisory: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }}
|
||||
ref: ${{ needs.resolve_target.outputs.revision }}
|
||||
include_repo_e2e: false
|
||||
include_release_path_suites: true
|
||||
@@ -658,7 +610,6 @@ jobs:
|
||||
include_live_suites: false
|
||||
release_test_profile: ${{ needs.resolve_target.outputs.release_profile }}
|
||||
package_artifact_name: ${{ needs.prepare_release_package.outputs.artifact_name }}
|
||||
codex_plugin_spec: ${{ needs.resolve_target.outputs.codex_plugin_spec }}
|
||||
secrets: *live_e2e_release_secrets
|
||||
|
||||
package_acceptance_release_checks:
|
||||
@@ -672,7 +623,6 @@ jobs:
|
||||
pull-requests: read
|
||||
uses: ./.github/workflows/package-acceptance.yml
|
||||
with:
|
||||
advisory: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }}
|
||||
workflow_ref: ${{ github.ref_name }}
|
||||
source: ${{ (needs.resolve_target.outputs.package_acceptance_package_spec != '' || needs.resolve_target.outputs.release_package_spec != '') && 'npm' || 'artifact' }}
|
||||
package_spec: ${{ needs.resolve_target.outputs.package_acceptance_package_spec || needs.resolve_target.outputs.release_package_spec || 'openclaw@beta' }}
|
||||
@@ -683,7 +633,7 @@ jobs:
|
||||
published_upgrade_survivor_baselines: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'last-stable-4 2026.4.23 2026.5.2 2026.4.15' || '' }}
|
||||
published_upgrade_survivor_scenarios: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'reported-issues' || '' }}
|
||||
telegram_mode: mock-openai
|
||||
telegram_scenarios: telegram-help-command,telegram-commands-command,telegram-tools-compact-command,telegram-whoami-command,telegram-status-command,telegram-other-bot-command-gating,telegram-context-command,telegram-mentioned-message-reply,telegram-long-final-reuses-preview,telegram-mention-gating
|
||||
telegram_scenarios: telegram-help-command,telegram-commands-command,telegram-tools-compact-command,telegram-whoami-command,telegram-status-command,telegram-other-bot-command-gating,telegram-context-command,telegram-mentioned-message-reply,telegram-reply-chain-exact-marker,telegram-stream-final-single-message,telegram-long-final-reuses-preview,telegram-mention-gating
|
||||
secrets:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
@@ -747,9 +697,9 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- lane: candidate
|
||||
output_dir: openai-candidate
|
||||
output_dir: gpt54
|
||||
- lane: baseline
|
||||
output_dir: anthropic-baseline
|
||||
output_dir: opus46
|
||||
env:
|
||||
QA_PARITY_CONCURRENCY: "1"
|
||||
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"
|
||||
@@ -795,7 +745,7 @@ jobs:
|
||||
;;
|
||||
baseline)
|
||||
model="anthropic/claude-opus-4-7"
|
||||
alt_model="anthropic/claude-sonnet-4-6"
|
||||
alt_model="anthropic/claude-sonnet-4-7"
|
||||
;;
|
||||
*)
|
||||
echo "Unknown QA parity lane: ${QA_PARITY_LANE}" >&2
|
||||
@@ -813,7 +763,7 @@ jobs:
|
||||
|
||||
- name: Upload parity lane artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-qa-parity-${{ matrix.lane }}-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
@@ -849,7 +799,7 @@ jobs:
|
||||
install-bun: "true"
|
||||
|
||||
- name: Download parity lane artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: release-qa-parity-*-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
@@ -864,196 +814,21 @@ jobs:
|
||||
run: |
|
||||
pnpm openclaw qa parity-report \
|
||||
--repo-root . \
|
||||
--candidate-summary .artifacts/qa-e2e/openai-candidate/qa-suite-summary.json \
|
||||
--baseline-summary .artifacts/qa-e2e/anthropic-baseline/qa-suite-summary.json \
|
||||
--candidate-summary .artifacts/qa-e2e/gpt54/qa-suite-summary.json \
|
||||
--baseline-summary .artifacts/qa-e2e/opus46/qa-suite-summary.json \
|
||||
--candidate-label "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--baseline-label anthropic/claude-opus-4-7 \
|
||||
--output-dir .artifacts/qa-e2e/parity
|
||||
|
||||
- name: Upload parity artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-qa-parity-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
|
||||
qa_lab_runtime_parity_release_checks:
|
||||
name: Run QA Lab runtime parity lane
|
||||
needs: [resolve_target]
|
||||
if: contains(fromJSON('["all","qa","qa-parity"]'), needs.resolve_target.outputs.rerun_group)
|
||||
continue-on-error: true
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
QA_PARITY_CONCURRENCY: "1"
|
||||
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"
|
||||
OPENAI_API_KEY: ""
|
||||
ANTHROPIC_API_KEY: ""
|
||||
OPENCLAW_LIVE_OPENAI_KEY: ""
|
||||
OPENCLAW_LIVE_ANTHROPIC_KEY: ""
|
||||
OPENCLAW_LIVE_GEMINI_KEY: ""
|
||||
OPENCLAW_LIVE_SETUP_TOKEN_VALUE: ""
|
||||
OPENCLAW_BUILD_PRIVATE_QA: "1"
|
||||
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
|
||||
steps:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ needs.resolve_target.outputs.revision }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: pnpm build
|
||||
|
||||
- name: Run runtime parity lane
|
||||
id: runtime_parity_lane
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pnpm openclaw qa suite \
|
||||
--provider-mode mock-openai \
|
||||
--parity-pack agentic \
|
||||
--concurrency "${QA_PARITY_CONCURRENCY}" \
|
||||
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--alt-model "openai/gpt-5.5-alt" \
|
||||
--runtime-pair pi,codex \
|
||||
--output-dir ".artifacts/qa-e2e/runtime-parity"
|
||||
|
||||
- name: Run standard runtime parity tier
|
||||
if: ${{ always() && steps.runtime_parity_lane.outcome != 'skipped' && steps.runtime_parity_lane.outcome != 'cancelled' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pnpm openclaw qa suite \
|
||||
--provider-mode mock-openai \
|
||||
--runtime-parity-tier standard \
|
||||
--concurrency "${QA_PARITY_CONCURRENCY}" \
|
||||
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--alt-model "openai/gpt-5.5-alt" \
|
||||
--runtime-pair pi,codex \
|
||||
--output-dir ".artifacts/qa-e2e/runtime-parity-standard"
|
||||
|
||||
- name: Run soak runtime parity tier
|
||||
id: runtime_parity_soak_lane
|
||||
if: ${{ always() && needs.resolve_target.outputs.run_release_soak == 'true' && steps.runtime_parity_lane.outcome != 'skipped' && steps.runtime_parity_lane.outcome != 'cancelled' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pnpm openclaw qa suite \
|
||||
--provider-mode mock-openai \
|
||||
--runtime-parity-tier soak \
|
||||
--concurrency "${QA_PARITY_CONCURRENCY}" \
|
||||
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--alt-model "openai/gpt-5.5-alt" \
|
||||
--runtime-pair pi,codex \
|
||||
--output-dir ".artifacts/qa-e2e/runtime-parity-soak"
|
||||
|
||||
- name: Generate runtime parity report
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pnpm openclaw qa parity-report \
|
||||
--repo-root . \
|
||||
--runtime-axis \
|
||||
--summary .artifacts/qa-e2e/runtime-parity/qa-suite-summary.json \
|
||||
--output-dir .artifacts/qa-e2e/runtime-parity-report
|
||||
|
||||
- name: Generate standard runtime parity report
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pnpm openclaw qa parity-report \
|
||||
--repo-root . \
|
||||
--runtime-axis \
|
||||
--summary .artifacts/qa-e2e/runtime-parity-standard/qa-suite-summary.json \
|
||||
--output-dir .artifacts/qa-e2e/runtime-parity-standard-report
|
||||
|
||||
- name: Generate soak runtime parity report
|
||||
if: ${{ always() && needs.resolve_target.outputs.run_release_soak == 'true' && steps.runtime_parity_soak_lane.outcome != 'skipped' && steps.runtime_parity_soak_lane.outcome != 'cancelled' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
summary=".artifacts/qa-e2e/runtime-parity-soak/qa-suite-summary.json"
|
||||
if [[ ! -f "$summary" ]]; then
|
||||
echo "No soak runtime parity summary was produced."
|
||||
exit 0
|
||||
fi
|
||||
pnpm openclaw qa parity-report \
|
||||
--repo-root . \
|
||||
--runtime-axis \
|
||||
--summary "$summary" \
|
||||
--output-dir .artifacts/qa-e2e/runtime-parity-soak-report
|
||||
|
||||
- name: Upload runtime parity artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-qa-runtime-parity-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
|
||||
runtime_tool_coverage_release_checks:
|
||||
name: Enforce QA Lab runtime tool coverage
|
||||
needs: [resolve_target, qa_lab_runtime_parity_release_checks]
|
||||
if: always() && contains(fromJSON('["all","qa","qa-parity"]'), needs.resolve_target.outputs.rerun_group)
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
env:
|
||||
OPENCLAW_BUILD_PRIVATE_QA: "1"
|
||||
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
|
||||
steps:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ needs.resolve_target.outputs.revision }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Download runtime parity artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: release-qa-runtime-parity-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
|
||||
- name: Enforce standard runtime tool coverage
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pnpm openclaw qa coverage \
|
||||
--repo-root . \
|
||||
--tools \
|
||||
--summary .artifacts/qa-e2e/runtime-parity-standard/qa-suite-summary.json \
|
||||
--output .artifacts/qa-e2e/runtime-parity-standard-report/qa-runtime-tool-coverage-report.md
|
||||
|
||||
- name: Upload runtime tool coverage artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@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: warn
|
||||
|
||||
qa_live_matrix_release_checks:
|
||||
name: Run QA Lab live Matrix lane
|
||||
needs: [resolve_target]
|
||||
@@ -1127,7 +902,7 @@ jobs:
|
||||
|
||||
- name: Upload Matrix QA artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-qa-live-matrix-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
@@ -1223,7 +998,7 @@ jobs:
|
||||
|
||||
- name: Upload Telegram QA artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-qa-live-telegram-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
@@ -1319,7 +1094,7 @@ jobs:
|
||||
|
||||
- name: Upload Discord QA artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-qa-live-discord-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
@@ -1333,9 +1108,6 @@ jobs:
|
||||
continue-on-error: true
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
concurrency:
|
||||
group: qa-live-whatsapp-shared
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
@@ -1418,7 +1190,7 @@ jobs:
|
||||
|
||||
- name: Upload WhatsApp QA artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-qa-live-whatsapp-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
@@ -1514,7 +1286,7 @@ jobs:
|
||||
|
||||
- name: Upload Slack QA artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-qa-live-slack-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
@@ -1532,8 +1304,6 @@ jobs:
|
||||
- package_acceptance_release_checks
|
||||
- qa_lab_parity_lane_release_checks
|
||||
- qa_lab_parity_report_release_checks
|
||||
- qa_lab_runtime_parity_release_checks
|
||||
- runtime_tool_coverage_release_checks
|
||||
- qa_live_matrix_release_checks
|
||||
- qa_live_telegram_release_checks
|
||||
- qa_live_discord_release_checks
|
||||
@@ -1546,15 +1316,9 @@ jobs:
|
||||
steps:
|
||||
- name: Verify release check results
|
||||
shell: bash
|
||||
env:
|
||||
WORKFLOW_REF: ${{ github.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
failed=0
|
||||
tideclaw_alpha=false
|
||||
if [[ "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
tideclaw_alpha=true
|
||||
fi
|
||||
for item in \
|
||||
"prepare_release_package=${{ needs.prepare_release_package.result }}" \
|
||||
"install_smoke_release_checks=${{ needs.install_smoke_release_checks.result }}" \
|
||||
@@ -1564,8 +1328,6 @@ jobs:
|
||||
"package_acceptance_release_checks=${{ needs.package_acceptance_release_checks.result }}" \
|
||||
"qa_lab_parity_lane_release_checks=${{ needs.qa_lab_parity_lane_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 }}" \
|
||||
"qa_live_matrix_release_checks=${{ needs.qa_live_matrix_release_checks.result }}" \
|
||||
"qa_live_telegram_release_checks=${{ needs.qa_live_telegram_release_checks.result }}" \
|
||||
"qa_live_discord_release_checks=${{ needs.qa_live_discord_release_checks.result }}" \
|
||||
@@ -1575,15 +1337,6 @@ jobs:
|
||||
name="${item%%=*}"
|
||||
result="${item#*=}"
|
||||
if [[ "$result" != "success" && "$result" != "skipped" ]]; then
|
||||
if [[ "$tideclaw_alpha" == "true" ]]; then
|
||||
case "$name" in
|
||||
prepare_release_package|install_smoke_release_checks) ;;
|
||||
*)
|
||||
echo "::warning::${name} ended with ${result}; Tideclaw alpha treats non-package-safety release-check lanes as advisory."
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
if [[ "$name" == qa_* ]]; then
|
||||
echo "::warning::${name} ended with ${result}; QA release-check lanes are advisory and do not block release validation."
|
||||
continue
|
||||
|
||||
422
.github/workflows/openclaw-release-publish.yml
vendored
422
.github/workflows/openclaw-release-publish.yml
vendored
@@ -15,10 +15,6 @@ on:
|
||||
description: Successful Full Release Validation run id for this tag/SHA, required when publish_openclaw_npm=true
|
||||
required: false
|
||||
type: string
|
||||
npm_telegram_run_id:
|
||||
description: Optional successful NPM Telegram Beta E2E run id to include in final release evidence
|
||||
required: false
|
||||
type: string
|
||||
npm_dist_tag:
|
||||
description: npm dist-tag for the OpenClaw package
|
||||
required: true
|
||||
@@ -80,7 +76,6 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
outputs:
|
||||
sha: ${{ steps.manifest.outputs.sha || steps.ref.outputs.sha }}
|
||||
preflight_artifact_name: ${{ steps.preflight_artifact.outputs.name }}
|
||||
steps:
|
||||
- name: Validate inputs
|
||||
env:
|
||||
@@ -115,12 +110,8 @@ jobs:
|
||||
echo "publish_openclaw_npm=true requires full_release_validation_run_id." >&2
|
||||
exit 1
|
||||
fi
|
||||
tideclaw_alpha_publish=false
|
||||
if [[ "${RELEASE_TAG}" == *"-alpha."* && "${RELEASE_NPM_DIST_TAG}" == "alpha" && "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
tideclaw_alpha_publish=true
|
||||
fi
|
||||
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && "${WORKFLOW_REF}" != "refs/heads/main" && ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ && "${tideclaw_alpha_publish}" != "true" ]]; then
|
||||
echo "publish_openclaw_npm=true requires dispatching this workflow from main, release/YYYY.M.D, or a Tideclaw alpha branch for alpha prereleases." >&2
|
||||
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && "${WORKFLOW_REF}" != "refs/heads/main" && ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]]; then
|
||||
echo "publish_openclaw_npm=true requires dispatching this workflow from main or release/YYYY.M.D." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${PLUGIN_PUBLISH_SCOPE}" == "selected" && -z "${PLUGINS}" ]]; then
|
||||
@@ -140,55 +131,14 @@ jobs:
|
||||
esac
|
||||
|
||||
- name: Download OpenClaw npm preflight manifest
|
||||
id: preflight_artifact
|
||||
if: ${{ inputs.publish_openclaw_npm }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
preferred_name="openclaw-npm-preflight-${RELEASE_TAG}"
|
||||
preflight_dir="${RUNNER_TEMP}/openclaw-npm-preflight-manifest"
|
||||
rm -rf "${preflight_dir}"
|
||||
mkdir -p "${preflight_dir}"
|
||||
|
||||
download_named_artifact() {
|
||||
local artifact_name="$1"
|
||||
for attempt in 1 2 3; do
|
||||
if gh run download "${PREFLIGHT_RUN_ID}" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--name "${artifact_name}" \
|
||||
--dir "${preflight_dir}"; then
|
||||
return 0
|
||||
fi
|
||||
if [[ "$attempt" != "3" ]]; then
|
||||
echo "::warning::Artifact download for ${artifact_name} failed on attempt ${attempt}; retrying."
|
||||
sleep $((attempt * 10))
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
if download_named_artifact "${preferred_name}"; then
|
||||
echo "name=${preferred_name}" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "::warning::${preferred_name} not found; checking run artifacts for a single compatible preflight artifact."
|
||||
mapfile -t matches < <(gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/runs/${PREFLIGHT_RUN_ID}/artifacts" \
|
||||
--jq '.artifacts[] | select(.expired != true) | .name' |
|
||||
grep '^openclaw-npm-preflight-' || true)
|
||||
if [[ "${#matches[@]}" != "1" ]]; then
|
||||
echo "Expected ${preferred_name}, or exactly one openclaw-npm-preflight-* fallback artifact in run ${PREFLIGHT_RUN_ID}." >&2
|
||||
printf 'Available preflight candidates:\n' >&2
|
||||
printf -- '- %s\n' "${matches[@]:-<none>}" >&2
|
||||
exit 1
|
||||
fi
|
||||
fallback_name="${matches[0]}"
|
||||
download_named_artifact "${fallback_name}"
|
||||
echo "name=${fallback_name}" >> "$GITHUB_OUTPUT"
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: openclaw-npm-preflight-${{ inputs.tag }}
|
||||
path: ${{ runner.temp }}/openclaw-npm-preflight-manifest
|
||||
repository: ${{ github.repository }}
|
||||
run-id: ${{ inputs.preflight_run_id }}
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
- name: Download full release validation manifest
|
||||
if: ${{ inputs.publish_openclaw_npm }}
|
||||
@@ -295,10 +245,7 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate release tag is reachable from a trusted release branch
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
WORKFLOW_REF_NAME: ${{ github.ref_name }}
|
||||
- name: Validate release tag is reachable from main or release branch
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --no-tags origin \
|
||||
@@ -312,17 +259,7 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
done < <(git for-each-ref --format='%(refname)' refs/remotes/origin/release)
|
||||
if [[ "${RELEASE_TAG}" == *"-alpha."* ]]; then
|
||||
if [[ ! "${WORKFLOW_REF_NAME}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
echo "Alpha publish tags must be dispatched from tideclaw/alpha/YYYY-MM-DD-HHMMZ." >&2
|
||||
exit 1
|
||||
fi
|
||||
git fetch --no-tags origin "+refs/heads/${WORKFLOW_REF_NAME}:refs/remotes/origin/${WORKFLOW_REF_NAME}"
|
||||
if git merge-base --is-ancestor HEAD "refs/remotes/origin/${WORKFLOW_REF_NAME}"; then
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
echo "Release tag must point to a commit reachable from main, release/*, or the matching Tideclaw alpha branch for alpha prereleases." >&2
|
||||
echo "Release tag must point to a commit reachable from main or release/*." >&2
|
||||
exit 1
|
||||
|
||||
- name: Summarize release target
|
||||
@@ -348,7 +285,6 @@ jobs:
|
||||
needs: [resolve_release_target]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
environment: npm-release
|
||||
steps:
|
||||
- name: Checkout release SHA
|
||||
uses: actions/checkout@v6
|
||||
@@ -357,12 +293,6 @@ jobs:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
cache-key-suffix: release-publish
|
||||
|
||||
- name: Dispatch publish workflows
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -376,9 +306,6 @@ jobs:
|
||||
PLUGINS: ${{ inputs.plugins }}
|
||||
PUBLISH_OPENCLAW_NPM: ${{ inputs.publish_openclaw_npm && 'true' || 'false' }}
|
||||
WAIT_FOR_CLAWHUB: ${{ inputs.wait_for_clawhub && 'true' || 'false' }}
|
||||
PREFLIGHT_ARTIFACT_NAME: ${{ needs.resolve_release_target.outputs.preflight_artifact_name }}
|
||||
NPM_TELEGRAM_RUN_ID: ${{ inputs.npm_telegram_run_id }}
|
||||
POSTPUBLISH_EVIDENCE_DIR: ${{ runner.temp }}/openclaw-release-postpublish-evidence
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -387,10 +314,7 @@ jobs:
|
||||
shift
|
||||
|
||||
local before_json dispatch_output run_id
|
||||
before_json="$(gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow}/runs" \
|
||||
-F event=workflow_dispatch \
|
||||
-F per_page=100 \
|
||||
--jq '[.workflow_runs[].id]')"
|
||||
before_json="$(gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
|
||||
|
||||
dispatch_output="$(gh workflow run --repo "$GITHUB_REPOSITORY" "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)"
|
||||
printf '%s\n' "$dispatch_output" >&2
|
||||
@@ -403,10 +327,8 @@ jobs:
|
||||
if [[ -z "$run_id" ]]; then
|
||||
for _ in $(seq 1 60); do
|
||||
run_id="$(
|
||||
BEFORE_IDS="$before_json" gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow}/runs" \
|
||||
-F event=workflow_dispatch \
|
||||
-F per_page=50 \
|
||||
--jq '.workflow_runs | map({databaseId:.id, createdAt:.created_at}) | map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
|
||||
BEFORE_IDS="$before_json" gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --event workflow_dispatch --limit 50 --json databaseId,createdAt \
|
||||
--jq 'map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
|
||||
)"
|
||||
if [[ -n "$run_id" ]]; then
|
||||
break
|
||||
@@ -427,75 +349,6 @@ jobs:
|
||||
printf '%s\n' "${run_id}"
|
||||
}
|
||||
|
||||
print_pending_deployments() {
|
||||
local workflow="$1"
|
||||
local run_id="$2"
|
||||
local pending_json
|
||||
|
||||
pending_json="$(gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/pending_deployments" 2>/dev/null || true)"
|
||||
if [[ -z "${pending_json}" ]] || ! printf '%s' "${pending_json}" | jq -e 'length > 0' >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "${workflow} pending environment approval:"
|
||||
while IFS=$'\t' read -r env_id env_name can_approve; do
|
||||
echo "- env=${env_name} canApprove=${can_approve}"
|
||||
echo " approve: gh api -X POST repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/pending_deployments -F 'environment_ids[]=${env_id}' -f state=approved -f comment='Approve release gate'"
|
||||
done < <(printf '%s' "${pending_json}" | jq -r '.[] | [.environment.id, .environment.name, .current_user_can_approve] | @tsv')
|
||||
}
|
||||
|
||||
approve_pending_deployments() {
|
||||
local workflow="$1"
|
||||
local run_id="$2"
|
||||
local pending_json approved
|
||||
|
||||
pending_json="$(gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/pending_deployments" 2>/dev/null || true)"
|
||||
if [[ -z "${pending_json}" ]] || ! printf '%s' "${pending_json}" | jq -e 'length > 0' >/dev/null 2>&1; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
approved=0
|
||||
while IFS=$'\t' read -r env_id env_name; do
|
||||
if [[ -z "${env_id}" ]]; then
|
||||
continue
|
||||
fi
|
||||
echo "${workflow}: approving pending environment ${env_name} (${env_id})"
|
||||
gh api -X POST "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/pending_deployments" \
|
||||
-F "environment_ids[]=${env_id}" \
|
||||
-f state=approved \
|
||||
-f comment="Approve child release gate after parent release approval" >/dev/null
|
||||
approved=1
|
||||
done < <(printf '%s' "${pending_json}" | jq -r '.[] | select(.current_user_can_approve == true) | [.environment.id, .environment.name] | @tsv')
|
||||
|
||||
if [[ "${approved}" == "1" ]]; then
|
||||
echo "${workflow}: approved available pending environment gates"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
print_failed_run_summary() {
|
||||
local run_id="$1"
|
||||
local failed_json
|
||||
|
||||
failed_json="$(gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json jobs \
|
||||
--jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {databaseId, name, conclusion, url}' || true)"
|
||||
if [[ -z "${failed_json}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Failed child job summary:"
|
||||
printf '%s\n' "${failed_json}"
|
||||
while IFS=$'\t' read -r job_id job_name; do
|
||||
if [[ -z "${job_id}" ]]; then
|
||||
continue
|
||||
fi
|
||||
echo "--- ${job_name} (${job_id}) log tail ---"
|
||||
gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --job "${job_id}" --log 2>/dev/null |
|
||||
tail -200 || true
|
||||
done < <(printf '%s\n' "${failed_json}" | jq -r '[.databaseId, .name] | @tsv' 2>/dev/null || true)
|
||||
}
|
||||
|
||||
wait_for_run() {
|
||||
local workflow="$1"
|
||||
local run_id="$2"
|
||||
@@ -513,8 +366,6 @@ jobs:
|
||||
state="${status}:${updated_at}"
|
||||
if [[ "$state" != "$last_state" ]]; then
|
||||
echo "${workflow} still ${status} (updated ${updated_at}): ${url}"
|
||||
print_pending_deployments "${workflow}" "${run_id}"
|
||||
approve_pending_deployments "${workflow}" "${run_id}" || true
|
||||
last_state="$state"
|
||||
fi
|
||||
sleep 30
|
||||
@@ -542,7 +393,7 @@ jobs:
|
||||
echo "- ${workflow}: ${conclusion} in ${duration_label} (${url})"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
if [[ "$conclusion" != "success" ]]; then
|
||||
print_failed_run_summary "${run_id}"
|
||||
gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json jobs --jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}' || true
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
@@ -561,85 +412,6 @@ jobs:
|
||||
wait_run_pid="$!"
|
||||
}
|
||||
|
||||
wait_for_job_success() {
|
||||
local workflow="$1"
|
||||
local run_id="$2"
|
||||
local job_name="$3"
|
||||
local jobs_json job_json run_status run_conclusion status conclusion url deadline
|
||||
|
||||
deadline=$((SECONDS + 900))
|
||||
while true; do
|
||||
jobs_json="$(gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json status,conclusion,jobs)"
|
||||
run_status="$(printf '%s' "$jobs_json" | jq -r '.status')"
|
||||
run_conclusion="$(printf '%s' "$jobs_json" | jq -r '.conclusion // ""')"
|
||||
job_json="$(printf '%s' "$jobs_json" | jq -c --arg name "$job_name" '.jobs[]? | select(.name == $name) | {status, conclusion, url}' | head -n 1)"
|
||||
if [[ -n "$job_json" ]]; then
|
||||
status="$(printf '%s' "$job_json" | jq -r '.status')"
|
||||
conclusion="$(printf '%s' "$job_json" | jq -r '.conclusion // ""')"
|
||||
url="$(printf '%s' "$job_json" | jq -r '.url // ""')"
|
||||
if [[ "$status" == "completed" ]]; then
|
||||
if [[ "$conclusion" == "success" || "$conclusion" == "skipped" ]]; then
|
||||
echo "${workflow} ${job_name} ${conclusion}: ${url}"
|
||||
echo "- ${workflow} ${job_name}: ${conclusion} (${url})" >> "$GITHUB_STEP_SUMMARY"
|
||||
return 0
|
||||
fi
|
||||
echo "${workflow} ${job_name} failed: ${conclusion} ${url}" >&2
|
||||
print_failed_run_summary "${run_id}"
|
||||
return 1
|
||||
fi
|
||||
echo "${workflow} ${job_name} still ${status}: ${url}"
|
||||
elif [[ "$run_status" == "completed" ]]; then
|
||||
if [[ "$run_conclusion" == "success" ]]; then
|
||||
echo "${workflow} completed before ${job_name} was needed."
|
||||
echo "- ${workflow} ${job_name}: not needed" >> "$GITHUB_STEP_SUMMARY"
|
||||
return 0
|
||||
fi
|
||||
echo "${workflow} completed before ${job_name} with ${run_conclusion}." >&2
|
||||
print_failed_run_summary "${run_id}"
|
||||
return 1
|
||||
else
|
||||
echo "${workflow} waiting for ${job_name} to start: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
|
||||
fi
|
||||
if (( SECONDS >= deadline )); then
|
||||
echo "${workflow} ${job_name} did not complete within 15 minutes." >&2
|
||||
return 1
|
||||
fi
|
||||
sleep 10
|
||||
done
|
||||
}
|
||||
|
||||
approve_child_publish_environment() {
|
||||
local workflow="$1"
|
||||
local run_id="$2"
|
||||
local run_json status conclusion deadline
|
||||
|
||||
deadline=$((SECONDS + 900))
|
||||
while true; do
|
||||
if approve_pending_deployments "${workflow}" "${run_id}"; then
|
||||
echo "- ${workflow}: child environment gate approved" >> "$GITHUB_STEP_SUMMARY"
|
||||
return 0
|
||||
fi
|
||||
run_json="$(gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json status,conclusion,url)"
|
||||
status="$(printf '%s' "$run_json" | jq -r '.status')"
|
||||
conclusion="$(printf '%s' "$run_json" | jq -r '.conclusion // ""')"
|
||||
if [[ "$status" == "completed" ]]; then
|
||||
if [[ "$conclusion" == "success" ]]; then
|
||||
echo "${workflow}: completed before child environment approval was needed"
|
||||
return 0
|
||||
fi
|
||||
echo "${workflow}: completed before child environment approval with ${conclusion}" >&2
|
||||
print_failed_run_summary "${run_id}"
|
||||
return 1
|
||||
fi
|
||||
if (( SECONDS >= deadline )); then
|
||||
echo "${workflow}: child environment approval was not available within 15 minutes." >&2
|
||||
print_pending_deployments "${workflow}" "${run_id}"
|
||||
return 1
|
||||
fi
|
||||
sleep 10
|
||||
done
|
||||
}
|
||||
|
||||
create_or_update_github_release() {
|
||||
local release_version notes_version title notes_file changelog_file latest_arg prerelease_args
|
||||
release_version="${RELEASE_TAG#v}"
|
||||
@@ -694,18 +466,17 @@ jobs:
|
||||
}
|
||||
|
||||
upload_dependency_evidence_release_asset() {
|
||||
local release_version download_dir asset_path asset_name artifact_name
|
||||
local release_version download_dir asset_path asset_name
|
||||
release_version="${RELEASE_TAG#v}"
|
||||
download_dir="${RUNNER_TEMP}/openclaw-release-dependency-evidence-asset"
|
||||
asset_name="openclaw-${release_version}-dependency-evidence.zip"
|
||||
asset_path="${RUNNER_TEMP}/${asset_name}"
|
||||
artifact_name="${PREFLIGHT_ARTIFACT_NAME:-openclaw-npm-preflight-${RELEASE_TAG}}"
|
||||
|
||||
rm -rf "${download_dir}" "${asset_path}"
|
||||
mkdir -p "${download_dir}"
|
||||
gh run download "${PREFLIGHT_RUN_ID}" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--name "${artifact_name}" \
|
||||
--name "openclaw-npm-preflight-${RELEASE_TAG}" \
|
||||
--dir "${download_dir}"
|
||||
|
||||
if [[ ! -d "${download_dir}/dependency-evidence" ]]; then
|
||||
@@ -721,124 +492,15 @@ jobs:
|
||||
echo "- Dependency evidence asset: \`${asset_name}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
}
|
||||
|
||||
verify_published_release() {
|
||||
local release_version evidence_path
|
||||
local -a verify_args
|
||||
|
||||
release_version="${RELEASE_TAG#v}"
|
||||
evidence_path="${POSTPUBLISH_EVIDENCE_DIR}/release-postpublish-evidence.json"
|
||||
mkdir -p "${POSTPUBLISH_EVIDENCE_DIR}"
|
||||
|
||||
verify_args=(
|
||||
release:verify-beta
|
||||
--
|
||||
"${release_version}"
|
||||
--tag "${RELEASE_TAG}"
|
||||
--dist-tag "${RELEASE_NPM_DIST_TAG}"
|
||||
--repo "${GITHUB_REPOSITORY}"
|
||||
--workflow-ref "${CHILD_WORKFLOW_REF}"
|
||||
--full-release-validation-run "${FULL_RELEASE_VALIDATION_RUN_ID}"
|
||||
--plugin-npm-run "${plugin_npm_run_id}"
|
||||
--openclaw-npm-run "${openclaw_npm_run_id}"
|
||||
--evidence-out "${evidence_path}"
|
||||
)
|
||||
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
|
||||
verify_args+=(--plugin-clawhub-run "${plugin_clawhub_run_id}")
|
||||
else
|
||||
verify_args+=(--skip-clawhub)
|
||||
fi
|
||||
if [[ -n "${PLUGINS// }" ]]; then
|
||||
verify_args+=(--plugins "${PLUGINS}")
|
||||
fi
|
||||
if [[ -n "${NPM_TELEGRAM_RUN_ID// }" ]]; then
|
||||
verify_args+=(--npm-telegram-run "${NPM_TELEGRAM_RUN_ID}")
|
||||
fi
|
||||
|
||||
pnpm "${verify_args[@]}"
|
||||
{
|
||||
echo "- Postpublish verification: passed"
|
||||
echo "- Postpublish evidence: \`${evidence_path}\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
}
|
||||
|
||||
append_release_proof_to_github_release() {
|
||||
local release_version body_file notes_file tarball integrity telegram_line clawhub_line
|
||||
|
||||
release_version="${RELEASE_TAG#v}"
|
||||
body_file="${RUNNER_TEMP}/release-body.md"
|
||||
notes_file="${RUNNER_TEMP}/release-notes-with-proof.md"
|
||||
tarball="$(npm view "openclaw@${release_version}" dist.tarball --json | jq -r '.')"
|
||||
integrity="$(npm view "openclaw@${release_version}" dist.integrity --json | jq -r '.')"
|
||||
gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --json body --jq .body > "${body_file}"
|
||||
|
||||
if [[ -n "${NPM_TELEGRAM_RUN_ID// }" ]]; then
|
||||
telegram_line="- npm Telegram beta E2E: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${NPM_TELEGRAM_RUN_ID}"
|
||||
else
|
||||
telegram_line="- npm Telegram beta E2E: not supplied"
|
||||
fi
|
||||
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
|
||||
clawhub_line="- plugin ClawHub publish: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${plugin_clawhub_run_id}"
|
||||
else
|
||||
clawhub_line="- plugin ClawHub publish: dispatched separately, not awaited by this proof: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${plugin_clawhub_run_id}"
|
||||
fi
|
||||
|
||||
RELEASE_BODY_FILE="${body_file}" \
|
||||
RELEASE_NOTES_FILE="${notes_file}" \
|
||||
RELEASE_VERSION="${release_version}" \
|
||||
RELEASE_TAG="${RELEASE_TAG}" \
|
||||
RELEASE_REPO="${GITHUB_REPOSITORY}" \
|
||||
RELEASE_TARBALL="${tarball}" \
|
||||
RELEASE_INTEGRITY="${integrity}" \
|
||||
RELEASE_PUBLISH_RUN_ID="${GITHUB_RUN_ID}" \
|
||||
PREFLIGHT_RUN_ID="${PREFLIGHT_RUN_ID}" \
|
||||
FULL_RELEASE_VALIDATION_RUN_ID="${FULL_RELEASE_VALIDATION_RUN_ID}" \
|
||||
PLUGIN_NPM_RUN_ID="${plugin_npm_run_id}" \
|
||||
OPENCLAW_NPM_RUN_ID="${openclaw_npm_run_id}" \
|
||||
CLAWHUB_LINE="${clawhub_line}" \
|
||||
TELEGRAM_LINE="${telegram_line}" \
|
||||
node --input-type=module <<'NODE'
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
const bodyFile = process.env.RELEASE_BODY_FILE;
|
||||
const notesFile = process.env.RELEASE_NOTES_FILE;
|
||||
if (!bodyFile || !notesFile) {
|
||||
throw new Error("Missing release notes file paths.");
|
||||
}
|
||||
|
||||
const body = readFileSync(bodyFile, "utf8").trimEnd();
|
||||
const section = [
|
||||
"### Release verification",
|
||||
"",
|
||||
`- npm package: https://www.npmjs.com/package/openclaw/v/${process.env.RELEASE_VERSION}`,
|
||||
`- registry tarball: ${process.env.RELEASE_TARBALL}`,
|
||||
`- integrity: \`${process.env.RELEASE_INTEGRITY}\``,
|
||||
`- release publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.RELEASE_PUBLISH_RUN_ID}`,
|
||||
`- npm preflight: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.PREFLIGHT_RUN_ID}`,
|
||||
`- full release validation: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.FULL_RELEASE_VALIDATION_RUN_ID}`,
|
||||
`- plugin npm publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.PLUGIN_NPM_RUN_ID}`,
|
||||
process.env.CLAWHUB_LINE,
|
||||
`- OpenClaw npm publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.OPENCLAW_NPM_RUN_ID}`,
|
||||
process.env.TELEGRAM_LINE,
|
||||
].join("\n");
|
||||
|
||||
const withoutOldProof = body.replace(/\n?### Release verification\n[\s\S]*?(?=\n### |\n## |$)/, "");
|
||||
writeFileSync(notesFile, `${withoutOldProof.trimEnd()}\n\n${section}\n`);
|
||||
NODE
|
||||
|
||||
gh release edit "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --notes-file "${notes_file}"
|
||||
echo "- Release proof: appended to GitHub release" >> "$GITHUB_STEP_SUMMARY"
|
||||
}
|
||||
|
||||
{
|
||||
echo "### Publish sequence"
|
||||
echo
|
||||
echo "- Workflow ref: \`${CHILD_WORKFLOW_REF}\`"
|
||||
echo "- Release tag: \`${RELEASE_TAG}\`"
|
||||
echo "- Release SHA: \`${TARGET_SHA}\`"
|
||||
echo "- Release approval: this workflow job"
|
||||
echo "- Plugin npm and ClawHub publish: dispatched in parallel"
|
||||
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then
|
||||
echo "- OpenClaw npm publish: starts after plugin npm succeeds"
|
||||
echo "- OpenClaw npm publish: starts after plugin npm succeeds; ClawHub may still be running"
|
||||
else
|
||||
echo "- OpenClaw npm publish: skipped by input"
|
||||
fi
|
||||
@@ -849,8 +511,8 @@ jobs:
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
npm_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}" -f release_publish_run_id="${GITHUB_RUN_ID}")
|
||||
clawhub_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}" -f release_publish_run_id="${GITHUB_RUN_ID}")
|
||||
npm_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}")
|
||||
clawhub_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}")
|
||||
if [[ -n "${PLUGINS}" ]]; then
|
||||
npm_args+=(-f plugins="${PLUGINS}")
|
||||
clawhub_args+=(-f plugins="${PLUGINS}")
|
||||
@@ -876,7 +538,6 @@ jobs:
|
||||
-f preflight_only=false \
|
||||
-f preflight_run_id="${PREFLIGHT_RUN_ID}" \
|
||||
-f full_release_validation_run_id="${FULL_RELEASE_VALIDATION_RUN_ID}" \
|
||||
-f release_publish_run_id="${GITHUB_RUN_ID}" \
|
||||
-f npm_dist_tag="${RELEASE_NPM_DIST_TAG}")"
|
||||
echo "- OpenClaw npm run ID: \`${openclaw_npm_run_id}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
@@ -891,13 +552,7 @@ jobs:
|
||||
wait_for_run_background plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "${clawhub_result}"
|
||||
clawhub_pid="${wait_run_pid}"
|
||||
else
|
||||
wait_for_job_success plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "Validate release publish approval"
|
||||
if approve_child_publish_environment plugin-clawhub-release.yml "${plugin_clawhub_run_id}"; then
|
||||
:
|
||||
else
|
||||
echo "- plugin-clawhub-release.yml: child environment gate not ready; publish was left dispatched (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
echo "- plugin-clawhub-release.yml: publish not awaited (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- plugin-clawhub-release.yml: not awaited (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
openclaw_result=""
|
||||
@@ -910,40 +565,23 @@ jobs:
|
||||
fi
|
||||
|
||||
failed=0
|
||||
openclaw_failed=0
|
||||
if [[ -n "${openclaw_pid}" ]] && ! wait "${openclaw_pid}"; then
|
||||
failed=1
|
||||
openclaw_failed=1
|
||||
fi
|
||||
if [[ -n "${openclaw_result}" && -f "${openclaw_result}" && "$(cat "${openclaw_result}")" != "success" ]]; then
|
||||
failed=1
|
||||
openclaw_failed=1
|
||||
fi
|
||||
|
||||
if [[ -n "${openclaw_npm_run_id}" && "${openclaw_failed}" == "0" ]]; then
|
||||
create_or_update_github_release
|
||||
upload_dependency_evidence_release_asset
|
||||
fi
|
||||
|
||||
if [[ -n "${clawhub_pid}" ]] && ! wait "${clawhub_pid}"; then
|
||||
failed=1
|
||||
fi
|
||||
if [[ -n "${openclaw_pid}" ]] && ! wait "${openclaw_pid}"; then
|
||||
failed=1
|
||||
fi
|
||||
if [[ -f "${clawhub_result}" && "$(cat "${clawhub_result}")" != "success" ]]; then
|
||||
failed=1
|
||||
fi
|
||||
|
||||
if [[ "${failed}" == "0" && -n "${openclaw_npm_run_id}" ]]; then
|
||||
verify_published_release
|
||||
append_release_proof_to_github_release
|
||||
if [[ -n "${openclaw_result}" && -f "${openclaw_result}" && "$(cat "${openclaw_result}")" != "success" ]]; then
|
||||
failed=1
|
||||
fi
|
||||
if [[ "${failed}" != "0" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload postpublish evidence
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: openclaw-release-postpublish-evidence-${{ inputs.tag }}
|
||||
path: ${{ runner.temp }}/openclaw-release-postpublish-evidence
|
||||
if-no-files-found: ignore
|
||||
if [[ -n "${openclaw_npm_run_id}" ]]; then
|
||||
create_or_update_github_release
|
||||
upload_dependency_evidence_release_asset
|
||||
fi
|
||||
|
||||
2
.github/workflows/opengrep-precise-full.yml
vendored
2
.github/workflows/opengrep-precise-full.yml
vendored
@@ -62,7 +62,7 @@ jobs:
|
||||
|
||||
- name: Upload SARIF as workflow artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: opengrep-full-sarif
|
||||
path: .opengrep-out/precise.sarif
|
||||
|
||||
2
.github/workflows/opengrep-precise.yml
vendored
2
.github/workflows/opengrep-precise.yml
vendored
@@ -92,7 +92,7 @@ jobs:
|
||||
|
||||
- name: Upload SARIF as workflow artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: opengrep-pr-diff-sarif
|
||||
path: .opengrep-out/precise.sarif
|
||||
|
||||
17
.github/workflows/package-acceptance.yml
vendored
17
.github/workflows/package-acceptance.yml
vendored
@@ -93,18 +93,8 @@ on:
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
advisory:
|
||||
description: Treat acceptance failures as advisory for the caller
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
workflow_call:
|
||||
inputs:
|
||||
advisory:
|
||||
description: Treat acceptance failures as advisory for the caller
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
workflow_ref:
|
||||
description: Trusted repo ref for workflow scripts and Docker E2E harness
|
||||
required: false
|
||||
@@ -519,7 +509,6 @@ jobs:
|
||||
needs: resolve_package
|
||||
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
|
||||
with:
|
||||
advisory: ${{ inputs.advisory }}
|
||||
ref: ${{ needs.resolve_package.outputs.package_source_sha || inputs.workflow_ref }}
|
||||
include_repo_e2e: false
|
||||
include_release_path_suites: ${{ needs.resolve_package.outputs.include_release_path_suites == 'true' }}
|
||||
@@ -584,7 +573,6 @@ jobs:
|
||||
if: needs.resolve_package.outputs.telegram_enabled == 'true'
|
||||
uses: ./.github/workflows/npm-telegram-beta-e2e.yml
|
||||
with:
|
||||
advisory: ${{ inputs.advisory }}
|
||||
package_spec: ${{ inputs.package_spec }}
|
||||
package_artifact_name: ${{ needs.resolve_package.outputs.package_artifact_name }}
|
||||
package_label: openclaw@${{ needs.resolve_package.outputs.package_version }}
|
||||
@@ -611,7 +599,6 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
advisory="${{ inputs.advisory }}"
|
||||
failed=0
|
||||
for item in \
|
||||
"resolve_package=${RESOLVE_RESULT}" \
|
||||
@@ -621,10 +608,6 @@ jobs:
|
||||
name="${item%%=*}"
|
||||
result="${item#*=}"
|
||||
if [[ "$result" != "success" && "$result" != "skipped" ]]; then
|
||||
if [[ "$advisory" == "true" && "$name" != "resolve_package" ]]; then
|
||||
echo "::warning::${name} ended with ${result}; package acceptance is advisory for this caller."
|
||||
continue
|
||||
fi
|
||||
echo "::error::${name} ended with ${result}"
|
||||
failed=1
|
||||
fi
|
||||
|
||||
66
.github/workflows/plugin-clawhub-release.yml
vendored
66
.github/workflows/plugin-clawhub-release.yml
vendored
@@ -16,14 +16,10 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
ref:
|
||||
description: Commit SHA on main, a release branch, or the matching Tideclaw alpha branch to publish from; defaults to the workflow ref
|
||||
description: Commit SHA on main or a release branch to publish from; defaults to the workflow ref
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
release_publish_run_id:
|
||||
description: Approved OpenClaw Release Publish workflow run id
|
||||
required: false
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: plugin-clawhub-release-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
|
||||
@@ -86,9 +82,7 @@ jobs:
|
||||
fi
|
||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate ref is on a trusted publish branch
|
||||
env:
|
||||
WORKFLOW_REF: ${{ github.ref }}
|
||||
- name: Validate ref is on main or a release branch
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if git merge-base --is-ancestor HEAD origin/main; then
|
||||
@@ -99,14 +93,7 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
done < <(git for-each-ref --format='%(refname)' refs/remotes/origin/release)
|
||||
if [[ "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
alpha_branch="${WORKFLOW_REF#refs/heads/}"
|
||||
git fetch --no-tags origin "+refs/heads/${alpha_branch}:refs/remotes/origin/${alpha_branch}"
|
||||
if git merge-base --is-ancestor HEAD "refs/remotes/origin/${alpha_branch}"; then
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
echo "Plugin ClawHub publishes must target a commit reachable from main, release/*, or the matching Tideclaw alpha branch." >&2
|
||||
echo "Plugin ClawHub publishes must target a commit reachable from main or release/*." >&2
|
||||
exit 1
|
||||
|
||||
- name: Validate publishable plugin metadata
|
||||
@@ -181,56 +168,12 @@ jobs:
|
||||
echo "::error::One or more selected plugin versions already exist on ClawHub. Bump the version before running a real publish."
|
||||
exit 1
|
||||
|
||||
- name: Validate Tideclaw alpha plugin channels
|
||||
if: startsWith(github.ref, 'refs/heads/tideclaw/alpha/')
|
||||
run: |
|
||||
set -euo pipefail
|
||||
invalid="$(
|
||||
jq -r '.candidates[]? | select(.publishTag != "alpha" or .channel != "alpha") | "- \(.packageName)@\(.version) [\(.publishTag)]"' .local/plugin-clawhub-release-plan.json
|
||||
)"
|
||||
if [[ -n "${invalid}" ]]; then
|
||||
echo "Tideclaw alpha ClawHub publishes may only publish alpha plugin versions." >&2
|
||||
printf '%s\n' "${invalid}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify OpenClaw ClawHub package ownership
|
||||
if: steps.plan.outputs.has_candidates == 'true'
|
||||
env:
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
run: node --import tsx scripts/plugin-clawhub-owner-preflight.ts .local/plugin-clawhub-release-plan.json
|
||||
|
||||
validate_release_publish_approval:
|
||||
name: Validate release publish approval
|
||||
needs: preview_plugins_clawhub
|
||||
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Validate release publish approval run
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
|
||||
EXPECTED_WORKFLOW_BRANCH: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" ]]; then
|
||||
if [[ "${GITHUB_ACTOR}" == "github-actions[bot]" ]]; then
|
||||
echo "Plugin ClawHub publish dispatched by another workflow must include release_publish_run_id." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Direct Plugin ClawHub Release dispatch; relying on this workflow's clawhub-plugin-release environment approval."
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${GITHUB_ACTOR}" != "github-actions[bot]" ]]; then
|
||||
echo "Plugin ClawHub publish must be dispatched by the OpenClaw Release Publish workflow, not directly by ${GITHUB_ACTOR}." >&2
|
||||
exit 1
|
||||
fi
|
||||
RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
|
||||
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "OpenClaw Release Publish"], ["headBranch", process.env.EXPECTED_WORKFLOW_BRANCH], ["event", "workflow_dispatch"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } if (run.status !== "in_progress") { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must still be in_progress, got ${run.status ?? "<missing>"}.`); process.exit(1); } if (run.conclusion) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} already concluded ${run.conclusion}.`); process.exit(1); } console.log(`Using release publish approval run ${process.env.RELEASE_PUBLISH_RUN_ID}: ${run.url}`);'
|
||||
|
||||
preview_plugin_pack:
|
||||
needs: preview_plugins_clawhub
|
||||
if: needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
|
||||
@@ -324,12 +267,11 @@ jobs:
|
||||
run: bash scripts/plugin-clawhub-publish.sh --dry-run "${PACKAGE_DIR}"
|
||||
|
||||
publish_plugins_clawhub:
|
||||
needs: [preview_plugins_clawhub, preview_plugin_pack, validate_release_publish_approval]
|
||||
needs: [preview_plugins_clawhub, preview_plugin_pack]
|
||||
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
environment: clawhub-plugin-release
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
id-token: write
|
||||
strategy:
|
||||
|
||||
66
.github/workflows/plugin-npm-release.yml
vendored
66
.github/workflows/plugin-npm-release.yml
vendored
@@ -25,17 +25,13 @@ on:
|
||||
- selected
|
||||
- all-publishable
|
||||
ref:
|
||||
description: Commit SHA on main, a release branch, or the matching Tideclaw alpha branch to publish from
|
||||
description: Commit SHA on main or a release branch to publish from (copy from the preview run)
|
||||
required: true
|
||||
type: string
|
||||
plugins:
|
||||
description: Comma-separated plugin package names to publish when publish_scope=selected
|
||||
required: false
|
||||
type: string
|
||||
release_publish_run_id:
|
||||
description: Approved OpenClaw Release Publish workflow run id
|
||||
required: false
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: plugin-npm-release-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
|
||||
@@ -75,9 +71,7 @@ jobs:
|
||||
id: ref
|
||||
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate ref is on a trusted publish branch
|
||||
env:
|
||||
WORKFLOW_REF: ${{ github.ref }}
|
||||
- name: Validate ref is on main or a release branch
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --no-tags origin \
|
||||
@@ -91,14 +85,7 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
done < <(git for-each-ref --format='%(refname)' refs/remotes/origin/release)
|
||||
if [[ "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
alpha_branch="${WORKFLOW_REF#refs/heads/}"
|
||||
git fetch --no-tags origin "+refs/heads/${alpha_branch}:refs/remotes/origin/${alpha_branch}"
|
||||
if git merge-base --is-ancestor HEAD "refs/remotes/origin/${alpha_branch}"; then
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
echo "Plugin npm publishes must target a commit reachable from main, release/*, or the matching Tideclaw alpha branch." >&2
|
||||
echo "Plugin npm publishes must target a commit reachable from main or release/*." >&2
|
||||
exit 1
|
||||
|
||||
- name: Validate publishable plugin metadata
|
||||
@@ -164,50 +151,6 @@ jobs:
|
||||
echo "Already published / skipped:"
|
||||
jq -r '.skippedPublished[]? | "- \(.packageName)@\(.version)"' .local/plugin-npm-release-plan.json
|
||||
|
||||
- name: Validate Tideclaw alpha plugin channels
|
||||
if: startsWith(github.ref, 'refs/heads/tideclaw/alpha/')
|
||||
run: |
|
||||
set -euo pipefail
|
||||
invalid="$(
|
||||
jq -r '.candidates[]? | select(.publishTag != "alpha" or .channel != "alpha") | "- \(.packageName)@\(.version) [\(.publishTag)]"' .local/plugin-npm-release-plan.json
|
||||
)"
|
||||
if [[ -n "${invalid}" ]]; then
|
||||
echo "Tideclaw alpha plugin npm publishes may only publish alpha plugin versions." >&2
|
||||
printf '%s\n' "${invalid}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
validate_release_publish_approval:
|
||||
name: Validate release publish approval
|
||||
needs: preview_plugins_npm
|
||||
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_npm.outputs.has_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Validate release publish approval run
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
|
||||
EXPECTED_WORKFLOW_BRANCH: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" ]]; then
|
||||
if [[ "${GITHUB_ACTOR}" == "github-actions[bot]" ]]; then
|
||||
echo "Plugin npm publish dispatched by another workflow must include release_publish_run_id." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Direct Plugin NPM Release dispatch; relying on this workflow's npm-release environment approval."
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${GITHUB_ACTOR}" != "github-actions[bot]" ]]; then
|
||||
echo "Plugin npm publish must be dispatched by the OpenClaw Release Publish workflow, not directly by ${GITHUB_ACTOR}." >&2
|
||||
exit 1
|
||||
fi
|
||||
RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
|
||||
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "OpenClaw Release Publish"], ["headBranch", process.env.EXPECTED_WORKFLOW_BRANCH], ["event", "workflow_dispatch"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } if (run.status !== "in_progress") { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must still be in_progress, got ${run.status ?? "<missing>"}.`); process.exit(1); } if (run.conclusion) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} already concluded ${run.conclusion}.`); process.exit(1); } console.log(`Using release publish approval run ${process.env.RELEASE_PUBLISH_RUN_ID}: ${run.url}`);'
|
||||
|
||||
preview_plugin_pack:
|
||||
needs: preview_plugins_npm
|
||||
if: needs.preview_plugins_npm.outputs.has_candidates == 'true'
|
||||
@@ -240,12 +183,11 @@ jobs:
|
||||
run: bash scripts/plugin-npm-publish.sh --pack-dry-run "${{ matrix.plugin.packageDir }}"
|
||||
|
||||
publish_plugins_npm:
|
||||
needs: [preview_plugins_npm, preview_plugin_pack, validate_release_publish_approval]
|
||||
needs: [preview_plugins_npm, preview_plugin_pack]
|
||||
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_npm.outputs.has_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm-release
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
id-token: write
|
||||
strategy:
|
||||
|
||||
145
.github/workflows/qa-live-transports-convex.yml
vendored
145
.github/workflows/qa-live-transports-convex.yml
vendored
@@ -60,17 +60,13 @@ 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@v8
|
||||
with:
|
||||
script: |
|
||||
if (context.eventName === "schedule") {
|
||||
core.info("Scheduled default-branch QA run; actor permission check is only required for manual dispatch.");
|
||||
core.setOutput("authorized", "true");
|
||||
return;
|
||||
}
|
||||
const allowed = new Set(["admin", "maintain", "write"]);
|
||||
@@ -83,18 +79,14 @@ jobs:
|
||||
const permission = data.permission;
|
||||
core.info(`Actor ${context.actor} permission: ${permission}`);
|
||||
if (!allowed.has(permission)) {
|
||||
core.notice(
|
||||
core.setFailed(
|
||||
`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 }}
|
||||
@@ -186,8 +178,6 @@ jobs:
|
||||
install-bun: "true"
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: pnpm build
|
||||
|
||||
- name: Run OpenAI candidate lane
|
||||
@@ -198,7 +188,7 @@ jobs:
|
||||
--concurrency "${QA_PARITY_CONCURRENCY}" \
|
||||
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--alt-model openai/gpt-5.5-alt \
|
||||
--output-dir .artifacts/qa-e2e/openai-candidate
|
||||
--output-dir .artifacts/qa-e2e/gpt54
|
||||
|
||||
- name: Run Opus 4.7 lane
|
||||
run: |
|
||||
@@ -207,118 +197,28 @@ jobs:
|
||||
--parity-pack agentic \
|
||||
--concurrency "${QA_PARITY_CONCURRENCY}" \
|
||||
--model anthropic/claude-opus-4-7 \
|
||||
--alt-model anthropic/claude-sonnet-4-6 \
|
||||
--output-dir .artifacts/qa-e2e/anthropic-baseline
|
||||
--alt-model anthropic/claude-sonnet-4-7 \
|
||||
--output-dir .artifacts/qa-e2e/opus46
|
||||
|
||||
- name: Generate parity report
|
||||
run: |
|
||||
pnpm openclaw qa parity-report \
|
||||
--repo-root . \
|
||||
--candidate-summary .artifacts/qa-e2e/openai-candidate/qa-suite-summary.json \
|
||||
--baseline-summary .artifacts/qa-e2e/anthropic-baseline/qa-suite-summary.json \
|
||||
--candidate-summary .artifacts/qa-e2e/gpt54/qa-suite-summary.json \
|
||||
--baseline-summary .artifacts/qa-e2e/opus46/qa-suite-summary.json \
|
||||
--candidate-label "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--baseline-label anthropic/claude-opus-4-7 \
|
||||
--output-dir .artifacts/qa-e2e/parity
|
||||
|
||||
- name: Upload parity artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: qa-parity-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: .artifacts/qa-e2e/
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
|
||||
run_live_runtime_token_efficiency:
|
||||
name: Run live runtime token-efficiency lane
|
||||
needs: [authorize_actor, validate_selected_ref]
|
||||
if: github.event_name == 'schedule'
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 45
|
||||
environment: qa-live-shared
|
||||
env:
|
||||
QA_PARITY_CONCURRENCY: "1"
|
||||
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"
|
||||
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
|
||||
steps:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@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 }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Validate required QA credential env
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ -z "${OPENAI_API_KEY:-}" ]]; then
|
||||
echo "Missing required OPENAI_API_KEY." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: pnpm build
|
||||
|
||||
- name: Run live runtime parity lane
|
||||
id: run_lane
|
||||
shell: bash
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENCLAW_LIVE_OPENAI_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
output_dir=".artifacts/qa-e2e/runtime-token-efficiency-live-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
echo "output_dir=${output_dir}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
pnpm openclaw qa suite \
|
||||
--repo-root . \
|
||||
--provider-mode live-frontier \
|
||||
--runtime-parity-tier standard \
|
||||
--runtime-parity-tier live-only \
|
||||
--concurrency "${QA_PARITY_CONCURRENCY}" \
|
||||
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--alt-model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--runtime-pair pi,codex \
|
||||
--fast \
|
||||
--allow-failures \
|
||||
--output-dir "${output_dir}/runtime-suite"
|
||||
|
||||
- name: Generate live runtime token-efficiency report
|
||||
if: always() && steps.run_lane.outcome != 'skipped' && steps.run_lane.outcome != 'cancelled'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
pnpm openclaw qa parity-report \
|
||||
--repo-root . \
|
||||
--runtime-axis \
|
||||
--token-efficiency \
|
||||
--summary "${{ steps.run_lane.outputs.output_dir }}/runtime-suite/qa-suite-summary.json" \
|
||||
--output-dir "${{ steps.run_lane.outputs.output_dir }}/runtime-report"
|
||||
|
||||
- name: Upload live runtime token-efficiency artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
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: warn
|
||||
|
||||
run_live_matrix:
|
||||
name: Run Matrix live QA lane
|
||||
needs: [authorize_actor, validate_selected_ref]
|
||||
@@ -354,8 +254,6 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: pnpm build
|
||||
|
||||
- name: Run Matrix live lane
|
||||
@@ -389,7 +287,7 @@ jobs:
|
||||
|
||||
- name: Upload Matrix QA artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: qa-live-matrix-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_lane.outputs.output_dir }}
|
||||
@@ -440,8 +338,6 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: pnpm build
|
||||
|
||||
- name: Run Matrix live lane shard
|
||||
@@ -474,7 +370,7 @@ jobs:
|
||||
|
||||
- name: Upload Matrix QA shard artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: qa-live-matrix-${{ matrix.profile }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_lane.outputs.output_dir }}
|
||||
@@ -524,8 +420,6 @@ jobs:
|
||||
require_var OPENCLAW_QA_CONVEX_SECRET_CI
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: pnpm build
|
||||
|
||||
- name: Run Telegram live lane
|
||||
@@ -569,7 +463,7 @@ jobs:
|
||||
|
||||
- name: Upload Telegram QA artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: qa-live-telegram-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_lane.outputs.output_dir }}
|
||||
@@ -619,8 +513,6 @@ jobs:
|
||||
require_var OPENCLAW_QA_CONVEX_SECRET_CI
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: pnpm build
|
||||
|
||||
- name: Run Discord live lane
|
||||
@@ -655,8 +547,8 @@ jobs:
|
||||
--repo-root . \
|
||||
--output-dir "${output_dir}" \
|
||||
--provider-mode live-frontier \
|
||||
--model openai/gpt-5.5 \
|
||||
--alt-model openai/gpt-5.5 \
|
||||
--model openai/gpt-5.4 \
|
||||
--alt-model openai/gpt-5.4 \
|
||||
--fast \
|
||||
--credential-source convex \
|
||||
--credential-role ci \
|
||||
@@ -664,7 +556,7 @@ jobs:
|
||||
|
||||
- name: Upload Discord QA artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: qa-live-discord-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_lane.outputs.output_dir }}
|
||||
@@ -676,9 +568,6 @@ jobs:
|
||||
needs: [authorize_actor, validate_selected_ref]
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 60
|
||||
concurrency:
|
||||
group: qa-live-whatsapp-shared
|
||||
cancel-in-progress: false
|
||||
environment: qa-live-shared
|
||||
steps:
|
||||
- name: Checkout selected ref
|
||||
@@ -717,8 +606,6 @@ jobs:
|
||||
require_var OPENCLAW_QA_CONVEX_SECRET_CI
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: pnpm build
|
||||
|
||||
- name: Run WhatsApp live lane
|
||||
@@ -762,7 +649,7 @@ jobs:
|
||||
|
||||
- name: Upload WhatsApp QA artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: qa-live-whatsapp-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_lane.outputs.output_dir }}
|
||||
@@ -812,8 +699,6 @@ jobs:
|
||||
require_var OPENCLAW_QA_CONVEX_SECRET_CI
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: pnpm build
|
||||
|
||||
- name: Run Slack live lane
|
||||
@@ -857,7 +742,7 @@ jobs:
|
||||
|
||||
- name: Upload Slack QA artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: qa-live-slack-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_lane.outputs.output_dir }}
|
||||
|
||||
21
.github/workflows/real-behavior-proof.yml
vendored
21
.github/workflows/real-behavior-proof.yml
vendored
@@ -18,7 +18,6 @@ jobs:
|
||||
name: Real behavior proof
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
pull-requests: read
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
@@ -26,25 +25,5 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
persist-credentials: false
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
permission-issues: read
|
||||
permission-members: read
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token-fallback
|
||||
if: steps.app-token.outcome == 'failure'
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: "2971289"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
|
||||
permission-issues: read
|
||||
permission-members: read
|
||||
- name: Check real behavior proof
|
||||
env:
|
||||
GH_APP_TOKEN: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: node scripts/github/real-behavior-proof-check.mjs
|
||||
|
||||
4
.github/workflows/website-installer-sync.yml
vendored
4
.github/workflows/website-installer-sync.yml
vendored
@@ -76,9 +76,11 @@ jobs:
|
||||
- name: install.sh in Docker
|
||||
run: |
|
||||
docker run --rm \
|
||||
-e OPENCLAW_NO_ONBOARD=1 \
|
||||
-e OPENCLAW_NO_PROMPT=1 \
|
||||
-v "$PWD/scripts/install.sh:/tmp/install.sh:ro" \
|
||||
node:24-bookworm-slim \
|
||||
bash -lc 'bash /tmp/install.sh --version latest && openclaw --version'
|
||||
bash -lc 'bash /tmp/install.sh --no-prompt --no-onboard --version latest && openclaw --version'
|
||||
|
||||
- name: install-cli.sh in Docker
|
||||
run: |
|
||||
|
||||
4
.github/workflows/workflow-sanity.yml
vendored
4
.github/workflows/workflow-sanity.yml
vendored
@@ -2,12 +2,8 @@ name: Workflow Sanity
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- "CHANGELOG.md"
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- "CHANGELOG.md"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
},
|
||||
"rules": {
|
||||
"curly": "error",
|
||||
"eslint/no-underscore-dangle": "error",
|
||||
"eslint-plugin-unicorn/prefer-array-find": "error",
|
||||
"eslint/no-array-constructor": "error",
|
||||
"eslint/no-await-in-loop": "off",
|
||||
|
||||
20
AGENTS.md
20
AGENTS.md
@@ -35,24 +35,18 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- External official plugins own package/deps and are excluded from core dist; core uses registry-aware `facade-runtime` or generic contracts.
|
||||
- Externalizing a bundled plugin: update package excludes, official catalogs, docs, tests, and prove core runtime paths resolve installed plugin roots before root-dep removal.
|
||||
- Legacy config repair belongs in `openclaw doctor --fix`, not startup/load-time core migrations. Runtime paths use canonical contracts.
|
||||
- Fix shape: default to clean bounded refactor, not smallest patch. Move ownership to right boundary; delete stale abstractions, duplicate policy, dead branches, wrappers, fallback stacks.
|
||||
- Lean code is a goal. No internal shims, aliases, legacy names, broad fallbacks, or defensive branches just to reduce diff or handle unrealistic edge cases.
|
||||
- Handle real production states, shipped upgrade paths, security boundaries, and dependency contracts. Public/hostile/observed malformed input gets care; hypothetical malformed input does not.
|
||||
- Public plugin SDK/API is the compat exception. New API first, old path only via named compat/deprecation metadata, docs, warnings when useful, tests for old+new, planned removal.
|
||||
- Migrate internal/bundled callers to modern API in the same change. Do not let internal compat become permanent architecture.
|
||||
- New seams: backward-compatible, documented, versioned. Third-party plugins exist.
|
||||
- Channels are implementation under `src/channels/**`; plugin authors get SDK seams. Providers own auth/catalog/runtime hooks; core owns generic loop.
|
||||
- Hot paths should carry prepared facts forward: provider id, model ref, channel id, target, capability family, attachment class. Do not rediscover with broad plugin/provider/channel/capability loaders.
|
||||
- Do not fix repeated request-time discovery with scattered caches. Move the canonical fact earlier; reuse prepared runtime objects; delete duplicate lookup branches.
|
||||
- Inline code comments: brief notes for tricky, bug-prone, or previously buggy logic.
|
||||
- Gateway protocol changes: additive first; incompatible needs versioning/docs/client follow-through.
|
||||
- Protocol version bumps: explicit owner confirmation only; never automatic/generated.
|
||||
- Config contract: exported types, schema/help, metadata, baselines, docs aligned. Retired public keys stay retired; compat in raw migration/doctor only.
|
||||
- Prompt cache: deterministic ordering for maps/sets/registries/plugin lists/files/network results before model/tool payloads. Preserve old transcript bytes when possible.
|
||||
- Agent tool schema cleanup: remove stale args cleanly; no hidden compat for model-facing params just to avoid churn.
|
||||
|
||||
## Commands
|
||||
|
||||
- Runtime: Node 22.19+; Node 24 recommended. Keep Node + Bun paths working.
|
||||
- Runtime: Node 22+. Keep Node + Bun paths working.
|
||||
- Package manager/runtime: repo defaults only. No swaps without approval.
|
||||
- Install: `pnpm install` (keep Bun lock/patches aligned if touched).
|
||||
- Sharp/Homebrew libvips source-build fail: `SHARP_IGNORE_GLOBAL_LIBVIPS=1 pnpm install`.
|
||||
@@ -69,22 +63,19 @@ Skills own workflows; root owns hard policy and routing.
|
||||
## Validation
|
||||
|
||||
- Use `$openclaw-testing` for test/CI choice and `$crabbox` for remote/full/E2E proof.
|
||||
- Crabbox request means real scenario proof: install/update/call/repro user path; not just copy tests and run them remotely.
|
||||
- Small/narrow tests, lints, format checks, and type probes are fine locally only in a healthy normal checkout.
|
||||
- In Codex worktrees, direct local `pnpm test*`, `pnpm check*`, `pnpm crabbox:run`, and `scripts/committer` can trigger pnpm dependency reconciliation or install prompts. Prefer `node` wrappers locally and Crabbox/Testbox for pnpm-gated proof.
|
||||
- Full suites, broad changed gates, Docker/package/E2E/live/cross-OS proof, or anything that bogs down the Mac: Crabbox/Testbox.
|
||||
- One/few files local. If a local command fans out, stop and move broad proof to Crabbox/Testbox.
|
||||
- Before handoff/push: prove touched surface. Before landing to `main`: issue proof plus appropriate full/broad proof unless scope is clearly narrow.
|
||||
- Pre-land/pre-commit code changes: use `$autoreview` until no accepted/actionable findings remain, unless equivalent manual review already done, trivial/docs-only, or user opts out.
|
||||
- Pre-land/pre-commit code changes: use `$codex-review` until no accepted/actionable findings remain, unless equivalent manual review already done, trivial/docs-only, or user opts out.
|
||||
- If proof is blocked, say exactly what is missing and why.
|
||||
- Do not land related failing format/lint/type/build/tests. If unrelated on latest `origin/main`, say so with scoped proof.
|
||||
- Docs/changelog-only and CI/workflow metadata-only: `git diff --check` plus relevant docs/workflow sanity; escalate only if scripts/config/generated/package/runtime behavior changed.
|
||||
- Prompt snapshots: CI truth is Linux Node 24. If macOS local passes but CI drifts, reproduce/generate in Linux before rerun.
|
||||
|
||||
## GitHub / PRs
|
||||
|
||||
- Use `$openclaw-pr-maintainer` immediately for maintainer-side OpenClaw issue/PR review, triage, duplicates, labels, comments, close, land, or evidence. Contributor PR creation/refresh follows the requested contributor workflow; linked refs alone do not require maintainer archive tooling.
|
||||
- Pasted GitHub issue/PR: first `git status -sb`; if dirty, yell; then `git push` + `git pull --ff-only`.
|
||||
- PR refs: `gh pr view/diff` or `gh api`, not web search. Prefer `gitcrawl` for maintainer discovery; missing/stale `gitcrawl` falls through to live `gh`, not contributor setup. Verify live with `gh` before mutation.
|
||||
- Bare issue/PR URL/number means review/report in chat. Suggest comment/close/merge when appropriate; mutate only when asked.
|
||||
- No unsolicited PR comments/reviews/labels/retitles/rebases/fixups/landing. Exception: close/duplicate action that needs a reason comment after explicit close/sweep/landing request.
|
||||
@@ -112,10 +103,6 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- No `@ts-nocheck`. Lint suppressions only intentional + explained.
|
||||
- External boundaries: prefer `zod` or existing schema helpers.
|
||||
- Runtime branching: discriminated unions/closed codes over freeform strings. Avoid semantic sentinels (`?? 0`, empty object/string).
|
||||
- Formatter-friendly shape: when oxfmt explodes an expression vertically, extract named booleans, payloads, or small helpers. Do not change width or use format-ignore for local compactness.
|
||||
- Calls should be boring: complex decisions happen above; call args/object fields are names, literals, or simple property reads.
|
||||
- Prefer early returns over nested condition pyramids. Split code into gather -> normalize -> decide -> act.
|
||||
- Use named intermediates only for domain meaning or readability; avoid temp-variable soup.
|
||||
- Dynamic import: no static+dynamic import for same prod module. Use `*.runtime.ts` lazy boundary. After edits: `pnpm build`; check `[INEFFECTIVE_DYNAMIC_IMPORT]`.
|
||||
- Cycles: keep `pnpm check:import-cycles` + architecture/madge green.
|
||||
- Classes: no prototype mixins/mutations. Prefer inheritance/composition. Tests prefer per-instance stubs.
|
||||
@@ -172,7 +159,6 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- "restart iOS/Android apps" = rebuild/reinstall/relaunch, not kill/launch.
|
||||
- SwiftUI: Observation (`@Observable`, `@Bindable`) over new `ObservableObject`.
|
||||
- Mac gateway: dev watch = `pnpm gateway:watch`; managed installs = `openclaw gateway restart/status --deep`; logs = `./scripts/clawlog.sh`. No launchd/ad-hoc tmux.
|
||||
- Mac app permission testing: stable app path + real signing identity required. No `--no-sign`, `SIGN_IDENTITY=-`, or raw debug binary; TCC prompts/listing won't stick.
|
||||
- Version bump surfaces live in `$openclaw-release-maintainer`.
|
||||
- Parallels: `$openclaw-parallels-smoke`; Discord roundtrip: `$parallels-discord-roundtrip`.
|
||||
- Crabbox/WebVNC human demos: keep remote desktop visible/windowed; no fullscreen remote browser unless video/capture-style output.
|
||||
|
||||
789
CHANGELOG.md
789
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
33
Dockerfile
33
Dockerfile
@@ -72,8 +72,7 @@ RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/sto
|
||||
NODE_OPTIONS=--max-old-space-size=2048 pnpm install --frozen-lockfile \
|
||||
--config.supportedArchitectures.os=linux \
|
||||
--config.supportedArchitectures.cpu="$(node -p 'process.arch')" \
|
||||
--config.supportedArchitectures.libc=glibc && \
|
||||
pnpm store add source-map@0.6.1
|
||||
--config.supportedArchitectures.libc=glibc
|
||||
|
||||
# pnpm v10+ may append peer-resolution hashes to virtual-store folder names; do not hardcode `.pnpm/...`
|
||||
# paths. Matrix's native downloader can hit transient release CDN errors while
|
||||
@@ -117,8 +116,8 @@ ENV OPENCLAW_PREFER_PNPM=1
|
||||
RUN pnpm_config_verify_deps_before_run=false pnpm ui:build
|
||||
RUN pnpm_config_verify_deps_before_run=false pnpm qa:lab:build
|
||||
|
||||
# Prune dev dependencies, omitted plugin runtime packages, and build-only
|
||||
# metadata before copying runtime assets into the final image.
|
||||
# Prune dev dependencies and strip build-only metadata before copying
|
||||
# runtime assets into the final image.
|
||||
FROM build AS runtime-assets
|
||||
ARG OPENCLAW_EXTENSIONS
|
||||
ARG OPENCLAW_BUNDLED_PLUGIN_DIR
|
||||
@@ -128,8 +127,8 @@ RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/sto
|
||||
--config.supportedArchitectures.os=linux \
|
||||
--config.supportedArchitectures.cpu="$(node -p 'process.arch')" \
|
||||
--config.supportedArchitectures.libc=glibc && \
|
||||
OPENCLAW_EXTENSIONS="$OPENCLAW_EXTENSIONS" OPENCLAW_BUNDLED_PLUGIN_DIR="$OPENCLAW_BUNDLED_PLUGIN_DIR" node scripts/prune-docker-plugin-dist.mjs && \
|
||||
node scripts/postinstall-bundled-plugins.mjs && \
|
||||
OPENCLAW_EXTENSIONS="$OPENCLAW_EXTENSIONS" node scripts/prune-docker-plugin-dist.mjs && \
|
||||
find dist -type f \( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o -name '*.map' \) -delete && \
|
||||
node scripts/check-package-dist-imports.mjs /app
|
||||
|
||||
@@ -199,29 +198,13 @@ RUN install -d -m 0755 "$COREPACK_HOME" && \
|
||||
chmod -R a+rX "$COREPACK_HOME"
|
||||
|
||||
# Install additional system packages needed by your skills or extensions.
|
||||
# Example: docker build --build-arg OPENCLAW_IMAGE_APT_PACKAGES="python3 wget" .
|
||||
# Legacy alias: OPENCLAW_DOCKER_APT_PACKAGES is still accepted as a fallback.
|
||||
ARG OPENCLAW_IMAGE_APT_PACKAGES
|
||||
# Example: docker build --build-arg OPENCLAW_DOCKER_APT_PACKAGES="python3 wget" .
|
||||
ARG OPENCLAW_DOCKER_APT_PACKAGES=""
|
||||
RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
|
||||
packages="${OPENCLAW_IMAGE_APT_PACKAGES-$OPENCLAW_DOCKER_APT_PACKAGES}"; \
|
||||
if [ -n "$packages" ]; then \
|
||||
if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \
|
||||
apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $packages; \
|
||||
fi
|
||||
|
||||
# Install additional Python packages needed by your plugins or skills.
|
||||
# Example: docker build --build-arg OPENCLAW_IMAGE_PIP_PACKAGES="requests humanize" .
|
||||
ARG OPENCLAW_IMAGE_PIP_PACKAGES=""
|
||||
RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
|
||||
if [ -n "$OPENCLAW_IMAGE_PIP_PACKAGES" ]; then \
|
||||
if ! python3 -m pip --version >/dev/null 2>&1; then \
|
||||
apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends python3-pip; \
|
||||
fi && \
|
||||
python3 -m pip install --no-cache-dir --break-system-packages $OPENCLAW_IMAGE_PIP_PACKAGES; \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $OPENCLAW_DOCKER_APT_PACKAGES; \
|
||||
fi
|
||||
|
||||
# Optionally install Chromium and Xvfb for browser automation.
|
||||
@@ -310,4 +293,4 @@ USER node
|
||||
HEALTHCHECK --interval=3m --timeout=10s --start-period=15s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:18789/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||
ENTRYPOINT ["tini", "-s", "--"]
|
||||
CMD ["node", "openclaw.mjs", "gateway"]
|
||||
CMD ["node", "openclaw.mjs", "gateway", "--allow-unconfigured"]
|
||||
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 OpenClaw Foundation
|
||||
Copyright (c) 2025 Peter Steinberger
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -96,7 +96,7 @@ Model note: while many providers and models are supported, prefer a current flag
|
||||
|
||||
## Install (recommended)
|
||||
|
||||
Runtime: **Node 24 (recommended) or Node 22.19+**.
|
||||
Runtime: **Node 24 (recommended) or Node 22.16+**.
|
||||
|
||||
```bash
|
||||
npm install -g openclaw@latest
|
||||
@@ -109,7 +109,7 @@ OpenClaw Onboard installs the Gateway daemon (launchd/systemd user service) so i
|
||||
|
||||
## Quick start (TL;DR)
|
||||
|
||||
Runtime: **Node 24 (recommended) or Node 22.19+**.
|
||||
Runtime: **Node 24 (recommended) or Node 22.16+**.
|
||||
|
||||
Full beginner guide (auth, pairing, channels): [Getting started](https://docs.openclaw.ai/start/getting-started)
|
||||
|
||||
|
||||
@@ -312,7 +312,7 @@ OpenClaw's web interface (Gateway Control UI + HTTP endpoints) is intended for *
|
||||
|
||||
### Node.js Version
|
||||
|
||||
OpenClaw requires **Node.js 22.19.0 or later** (LTS). Node 24 is the recommended default runtime for new installs. The minimum version includes important security patches:
|
||||
OpenClaw requires **Node.js 22.16.0 or later** (LTS). This version includes important security patches:
|
||||
|
||||
- CVE-2025-59466: async_hooks DoS vulnerability
|
||||
- CVE-2026-21636: Permission model bypass vulnerability
|
||||
@@ -320,7 +320,7 @@ OpenClaw requires **Node.js 22.19.0 or later** (LTS). Node 24 is the recommended
|
||||
Verify your Node.js version:
|
||||
|
||||
```bash
|
||||
node --version # Should be v22.19.0 or later
|
||||
node --version # Should be v22.16.0 or later
|
||||
```
|
||||
|
||||
### Docker Security
|
||||
|
||||
1463
appcast.xml
1463
appcast.xml
File diff suppressed because it is too large
Load Diff
@@ -209,16 +209,15 @@ Why these matter:
|
||||
|
||||
- Google Play treats SMS and Call Log access as highly restricted. In most cases, Play only allows them for the default SMS app, default Phone app, default Assistant, or a narrow policy exception.
|
||||
- Review usually involves a `Permissions Declaration Form`, policy justification, and demo video evidence in Play Console.
|
||||
- The Play build removes these behind the `play` flavor.
|
||||
- Photo library access is also removed from the Play build. Use third-party builds for `photos.latest`.
|
||||
- If we want a Play-safe build, these should be the first permissions removed behind a dedicated product flavor / variant.
|
||||
|
||||
Current OpenClaw Android implication:
|
||||
|
||||
- APK / sideload build can keep SMS, Call Log, and recent-photo features.
|
||||
- Google Play build excludes SMS send/search, Call Log search, and recent-photo access unless the product is intentionally positioned and approved under the relevant policy exception.
|
||||
- APK / sideload build can keep SMS and Call Log features.
|
||||
- Google Play build should exclude SMS send/search and Call Log search unless the product is intentionally positioned and approved as a default-handler exception case.
|
||||
- The repo now ships this split as Android product flavors:
|
||||
- `play`: removes `READ_SMS`, `SEND_SMS`, `READ_CALL_LOG`, `READ_MEDIA_IMAGES`, `READ_MEDIA_VISUAL_USER_SELECTED`, and `READ_EXTERNAL_STORAGE`; hides SMS, Call Log, and Photos surfaces in onboarding, settings, and advertised node capabilities.
|
||||
- `thirdParty`: keeps the full permission set and the existing SMS / Call Log / Photos functionality.
|
||||
- `play`: removes `READ_SMS`, `SEND_SMS`, and `READ_CALL_LOG`, and hides SMS / Call Log surfaces in onboarding, settings, and advertised node capabilities.
|
||||
- `thirdParty`: keeps the full permission set and the existing SMS / Call Log functionality.
|
||||
|
||||
Policy links:
|
||||
|
||||
|
||||
@@ -65,8 +65,8 @@ android {
|
||||
applicationId = "ai.openclaw.app"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 2026052000
|
||||
versionName = "2026.5.20"
|
||||
versionCode = 2026051600
|
||||
versionName = "2026.5.16"
|
||||
ndk {
|
||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
|
||||
@@ -5,7 +5,6 @@ import ai.openclaw.app.chat.ChatPendingToolCall
|
||||
import ai.openclaw.app.chat.ChatSessionEntry
|
||||
import ai.openclaw.app.chat.OutgoingAttachment
|
||||
import ai.openclaw.app.gateway.GatewayEndpoint
|
||||
import ai.openclaw.app.gateway.GatewayUpdateAvailableSummary
|
||||
import ai.openclaw.app.node.CameraCaptureManager
|
||||
import ai.openclaw.app.node.CanvasController
|
||||
import ai.openclaw.app.node.SmsManager
|
||||
@@ -82,40 +81,6 @@ class MainViewModel(
|
||||
val statusText: StateFlow<String> = runtimeState(initial = "Offline") { it.statusText }
|
||||
val serverName: StateFlow<String?> = runtimeState(initial = null) { it.serverName }
|
||||
val remoteAddress: StateFlow<String?> = runtimeState(initial = null) { it.remoteAddress }
|
||||
val gatewayVersion: StateFlow<String?> = runtimeState(initial = null) { it.gatewayVersion }
|
||||
val gatewayUpdateAvailable: StateFlow<GatewayUpdateAvailableSummary?> = runtimeState(initial = null) { it.gatewayUpdateAvailable }
|
||||
val modelCatalog: StateFlow<List<GatewayModelSummary>> = runtimeState(initial = emptyList()) { it.modelCatalog }
|
||||
val modelAuthProviders: StateFlow<List<GatewayModelProviderSummary>> = runtimeState(initial = emptyList()) { it.modelAuthProviders }
|
||||
val modelCatalogRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.modelCatalogRefreshing }
|
||||
val modelCatalogErrorText: StateFlow<String?> = runtimeState(initial = null) { it.modelCatalogErrorText }
|
||||
val gatewayDefaultAgentId: StateFlow<String?> = runtimeState(initial = null) { it.gatewayDefaultAgentId }
|
||||
val gatewayAgents: StateFlow<List<GatewayAgentSummary>> = runtimeState(initial = emptyList()) { it.gatewayAgents }
|
||||
val cronStatus: StateFlow<GatewayCronStatus> = runtimeState(initial = GatewayCronStatus(enabled = false, jobs = 0, nextWakeAtMs = null)) { it.cronStatus }
|
||||
val cronJobs: StateFlow<List<GatewayCronJobSummary>> = runtimeState(initial = emptyList()) { it.cronJobs }
|
||||
val cronRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.cronRefreshing }
|
||||
val cronErrorText: StateFlow<String?> = runtimeState(initial = null) { it.cronErrorText }
|
||||
val usageSummary: StateFlow<GatewayUsageSummary> = runtimeState(initial = GatewayUsageSummary(updatedAtMs = null, providers = emptyList())) { it.usageSummary }
|
||||
val usageRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.usageRefreshing }
|
||||
val usageErrorText: StateFlow<String?> = runtimeState(initial = null) { it.usageErrorText }
|
||||
val skillsSummary: StateFlow<GatewaySkillsSummary> = runtimeState(initial = GatewaySkillsSummary(skills = emptyList())) { it.skillsSummary }
|
||||
val skillsRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.skillsRefreshing }
|
||||
val skillsErrorText: StateFlow<String?> = runtimeState(initial = null) { it.skillsErrorText }
|
||||
val nodesDevicesSummary: StateFlow<GatewayNodesDevicesSummary> =
|
||||
runtimeState(initial = GatewayNodesDevicesSummary(nodes = emptyList(), pendingDevices = emptyList(), pairedDevices = emptyList())) { it.nodesDevicesSummary }
|
||||
val nodesDevicesRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.nodesDevicesRefreshing }
|
||||
val nodesDevicesErrorText: StateFlow<String?> = runtimeState(initial = null) { it.nodesDevicesErrorText }
|
||||
val channelsSummary: StateFlow<GatewayChannelsSummary> =
|
||||
runtimeState(initial = GatewayChannelsSummary(channels = emptyList())) { it.channelsSummary }
|
||||
val channelsRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.channelsRefreshing }
|
||||
val channelsErrorText: StateFlow<String?> = runtimeState(initial = null) { it.channelsErrorText }
|
||||
val dreamingSummary: StateFlow<GatewayDreamingSummary> =
|
||||
runtimeState(initial = GatewayDreamingSummary()) { it.dreamingSummary }
|
||||
val dreamingRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.dreamingRefreshing }
|
||||
val dreamingErrorText: StateFlow<String?> = runtimeState(initial = null) { it.dreamingErrorText }
|
||||
val healthLogsSummary: StateFlow<GatewayHealthLogsSummary> =
|
||||
runtimeState(initial = GatewayHealthLogsSummary()) { it.healthLogsSummary }
|
||||
val healthLogsRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.healthLogsRefreshing }
|
||||
val healthLogsErrorText: StateFlow<String?> = runtimeState(initial = null) { it.healthLogsErrorText }
|
||||
val pendingGatewayTrust: StateFlow<NodeRuntime.GatewayTrustPrompt?> = runtimeState(initial = null) { it.pendingGatewayTrust }
|
||||
val seamColorArgb: StateFlow<Long> = runtimeState(initial = 0xFF0EA5E9) { it.seamColorArgb }
|
||||
val mainSessionKey: StateFlow<String> = runtimeState(initial = "main") { it.mainSessionKey }
|
||||
@@ -153,8 +118,6 @@ class MainViewModel(
|
||||
val talkModeListening: StateFlow<Boolean> = runtimeState(initial = false) { it.talkModeListening }
|
||||
val talkModeSpeaking: StateFlow<Boolean> = runtimeState(initial = false) { it.talkModeSpeaking }
|
||||
val talkModeStatusText: StateFlow<String> = runtimeState(initial = "Off") { it.talkModeStatusText }
|
||||
val talkModeConversation: StateFlow<List<VoiceConversationEntry>> =
|
||||
runtimeState(initial = emptyList()) { it.talkModeConversation }
|
||||
|
||||
val chatSessionKey: StateFlow<String> = runtimeState(initial = "main") { it.chatSessionKey }
|
||||
val chatSessionId: StateFlow<String?> = runtimeState(initial = null) { it.chatSessionId }
|
||||
@@ -328,10 +291,6 @@ class MainViewModel(
|
||||
ensureRuntime().setMicEnabled(enabled)
|
||||
}
|
||||
|
||||
fun cancelMicCapture() {
|
||||
ensureRuntime().cancelMicCapture()
|
||||
}
|
||||
|
||||
fun setTalkModeEnabled(enabled: Boolean) {
|
||||
ensureRuntime().setTalkModeEnabled(enabled)
|
||||
}
|
||||
@@ -394,42 +353,6 @@ class MainViewModel(
|
||||
ensureRuntime().refreshHomeCanvasOverviewIfConnected()
|
||||
}
|
||||
|
||||
fun refreshModelCatalog() {
|
||||
ensureRuntime().refreshModelCatalog()
|
||||
}
|
||||
|
||||
fun refreshAgents() {
|
||||
ensureRuntime().refreshAgents()
|
||||
}
|
||||
|
||||
fun refreshCronJobs() {
|
||||
ensureRuntime().refreshCronJobs()
|
||||
}
|
||||
|
||||
fun refreshUsage() {
|
||||
ensureRuntime().refreshUsage()
|
||||
}
|
||||
|
||||
fun refreshSkills() {
|
||||
ensureRuntime().refreshSkills()
|
||||
}
|
||||
|
||||
fun refreshNodesDevices() {
|
||||
ensureRuntime().refreshNodesDevices()
|
||||
}
|
||||
|
||||
fun refreshChannels() {
|
||||
ensureRuntime().refreshChannels()
|
||||
}
|
||||
|
||||
fun refreshDreaming() {
|
||||
ensureRuntime().refreshDreaming()
|
||||
}
|
||||
|
||||
fun refreshHealthLogs() {
|
||||
ensureRuntime().refreshHealthLogs()
|
||||
}
|
||||
|
||||
fun loadChat(sessionKey: String) {
|
||||
ensureRuntime().loadChat(sessionKey)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,148 +17,80 @@ import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class PermissionRequester internal constructor(
|
||||
class PermissionRequester(
|
||||
private val activity: ComponentActivity,
|
||||
launcherFactory: ((Map<String, Boolean>) -> Unit) -> ActivityResultLauncher<Array<String>>,
|
||||
) {
|
||||
private data class PendingPermissionRequest(
|
||||
val deferred: CompletableDeferred<Map<String, Boolean>>,
|
||||
var timedOut: Boolean = false,
|
||||
)
|
||||
|
||||
private class PermissionRequestSlot(
|
||||
val launcher: ActivityResultLauncher<Array<String>>,
|
||||
var request: PendingPermissionRequest? = null,
|
||||
)
|
||||
|
||||
constructor(activity: ComponentActivity) : this(
|
||||
activity = activity,
|
||||
launcherFactory = { callback ->
|
||||
activity.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions(), callback)
|
||||
},
|
||||
)
|
||||
|
||||
private val mutex = Mutex()
|
||||
private val requestSlotsLock = Any()
|
||||
private var pending: CompletableDeferred<Map<String, Boolean>>? = null
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val launchers = List(4) { createPermissionRequestSlot(launcherFactory) }
|
||||
|
||||
private val launcher: ActivityResultLauncher<Array<String>> =
|
||||
activity.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
|
||||
val p = pending
|
||||
pending = null
|
||||
p?.complete(result)
|
||||
}
|
||||
|
||||
suspend fun requestIfMissing(
|
||||
permissions: List<String>,
|
||||
timeoutMs: Long = 20_000,
|
||||
): Map<String, Boolean> {
|
||||
return mutex.withLock {
|
||||
while (true) {
|
||||
val missing =
|
||||
permissions.filter { perm ->
|
||||
ContextCompat.checkSelfPermission(activity, perm) != PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
if (missing.isEmpty()) {
|
||||
return permissions.associateWith { true }
|
||||
): Map<String, Boolean> =
|
||||
mutex.withLock {
|
||||
val missing =
|
||||
permissions.filter { perm ->
|
||||
ContextCompat.checkSelfPermission(activity, perm) != PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
val needsRationale =
|
||||
missing.any { ActivityCompat.shouldShowRequestPermissionRationale(activity, it) }
|
||||
if (needsRationale) {
|
||||
val proceed = showRationaleDialog(missing)
|
||||
if (!proceed) {
|
||||
return permissions.associateWith { perm ->
|
||||
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val deferred = CompletableDeferred<Map<String, Boolean>>()
|
||||
val request = PendingPermissionRequest(deferred)
|
||||
val slot = reservePermissionRequestSlot(request)
|
||||
try {
|
||||
withContext(Dispatchers.Main) {
|
||||
slot.launcher.launch(missing.toTypedArray())
|
||||
}
|
||||
} catch (err: Throwable) {
|
||||
clearPermissionRequestSlot(slot, request)
|
||||
throw err
|
||||
}
|
||||
|
||||
val result =
|
||||
try {
|
||||
withTimeout(timeoutMs) { deferred.await() }
|
||||
} catch (err: TimeoutCancellationException) {
|
||||
request.timedOut = true
|
||||
throw err
|
||||
}
|
||||
|
||||
val merged =
|
||||
permissions.associateWith { perm ->
|
||||
val nowGranted =
|
||||
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
|
||||
result[perm] == true || nowGranted
|
||||
}
|
||||
|
||||
val denied =
|
||||
merged.filterValues { !it }.keys.filter {
|
||||
!ActivityCompat.shouldShowRequestPermissionRationale(activity, it)
|
||||
}
|
||||
if (denied.isNotEmpty()) {
|
||||
showSettingsDialog(denied)
|
||||
}
|
||||
|
||||
return merged
|
||||
if (missing.isEmpty()) {
|
||||
return permissions.associateWith { true }
|
||||
}
|
||||
error("unreachable")
|
||||
}
|
||||
}
|
||||
|
||||
private fun createPermissionRequestSlot(
|
||||
launcherFactory: ((Map<String, Boolean>) -> Unit) -> ActivityResultLauncher<Array<String>>,
|
||||
): PermissionRequestSlot {
|
||||
var slot: PermissionRequestSlot? = null
|
||||
val launcher = launcherFactory { result -> completePermissionRequest(checkNotNull(slot), result) }
|
||||
val created = PermissionRequestSlot(launcher)
|
||||
slot = created
|
||||
return created
|
||||
}
|
||||
|
||||
private fun reservePermissionRequestSlot(request: PendingPermissionRequest): PermissionRequestSlot =
|
||||
synchronized(requestSlotsLock) {
|
||||
val slot = launchers.firstOrNull { it.request == null } ?: error("permission request launcher busy")
|
||||
slot.request = request
|
||||
slot
|
||||
}
|
||||
|
||||
private fun completePermissionRequest(
|
||||
slot: PermissionRequestSlot,
|
||||
result: Map<String, Boolean>,
|
||||
) {
|
||||
val request =
|
||||
synchronized(requestSlotsLock) {
|
||||
slot.request.also {
|
||||
slot.request = null
|
||||
val needsRationale =
|
||||
missing.any { ActivityCompat.shouldShowRequestPermissionRationale(activity, it) }
|
||||
if (needsRationale) {
|
||||
val proceed = showRationaleDialog(missing)
|
||||
if (!proceed) {
|
||||
return permissions.associateWith { perm ->
|
||||
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
} ?: return
|
||||
if (request.timedOut) return
|
||||
request.deferred.complete(result)
|
||||
}
|
||||
|
||||
private fun clearPermissionRequestSlot(
|
||||
slot: PermissionRequestSlot,
|
||||
request: PendingPermissionRequest,
|
||||
) {
|
||||
synchronized(requestSlotsLock) {
|
||||
if (slot.request === request) {
|
||||
slot.request = null
|
||||
}
|
||||
|
||||
val deferred = CompletableDeferred<Map<String, Boolean>>()
|
||||
pending = deferred
|
||||
withContext(Dispatchers.Main) {
|
||||
launcher.launch(missing.toTypedArray())
|
||||
}
|
||||
|
||||
val result =
|
||||
withContext(Dispatchers.Default) {
|
||||
kotlinx.coroutines.withTimeout(timeoutMs) { deferred.await() }
|
||||
}
|
||||
|
||||
// Merge: if something was already granted, treat it as granted even if launcher omitted it.
|
||||
val merged =
|
||||
permissions.associateWith { perm ->
|
||||
val nowGranted =
|
||||
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
|
||||
result[perm] == true || nowGranted
|
||||
}
|
||||
|
||||
val denied =
|
||||
merged.filterValues { !it }.keys.filter {
|
||||
!ActivityCompat.shouldShowRequestPermissionRationale(activity, it)
|
||||
}
|
||||
if (denied.isNotEmpty()) {
|
||||
showSettingsDialog(denied)
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun showRationaleDialog(permissions: List<String>): Boolean =
|
||||
withContext(Dispatchers.Main) {
|
||||
|
||||
@@ -58,7 +58,6 @@ class ChatController(
|
||||
|
||||
private val pendingRuns = mutableSetOf<String>()
|
||||
private val pendingRunTimeoutJobs = ConcurrentHashMap<String, Job>()
|
||||
private val optimisticMessagesByRunId = LinkedHashMap<String, ChatMessage>()
|
||||
private val pendingRunTimeoutMs = 120_000L
|
||||
|
||||
private var lastHealthPollAtMs: Long? = null
|
||||
@@ -77,7 +76,6 @@ class ChatController(
|
||||
fun load(sessionKey: String) {
|
||||
val key = normalizeRequestedSessionKey(sessionKey)
|
||||
_sessionKey.value = key
|
||||
optimisticMessagesByRunId.clear()
|
||||
scope.launch { bootstrap(forceHealth = true, refreshSessions = true) }
|
||||
}
|
||||
|
||||
@@ -115,7 +113,6 @@ class ChatController(
|
||||
if (key.isEmpty()) return
|
||||
if (key == _sessionKey.value) return
|
||||
_sessionKey.value = key
|
||||
optimisticMessagesByRunId.clear()
|
||||
// Keep the thread switch path lean: history + health are needed immediately,
|
||||
// but the session list is usually unchanged and can refresh on explicit pull-to-refresh.
|
||||
scope.launch { bootstrap(forceHealth = true, refreshSessions = false) }
|
||||
@@ -174,15 +171,14 @@ class ChatController(
|
||||
)
|
||||
}
|
||||
}
|
||||
val optimisticMessage =
|
||||
_messages.value =
|
||||
_messages.value +
|
||||
ChatMessage(
|
||||
id = UUID.randomUUID().toString(),
|
||||
role = "user",
|
||||
content = userContent,
|
||||
timestampMs = System.currentTimeMillis(),
|
||||
)
|
||||
optimisticMessagesByRunId[runId] = optimisticMessage
|
||||
_messages.value = _messages.value + optimisticMessage
|
||||
|
||||
armPendingRunTimeout(runId)
|
||||
synchronized(pendingRuns) {
|
||||
@@ -222,7 +218,6 @@ class ChatController(
|
||||
val res = session.request("chat.send", params.toString())
|
||||
val actualRunId = parseRunId(res) ?: runId
|
||||
if (actualRunId != runId) {
|
||||
optimisticMessagesByRunId[actualRunId] = optimisticMessagesByRunId.remove(runId) ?: optimisticMessage
|
||||
clearPendingRun(runId)
|
||||
armPendingRunTimeout(actualRunId)
|
||||
synchronized(pendingRuns) {
|
||||
@@ -233,7 +228,6 @@ class ChatController(
|
||||
true
|
||||
} catch (err: Throwable) {
|
||||
clearPendingRun(runId)
|
||||
removeOptimisticMessage(runId)
|
||||
_errorText.value = err.message
|
||||
false
|
||||
}
|
||||
@@ -308,7 +302,7 @@ class ChatController(
|
||||
|
||||
val historyJson = session.request("chat.history", """{"sessionKey":"$key"}""")
|
||||
val history = parseHistory(historyJson, sessionKey = key, previousMessages = _messages.value)
|
||||
_messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
|
||||
_messages.value = history.messages
|
||||
_sessionId.value = history.sessionId
|
||||
history.thinkingLevel
|
||||
?.trim()
|
||||
@@ -375,13 +369,7 @@ class ChatController(
|
||||
if (state == "error") {
|
||||
_errorText.value = payload["errorMessage"].asStringOrNull() ?: "Chat failed"
|
||||
}
|
||||
if (runId != null) {
|
||||
clearPendingRun(runId)
|
||||
optimisticMessagesByRunId.remove(runId)
|
||||
} else {
|
||||
clearPendingRuns()
|
||||
optimisticMessagesByRunId.clear()
|
||||
}
|
||||
if (runId != null) clearPendingRun(runId) else clearPendingRuns()
|
||||
pendingToolCallsById.clear()
|
||||
publishPendingToolCalls()
|
||||
_streamingAssistantText.value = null
|
||||
@@ -390,7 +378,7 @@ class ChatController(
|
||||
val historyJson =
|
||||
session.request("chat.history", """{"sessionKey":"${_sessionKey.value}"}""")
|
||||
val history = parseHistory(historyJson, sessionKey = _sessionKey.value, previousMessages = _messages.value)
|
||||
_messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
|
||||
_messages.value = history.messages
|
||||
_sessionId.value = history.sessionId
|
||||
history.thinkingLevel
|
||||
?.trim()
|
||||
@@ -483,7 +471,6 @@ class ChatController(
|
||||
}
|
||||
if (!stillPending) return@launch
|
||||
clearPendingRun(runId)
|
||||
removeOptimisticMessage(runId)
|
||||
_errorText.value = "Timed out waiting for a reply; try again or refresh."
|
||||
}
|
||||
}
|
||||
@@ -501,18 +488,12 @@ class ChatController(
|
||||
job.cancel()
|
||||
}
|
||||
pendingRunTimeoutJobs.clear()
|
||||
optimisticMessagesByRunId.clear()
|
||||
synchronized(pendingRuns) {
|
||||
pendingRuns.clear()
|
||||
_pendingRunCount.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeOptimisticMessage(runId: String) {
|
||||
val message = optimisticMessagesByRunId.remove(runId) ?: return
|
||||
_messages.value = _messages.value.filterNot { it.id == message.id }
|
||||
}
|
||||
|
||||
private fun parseHistory(
|
||||
historyJson: String,
|
||||
sessionKey: String,
|
||||
@@ -639,54 +620,11 @@ internal fun reconcileMessageIds(
|
||||
}
|
||||
}
|
||||
|
||||
internal fun mergeOptimisticMessages(
|
||||
incoming: List<ChatMessage>,
|
||||
optimistic: Collection<ChatMessage>,
|
||||
): List<ChatMessage> {
|
||||
if (optimistic.isEmpty()) return incoming
|
||||
|
||||
val unmatchedIncoming = incoming.toMutableList()
|
||||
val missingOptimistic =
|
||||
optimistic.filter { message ->
|
||||
val matchIndex =
|
||||
unmatchedIncoming.indexOfFirst { incomingMessage ->
|
||||
incomingMessageConsumesOptimistic(incomingMessage, message)
|
||||
}
|
||||
if (matchIndex >= 0) {
|
||||
unmatchedIncoming.removeAt(matchIndex)
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
if (missingOptimistic.isEmpty()) return incoming
|
||||
|
||||
return (incoming + missingOptimistic).sortedWith(compareBy<ChatMessage> { it.timestampMs ?: Long.MAX_VALUE }.thenBy { it.id })
|
||||
}
|
||||
|
||||
internal fun messageIdentityKey(message: ChatMessage): String? {
|
||||
val contentKey = messageContentIdentityKey(message) ?: return null
|
||||
val timestamp = message.timestampMs?.toString().orEmpty()
|
||||
if (timestamp.isEmpty() && contentKey.isEmpty()) return null
|
||||
return listOf(contentKey, timestamp).joinToString(separator = "|")
|
||||
}
|
||||
|
||||
private fun optimisticMessageIdentityKey(message: ChatMessage): String? = messageContentIdentityKey(message)
|
||||
|
||||
private fun incomingMessageConsumesOptimistic(
|
||||
incoming: ChatMessage,
|
||||
optimistic: ChatMessage,
|
||||
): Boolean {
|
||||
if (optimisticMessageIdentityKey(incoming) != optimisticMessageIdentityKey(optimistic)) return false
|
||||
val incomingTimestamp = incoming.timestampMs ?: return false
|
||||
val optimisticTimestamp = optimistic.timestampMs ?: return true
|
||||
return incomingTimestamp >= optimisticTimestamp
|
||||
}
|
||||
|
||||
private fun messageContentIdentityKey(message: ChatMessage): String? {
|
||||
val role = message.role.trim().lowercase()
|
||||
if (role.isEmpty()) return null
|
||||
|
||||
val timestamp = message.timestampMs?.toString().orEmpty()
|
||||
val contentFingerprint =
|
||||
message.content.joinToString(separator = "\u001E") { part ->
|
||||
listOf(
|
||||
@@ -704,7 +642,8 @@ private fun messageContentIdentityKey(message: ChatMessage): String? {
|
||||
).joinToString(separator = "\u001F")
|
||||
}
|
||||
|
||||
return listOf(role, contentFingerprint).joinToString(separator = "|")
|
||||
if (timestamp.isEmpty() && contentFingerprint.isEmpty()) return null
|
||||
return listOf(role, timestamp, contentFingerprint).joinToString(separator = "|")
|
||||
}
|
||||
|
||||
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
|
||||
@@ -68,20 +68,6 @@ data class GatewayConnectErrorDetails(
|
||||
val reason: String? = null,
|
||||
)
|
||||
|
||||
data class GatewayHelloSummary(
|
||||
val serverName: String?,
|
||||
val remoteAddress: String?,
|
||||
val serverVersion: String?,
|
||||
val mainSessionKey: String?,
|
||||
val updateAvailable: GatewayUpdateAvailableSummary?,
|
||||
)
|
||||
|
||||
data class GatewayUpdateAvailableSummary(
|
||||
val currentVersion: String?,
|
||||
val latestVersion: String?,
|
||||
val channel: String?,
|
||||
)
|
||||
|
||||
private data class SelectedConnectAuth(
|
||||
val authToken: String?,
|
||||
val authBootstrapToken: String?,
|
||||
@@ -100,7 +86,7 @@ class GatewaySession(
|
||||
private val scope: CoroutineScope,
|
||||
private val identityStore: DeviceIdentityStore,
|
||||
private val deviceAuthStore: DeviceAuthTokenStore,
|
||||
private val onConnected: (GatewayHelloSummary) -> Unit,
|
||||
private val onConnected: (serverName: String?, remoteAddress: String?, mainSessionKey: String?) -> Unit,
|
||||
private val onDisconnected: (message: String) -> Unit,
|
||||
private val onEvent: (event: String, payloadJson: String?) -> Unit,
|
||||
private val onInvoke: (suspend (InvokeRequest) -> InvokeResult)? = null,
|
||||
@@ -163,10 +149,7 @@ class GatewaySession(
|
||||
val tls: GatewayTlsParams?,
|
||||
)
|
||||
|
||||
private val lifecycleLock = Any()
|
||||
|
||||
@Volatile private var desired: DesiredConnection? = null
|
||||
|
||||
private var desired: DesiredConnection? = null
|
||||
private var job: Job? = null
|
||||
|
||||
@Volatile private var currentConnection: Connection? = null
|
||||
@@ -185,39 +168,26 @@ class GatewaySession(
|
||||
options: GatewayConnectOptions,
|
||||
tls: GatewayTlsParams? = null,
|
||||
) {
|
||||
val connectionToClose: Connection?
|
||||
synchronized(lifecycleLock) {
|
||||
desired = DesiredConnection(endpoint, token, bootstrapToken, password, options, tls)
|
||||
pendingDeviceTokenRetry = false
|
||||
deviceTokenRetryBudgetUsed = false
|
||||
reconnectPausedForAuthFailure = false
|
||||
connectionToClose = currentConnection
|
||||
if (job?.isActive != true) {
|
||||
job = scope.launch(Dispatchers.IO) { runLoop() }
|
||||
}
|
||||
desired = DesiredConnection(endpoint, token, bootstrapToken, password, options, tls)
|
||||
pendingDeviceTokenRetry = false
|
||||
deviceTokenRetryBudgetUsed = false
|
||||
reconnectPausedForAuthFailure = false
|
||||
if (job == null) {
|
||||
job = scope.launch(Dispatchers.IO) { runLoop() }
|
||||
}
|
||||
connectionToClose?.closeQuietly()
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
val jobToCancel: Job?
|
||||
val connectionToClose: Connection?
|
||||
synchronized(lifecycleLock) {
|
||||
desired = null
|
||||
pendingDeviceTokenRetry = false
|
||||
deviceTokenRetryBudgetUsed = false
|
||||
reconnectPausedForAuthFailure = false
|
||||
connectionToClose = currentConnection
|
||||
jobToCancel = job
|
||||
job = null
|
||||
}
|
||||
connectionToClose?.closeQuietly()
|
||||
desired = null
|
||||
pendingDeviceTokenRetry = false
|
||||
deviceTokenRetryBudgetUsed = false
|
||||
reconnectPausedForAuthFailure = false
|
||||
currentConnection?.closeQuietly()
|
||||
scope.launch(Dispatchers.IO) {
|
||||
jobToCancel?.cancelAndJoin()
|
||||
if (desired == null) {
|
||||
pluginSurfaceUrls = emptyMap()
|
||||
mainSessionKey = null
|
||||
}
|
||||
job?.cancelAndJoin()
|
||||
job = null
|
||||
pluginSurfaceUrls = emptyMap()
|
||||
mainSessionKey = null
|
||||
onDisconnected("Offline")
|
||||
}
|
||||
}
|
||||
@@ -346,22 +316,6 @@ class GatewaySession(
|
||||
return RpcResult(ok = res.ok, payloadJson = res.payloadJson, error = res.error)
|
||||
}
|
||||
|
||||
suspend fun sendRequestFrame(
|
||||
method: String,
|
||||
paramsJson: String?,
|
||||
timeoutMs: Long = 15_000,
|
||||
onError: (ErrorShape) -> Unit = {},
|
||||
) {
|
||||
val conn = currentConnection ?: throw IllegalStateException("not connected")
|
||||
val params =
|
||||
if (paramsJson.isNullOrBlank()) {
|
||||
null
|
||||
} else {
|
||||
json.parseToJsonElement(paramsJson)
|
||||
}
|
||||
conn.sendRequestFrame(method = method, params = params, timeoutMs = timeoutMs, onError = onError)
|
||||
}
|
||||
|
||||
private data class RpcResponse(
|
||||
val id: String,
|
||||
val ok: Boolean,
|
||||
@@ -406,12 +360,14 @@ class GatewaySession(
|
||||
val id = UUID.randomUUID().toString()
|
||||
val deferred = CompletableDeferred<RpcResponse>()
|
||||
pending[id] = deferred
|
||||
try {
|
||||
sendJson(buildRequestFrame(id = id, method = method, params = params))
|
||||
} catch (err: Throwable) {
|
||||
pending.remove(id)
|
||||
throw err
|
||||
}
|
||||
val frame =
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("req"))
|
||||
put("id", JsonPrimitive(id))
|
||||
put("method", JsonPrimitive(method))
|
||||
if (params != null) put("params", params)
|
||||
}
|
||||
sendJson(frame)
|
||||
return try {
|
||||
withTimeout(timeoutMs) { deferred.await() }
|
||||
} catch (err: TimeoutCancellationException) {
|
||||
@@ -420,57 +376,13 @@ class GatewaySession(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendRequestFrame(
|
||||
method: String,
|
||||
params: JsonElement?,
|
||||
timeoutMs: Long,
|
||||
onError: (ErrorShape) -> Unit,
|
||||
) {
|
||||
val id = UUID.randomUUID().toString()
|
||||
val deferred = CompletableDeferred<RpcResponse>()
|
||||
pending[id] = deferred
|
||||
try {
|
||||
sendJson(buildRequestFrame(id = id, method = method, params = params))
|
||||
} catch (err: Throwable) {
|
||||
pending.remove(id)
|
||||
throw err
|
||||
}
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val response =
|
||||
try {
|
||||
withTimeout(timeoutMs) { deferred.await() }
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
pending.remove(id)
|
||||
onError(ErrorShape("UNAVAILABLE", "request timeout"))
|
||||
return@launch
|
||||
}
|
||||
if (!response.ok) {
|
||||
onError(response.error ?: ErrorShape("UNAVAILABLE", "request failed"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendJson(obj: JsonObject) {
|
||||
val jsonString = obj.toString()
|
||||
writeLock.withLock {
|
||||
if (socket?.send(jsonString) != true) {
|
||||
throw IllegalStateException("gateway send failed")
|
||||
}
|
||||
socket?.send(jsonString)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildRequestFrame(
|
||||
id: String,
|
||||
method: String,
|
||||
params: JsonElement?,
|
||||
): JsonObject =
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("req"))
|
||||
put("id", JsonPrimitive(id))
|
||||
put("method", JsonPrimitive(method))
|
||||
if (params != null) put("params", params)
|
||||
}
|
||||
|
||||
suspend fun awaitClose() = closedDeferred.await()
|
||||
|
||||
fun closeQuietly() {
|
||||
@@ -618,8 +530,8 @@ class GatewaySession(
|
||||
val allowedOperatorScopes =
|
||||
setOf(
|
||||
"operator.approvals",
|
||||
"operator.pairing",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
"operator.write",
|
||||
)
|
||||
scopes.filter { allowedOperatorScopes.contains(it) }.distinct().sorted()
|
||||
@@ -662,9 +574,7 @@ class GatewaySession(
|
||||
pendingDeviceTokenRetry = false
|
||||
deviceTokenRetryBudgetUsed = false
|
||||
reconnectPausedForAuthFailure = false
|
||||
val server = obj["server"].asObjectOrNull()
|
||||
val serverName = server?.get("host").asStringOrNull()
|
||||
val serverVersion = server?.get("version").asStringOrNull()
|
||||
val serverName = obj["server"].asObjectOrNull()?.get("host").asStringOrNull()
|
||||
val authObj = obj["auth"].asObjectOrNull()
|
||||
val deviceToken = authObj?.get("deviceToken").asStringOrNull()
|
||||
val authRole = authObj?.get("role").asStringOrNull() ?: options.role
|
||||
@@ -702,33 +612,13 @@ class GatewaySession(
|
||||
?.let { normalized -> surface to normalized }
|
||||
} ?: emptyList()
|
||||
pluginSurfaceUrls = normalizedPluginSurfaceUrls.toMap()
|
||||
val snapshot = obj["snapshot"].asObjectOrNull()
|
||||
val sessionDefaults =
|
||||
snapshot
|
||||
obj["snapshot"]
|
||||
.asObjectOrNull()
|
||||
?.get("sessionDefaults")
|
||||
.asObjectOrNull()
|
||||
mainSessionKey = sessionDefaults?.get("mainSessionKey").asStringOrNull()
|
||||
onConnected(
|
||||
GatewayHelloSummary(
|
||||
serverName = serverName,
|
||||
remoteAddress = remoteAddress,
|
||||
serverVersion = serverVersion,
|
||||
mainSessionKey = mainSessionKey,
|
||||
updateAvailable = parseUpdateAvailable(snapshot?.get("updateAvailable").asObjectOrNull()),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseUpdateAvailable(value: JsonObject?): GatewayUpdateAvailableSummary? {
|
||||
if (value == null) return null
|
||||
val latestVersion = value["latestVersion"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val currentVersion = value["currentVersion"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val channel = value["channel"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
||||
return GatewayUpdateAvailableSummary(
|
||||
currentVersion = currentVersion,
|
||||
latestVersion = latestVersion,
|
||||
channel = channel,
|
||||
)
|
||||
onConnected(serverName, remoteAddress, mainSessionKey)
|
||||
}
|
||||
|
||||
private fun buildConnectParams(
|
||||
@@ -1015,11 +905,9 @@ class GatewaySession(
|
||||
conn.connect()
|
||||
conn.awaitClose()
|
||||
} finally {
|
||||
if (currentConnection === conn) {
|
||||
currentConnection = null
|
||||
pluginSurfaceUrls = emptyMap()
|
||||
mainSessionKey = null
|
||||
}
|
||||
currentConnection = null
|
||||
pluginSurfaceUrls = emptyMap()
|
||||
mainSessionKey = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1202,10 +1090,8 @@ internal fun shouldPauseGatewayReconnectAfterAuthFailure(
|
||||
role?.trim() == "node" &&
|
||||
scopes.isEmpty() &&
|
||||
error.details.reason == "not-paired" &&
|
||||
(
|
||||
error.details.pauseReconnect == false ||
|
||||
error.details.recommendedNextStep == "wait_then_retry"
|
||||
)
|
||||
(error.details.pauseReconnect == false ||
|
||||
error.details.recommendedNextStep == "wait_then_retry")
|
||||
)
|
||||
"AUTH_TOKEN_MISMATCH" -> deviceTokenRetryBudgetUsed && !pendingDeviceTokenRetry
|
||||
else -> false
|
||||
|
||||
@@ -53,10 +53,7 @@ fun buildGatewayTlsConfig(
|
||||
onStore: ((String) -> Unit)? = null,
|
||||
): GatewayTlsConfig? {
|
||||
if (params == null) return null
|
||||
val expected =
|
||||
params.expectedFingerprint
|
||||
?.let(::normalizeGatewayTlsFingerprint)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
val expected = params.expectedFingerprint?.let(::normalizeFingerprint)
|
||||
val defaultTrust = defaultTrustManager()
|
||||
|
||||
@SuppressLint("CustomX509TrustManager")
|
||||
@@ -203,7 +200,7 @@ private fun sha256Hex(data: ByteArray): String {
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
fun normalizeGatewayTlsFingerprint(raw: String): String {
|
||||
private fun normalizeFingerprint(raw: String): String {
|
||||
val stripped =
|
||||
raw
|
||||
.trim()
|
||||
|
||||
@@ -22,7 +22,6 @@ class ConnectionManager(
|
||||
private val readSmsAvailable: () -> Boolean,
|
||||
private val smsSearchPossible: () -> Boolean,
|
||||
private val callLogAvailable: () -> Boolean,
|
||||
private val photosAvailable: () -> Boolean,
|
||||
private val hasRecordAudioPermission: () -> Boolean,
|
||||
private val manualTls: () -> Boolean,
|
||||
) {
|
||||
@@ -97,7 +96,6 @@ class ConnectionManager(
|
||||
readSmsAvailable = readSmsAvailable(),
|
||||
smsSearchPossible = smsSearchPossible(),
|
||||
callLogAvailable = callLogAvailable(),
|
||||
photosAvailable = photosAvailable(),
|
||||
voiceWakeEnabled = voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission(),
|
||||
motionActivityAvailable = motionActivityAvailable(),
|
||||
motionPedometerAvailable = motionPedometerAvailable(),
|
||||
@@ -162,15 +160,7 @@ class ConnectionManager(
|
||||
fun buildOperatorConnectOptions(): GatewayConnectOptions =
|
||||
GatewayConnectOptions(
|
||||
role = "operator",
|
||||
// QR bootstrap hands Android a bounded operator token that includes approvals; keep the
|
||||
// default operator reconnect request aligned so the post-bootstrap loop can approve work.
|
||||
scopes =
|
||||
listOf(
|
||||
"operator.approvals",
|
||||
"operator.pairing",
|
||||
"operator.read",
|
||||
"operator.write",
|
||||
),
|
||||
scopes = listOf("operator.read", "operator.write", "operator.talk.secrets"),
|
||||
caps = emptyList(),
|
||||
commands = emptyList(),
|
||||
permissions = emptyMap(),
|
||||
|
||||
@@ -28,7 +28,6 @@ class DeviceHandler(
|
||||
private val appContext: Context,
|
||||
private val smsEnabled: Boolean = SensitiveFeatureConfig.smsEnabled,
|
||||
private val callLogEnabled: Boolean = SensitiveFeatureConfig.callLogEnabled,
|
||||
private val photosEnabled: Boolean = SensitiveFeatureConfig.photosEnabled,
|
||||
) {
|
||||
companion object {
|
||||
internal fun hasAnySmsCapability(
|
||||
@@ -151,9 +150,7 @@ class DeviceHandler(
|
||||
val smsReadGranted = hasPermission(Manifest.permission.READ_SMS)
|
||||
val notificationAccess = DeviceNotificationListenerService.isAccessEnabled(appContext)
|
||||
val photosGranted =
|
||||
if (!photosEnabled) {
|
||||
false
|
||||
} else if (Build.VERSION.SDK_INT >= 33) {
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
hasPermission(Manifest.permission.READ_MEDIA_IMAGES)
|
||||
} else {
|
||||
hasPermission(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
@@ -251,7 +248,7 @@ class DeviceHandler(
|
||||
"photos",
|
||||
permissionStateJson(
|
||||
granted = photosGranted,
|
||||
promptableWhenDenied = photosEnabled,
|
||||
promptableWhenDenied = true,
|
||||
),
|
||||
)
|
||||
put(
|
||||
|
||||
@@ -23,7 +23,6 @@ data class NodeRuntimeFlags(
|
||||
val readSmsAvailable: Boolean,
|
||||
val smsSearchPossible: Boolean,
|
||||
val callLogAvailable: Boolean,
|
||||
val photosAvailable: Boolean,
|
||||
val voiceWakeEnabled: Boolean,
|
||||
val motionActivityAvailable: Boolean,
|
||||
val motionPedometerAvailable: Boolean,
|
||||
@@ -38,7 +37,6 @@ enum class InvokeCommandAvailability {
|
||||
ReadSmsAvailable,
|
||||
RequestableSmsSearchAvailable,
|
||||
CallLogAvailable,
|
||||
PhotosAvailable,
|
||||
MotionActivityAvailable,
|
||||
MotionPedometerAvailable,
|
||||
DebugBuild,
|
||||
@@ -50,7 +48,6 @@ enum class NodeCapabilityAvailability {
|
||||
LocationEnabled,
|
||||
SmsAvailable,
|
||||
CallLogAvailable,
|
||||
PhotosAvailable,
|
||||
VoiceWakeEnabled,
|
||||
MotionAvailable,
|
||||
}
|
||||
@@ -90,10 +87,7 @@ object InvokeCommandRegistry {
|
||||
name = OpenClawCapability.Location.rawValue,
|
||||
availability = NodeCapabilityAvailability.LocationEnabled,
|
||||
),
|
||||
NodeCapabilitySpec(
|
||||
name = OpenClawCapability.Photos.rawValue,
|
||||
availability = NodeCapabilityAvailability.PhotosAvailable,
|
||||
),
|
||||
NodeCapabilitySpec(name = OpenClawCapability.Photos.rawValue),
|
||||
NodeCapabilitySpec(name = OpenClawCapability.Contacts.rawValue),
|
||||
NodeCapabilitySpec(name = OpenClawCapability.Calendar.rawValue),
|
||||
NodeCapabilitySpec(
|
||||
@@ -194,7 +188,6 @@ object InvokeCommandRegistry {
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawPhotosCommand.Latest.rawValue,
|
||||
availability = InvokeCommandAvailability.PhotosAvailable,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawContactsCommand.Search.rawValue,
|
||||
@@ -251,7 +244,6 @@ object InvokeCommandRegistry {
|
||||
NodeCapabilityAvailability.LocationEnabled -> flags.locationEnabled
|
||||
NodeCapabilityAvailability.SmsAvailable -> flags.sendSmsAvailable || flags.readSmsAvailable
|
||||
NodeCapabilityAvailability.CallLogAvailable -> flags.callLogAvailable
|
||||
NodeCapabilityAvailability.PhotosAvailable -> flags.photosAvailable
|
||||
NodeCapabilityAvailability.VoiceWakeEnabled -> flags.voiceWakeEnabled
|
||||
NodeCapabilityAvailability.MotionAvailable -> flags.motionActivityAvailable || flags.motionPedometerAvailable
|
||||
}
|
||||
@@ -268,7 +260,6 @@ object InvokeCommandRegistry {
|
||||
InvokeCommandAvailability.ReadSmsAvailable -> flags.readSmsAvailable
|
||||
InvokeCommandAvailability.RequestableSmsSearchAvailable -> flags.smsSearchPossible
|
||||
InvokeCommandAvailability.CallLogAvailable -> flags.callLogAvailable
|
||||
InvokeCommandAvailability.PhotosAvailable -> flags.photosAvailable
|
||||
InvokeCommandAvailability.MotionActivityAvailable -> flags.motionActivityAvailable
|
||||
InvokeCommandAvailability.MotionPedometerAvailable -> flags.motionPedometerAvailable
|
||||
InvokeCommandAvailability.DebugBuild -> flags.debugBuild
|
||||
|
||||
@@ -77,7 +77,6 @@ class InvokeDispatcher(
|
||||
private val smsFeatureEnabled: () -> Boolean,
|
||||
private val smsTelephonyAvailable: () -> Boolean,
|
||||
private val callLogAvailable: () -> Boolean,
|
||||
private val photosAvailable: () -> Boolean,
|
||||
private val debugBuild: () -> Boolean,
|
||||
private val onCanvasA2uiPush: () -> Unit,
|
||||
private val onCanvasA2uiReset: () -> Unit,
|
||||
@@ -326,15 +325,6 @@ class InvokeDispatcher(
|
||||
message = "CALL_LOG_UNAVAILABLE: call log not available on this build",
|
||||
)
|
||||
}
|
||||
InvokeCommandAvailability.PhotosAvailable ->
|
||||
if (photosAvailable()) {
|
||||
null
|
||||
} else {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "PHOTOS_UNAVAILABLE",
|
||||
message = "PHOTOS_UNAVAILABLE: photos not available on this build",
|
||||
)
|
||||
}
|
||||
InvokeCommandAvailability.DebugBuild ->
|
||||
if (debugBuild()) {
|
||||
null
|
||||
|
||||
@@ -27,23 +27,28 @@ internal object JpegSizeLimiter {
|
||||
require(initialWidth > 0 && initialHeight > 0) { "Invalid image size" }
|
||||
require(maxBytes > 0) { "Invalid maxBytes" }
|
||||
|
||||
val clampedStartQuality = startQuality.coerceIn(minQuality, 100)
|
||||
var width = initialWidth
|
||||
var height = initialHeight
|
||||
var best: JpegSizeLimiterResult? = null
|
||||
val clampedStartQuality = startQuality.coerceIn(minQuality, 100)
|
||||
var best =
|
||||
JpegSizeLimiterResult(
|
||||
bytes = encode(width, height, clampedStartQuality),
|
||||
width = width,
|
||||
height = height,
|
||||
quality = clampedStartQuality,
|
||||
)
|
||||
if (best.bytes.size <= maxBytes) return best
|
||||
|
||||
repeat(maxScaleAttempts + 1) { scaleAttempt ->
|
||||
repeat(maxScaleAttempts) {
|
||||
var quality = clampedStartQuality
|
||||
repeat(maxQualityAttempts) {
|
||||
val bytes = encode(width, height, quality)
|
||||
val attempt = JpegSizeLimiterResult(bytes = bytes, width = width, height = height, quality = quality)
|
||||
best = attempt
|
||||
best = JpegSizeLimiterResult(bytes = bytes, width = width, height = height, quality = quality)
|
||||
if (bytes.size <= maxBytes) return best
|
||||
if (quality <= minQuality) return@repeat
|
||||
quality = max(minQuality, (quality * 0.75).roundToInt())
|
||||
}
|
||||
|
||||
if (scaleAttempt == maxScaleAttempts) return@repeat
|
||||
val minScale = (minSize.toDouble() / min(width, height).toDouble()).coerceAtMost(1.0)
|
||||
val nextScale = max(scaleStep, minScale)
|
||||
val nextWidth = max(minSize, (width * nextScale).roundToInt())
|
||||
@@ -53,11 +58,10 @@ internal object JpegSizeLimiter {
|
||||
height = min(nextHeight, height)
|
||||
}
|
||||
|
||||
val failed = checkNotNull(best)
|
||||
if (failed.bytes.size > maxBytes) {
|
||||
throw IllegalStateException("CAMERA_TOO_LARGE: ${failed.bytes.size} bytes > $maxBytes bytes")
|
||||
if (best.bytes.size > maxBytes) {
|
||||
throw IllegalStateException("CAMERA_TOO_LARGE: ${best.bytes.size} bytes > $maxBytes bytes")
|
||||
}
|
||||
|
||||
return failed
|
||||
return best
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawPrimaryButton
|
||||
import ai.openclaw.app.ui.design.ClawSecondaryButton
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ScreenShare
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Surface
|
||||
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.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
internal fun CanvasSettingsScreen(
|
||||
viewModel: MainViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val currentUrl by viewModel.canvasCurrentUrl.collectAsState()
|
||||
val hydrated by viewModel.canvasA2uiHydrated.collectAsState()
|
||||
val rehydratePending by viewModel.canvasRehydratePending.collectAsState()
|
||||
val rehydrateErrorText by viewModel.canvasRehydrateErrorText.collectAsState()
|
||||
val hasLivePage = currentUrl?.isNotBlank() == true
|
||||
val showCanvasSurface = isConnected
|
||||
val canvasLabel = if (hasLivePage) "Live page" else "Home canvas"
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
viewModel.refreshHomeCanvasOverviewIfConnected()
|
||||
}
|
||||
}
|
||||
|
||||
SettingsDetailFrame(
|
||||
title = "Canvas",
|
||||
subtitle = "Current screen output and interactive app surface.",
|
||||
icon = Icons.AutoMirrored.Filled.ScreenShare,
|
||||
onBack = onBack,
|
||||
) {
|
||||
SettingsMetricPanel(
|
||||
rows =
|
||||
listOf(
|
||||
SettingsMetric("Connection", if (isConnected) "Online" else "Offline"),
|
||||
SettingsMetric("Surface", canvasLabel),
|
||||
SettingsMetric("Bridge", if (hasLivePage && hydrated) "Ready" else "Standby"),
|
||||
),
|
||||
)
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ClawPrimaryButton(
|
||||
text = if (rehydratePending) "Refreshing" else "Refresh Screen",
|
||||
onClick = { viewModel.requestCanvasRehydrate(source = "settings_canvas") },
|
||||
enabled = isConnected && !rehydratePending,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
ClawSecondaryButton(
|
||||
text = "Reconnect",
|
||||
onClick = viewModel::refreshGatewayConnection,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
rehydrateErrorText?.let {
|
||||
ClawPanel {
|
||||
Text(text = it, style = ClawTheme.type.body, color = ClawTheme.colors.warning)
|
||||
}
|
||||
}
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp)) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(text = canvasLabel, style = ClawTheme.type.section, color = ClawTheme.colors.text, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().height(520.dp).clip(RoundedCornerShape(ClawTheme.radii.panel)),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.panel),
|
||||
color = ClawTheme.colors.canvas,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Box {
|
||||
if (showCanvasSurface) {
|
||||
CanvasScreen(viewModel = viewModel, visible = true, modifier = Modifier.fillMaxWidth().height(520.dp))
|
||||
} else {
|
||||
CanvasStandbyPanel(isConnected = isConnected)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CanvasStandbyPanel(isConnected: Boolean) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().height(520.dp).padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(54.dp),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.panel),
|
||||
color = ClawTheme.colors.surfacePressed,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.borderStrong),
|
||||
contentColor = ClawTheme.colors.text,
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = Icons.AutoMirrored.Filled.ScreenShare, contentDescription = null, modifier = Modifier.size(26.dp))
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = if (isConnected) "Screen surface ready" else "Connect the gateway",
|
||||
style = ClawTheme.type.title,
|
||||
color = ClawTheme.colors.text,
|
||||
modifier = Modifier.padding(top = 18.dp),
|
||||
)
|
||||
Text(
|
||||
text = if (isConnected) "Canvas output appears here when OpenClaw opens an app surface." else "Canvas output needs an active gateway connection.",
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.GatewayChannelSummary
|
||||
import ai.openclaw.app.GatewayChannelsSummary
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.ui.design.ClawDetailRow
|
||||
import ai.openclaw.app.ui.design.ClawListPanel
|
||||
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.ClawTextBadge
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
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.filled.Notifications
|
||||
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.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
internal fun ChannelsSettingsScreen(
|
||||
viewModel: MainViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val summary by viewModel.channelsSummary.collectAsState()
|
||||
val refreshing by viewModel.channelsRefreshing.collectAsState()
|
||||
val errorText by viewModel.channelsErrorText.collectAsState()
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val channels = summary.channels
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
viewModel.refreshChannels()
|
||||
}
|
||||
}
|
||||
|
||||
SettingsDetailFrame(
|
||||
title = "Channels",
|
||||
subtitle = "Messaging surfaces connected to this gateway.",
|
||||
icon = Icons.Default.Notifications,
|
||||
onBack = onBack,
|
||||
) {
|
||||
SettingsMetricPanel(
|
||||
rows =
|
||||
listOf(
|
||||
SettingsMetric("Channels", channels.size.toString()),
|
||||
SettingsMetric("Connected", channels.count { it.connected }.toString()),
|
||||
SettingsMetric("Configured", channels.count { it.configured }.toString()),
|
||||
SettingsMetric("Issues", channels.count { it.error != null }.toString()),
|
||||
),
|
||||
)
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ClawSecondaryButton(
|
||||
text = if (refreshing) "Refreshing" else "Refresh",
|
||||
onClick = viewModel::refreshChannels,
|
||||
enabled = isConnected && !refreshing,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
errorText?.let { error ->
|
||||
ClawPanel {
|
||||
Text(text = error, style = ClawTheme.type.body, color = ClawTheme.colors.warning)
|
||||
}
|
||||
}
|
||||
if (summary.partial || summary.warnings.isNotEmpty()) {
|
||||
ClawPanel {
|
||||
Text(text = channelsWarningText(summary), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
when {
|
||||
!isConnected ->
|
||||
ClawPanel {
|
||||
Text(text = "Connect the gateway to load channels.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
channels.isEmpty() ->
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(text = "No channels found.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = "Telegram, WhatsApp, email, and other channels appear here after setup.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
else -> ChannelsPanel(channels = channels)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChannelsPanel(channels: List<GatewayChannelSummary>) {
|
||||
ClawListPanel(items = channels) { channel ->
|
||||
ChannelRow(channel = channel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChannelRow(channel: GatewayChannelSummary) {
|
||||
ClawDetailRow(
|
||||
title = channel.label,
|
||||
subtitle = channelSubtitle(channel),
|
||||
leading = { ClawTextBadge(text = channelBadge(channel.label)) },
|
||||
trailing = { ClawStatusPill(text = channelStatusText(channel), status = channelStatus(channel)) },
|
||||
)
|
||||
}
|
||||
|
||||
private fun channelSubtitle(channel: GatewayChannelSummary): String {
|
||||
val accounts =
|
||||
when (channel.accountCount) {
|
||||
0 -> null
|
||||
1 -> "1 account"
|
||||
else -> "${channel.accountCount} accounts"
|
||||
}
|
||||
val lifecycle =
|
||||
when {
|
||||
channel.connected -> "Connected"
|
||||
channel.running -> "Running"
|
||||
channel.linked -> "Linked"
|
||||
channel.configured -> "Configured"
|
||||
channel.enabled -> "Enabled"
|
||||
else -> "Off"
|
||||
}
|
||||
return listOfNotNull(accounts, lifecycle, channel.error).joinToString(" · ")
|
||||
}
|
||||
|
||||
private fun channelStatusText(channel: GatewayChannelSummary): String =
|
||||
when {
|
||||
channel.error != null -> "Issue"
|
||||
channel.connected -> "Connected"
|
||||
channel.running -> "Running"
|
||||
channel.linked || channel.configured -> "Ready"
|
||||
channel.enabled -> "Setup"
|
||||
else -> "Off"
|
||||
}
|
||||
|
||||
private fun channelStatus(channel: GatewayChannelSummary): ClawStatus =
|
||||
when {
|
||||
channel.error != null -> ClawStatus.Danger
|
||||
channel.connected || channel.running -> ClawStatus.Success
|
||||
channel.linked || channel.configured -> ClawStatus.Neutral
|
||||
channel.enabled -> ClawStatus.Warning
|
||||
else -> ClawStatus.Neutral
|
||||
}
|
||||
|
||||
private fun channelBadge(label: String): String =
|
||||
label
|
||||
.split(' ', '-', '_')
|
||||
.filter { it.isNotBlank() }
|
||||
.take(2)
|
||||
.mapNotNull { it.firstOrNull()?.uppercaseChar()?.toString() }
|
||||
.joinToString("")
|
||||
.ifBlank { "C" }
|
||||
|
||||
private fun channelsWarningText(summary: GatewayChannelsSummary): String = summary.warnings.firstOrNull()?.takeIf { it.isNotBlank() } ?: "Some channel status checks did not complete."
|
||||
@@ -1,320 +0,0 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.GatewayModelProviderSummary
|
||||
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.ClawScaffold
|
||||
import ai.openclaw.app.ui.design.ClawSeparatedColumn
|
||||
import ai.openclaw.app.ui.design.ClawTextField
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.outlined.AccessTime
|
||||
import androidx.compose.material.icons.outlined.ChatBubbleOutline
|
||||
import androidx.compose.material.icons.outlined.Inventory2
|
||||
import androidx.compose.material.icons.outlined.MicNone
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
internal fun CommandPalette(
|
||||
viewModel: MainViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
onOpenChat: () -> Unit,
|
||||
onOpenVoice: () -> Unit,
|
||||
onOpenSessions: () -> Unit,
|
||||
onOpenProviders: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenSession: (String) -> Unit,
|
||||
) {
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val sessions by viewModel.chatSessions.collectAsState()
|
||||
val models by viewModel.modelCatalog.collectAsState()
|
||||
val providers by viewModel.modelAuthProviders.collectAsState()
|
||||
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
|
||||
var query by rememberSaveable { mutableStateOf("") }
|
||||
val normalizedQuery = query.trim().lowercase()
|
||||
val quickActions =
|
||||
listOf(
|
||||
CommandItem("Open Chat", "Start or continue a conversation", Icons.Outlined.ChatBubbleOutline, onOpenChat),
|
||||
CommandItem("Start Voice", "Talk or dictate with OpenClaw", Icons.Outlined.MicNone, onOpenVoice),
|
||||
CommandItem("Browse Sessions", "Find previous conversations", Icons.Outlined.AccessTime, onOpenSessions),
|
||||
CommandItem("Providers & Models", providerCommandSubtitle(isConnected, providers, models), Icons.Outlined.Inventory2, onOpenProviders),
|
||||
CommandItem("Settings", "Gateway, voice, notifications, privacy", Icons.Outlined.Settings, onOpenSettings),
|
||||
)
|
||||
val actionRows = quickActions.filter { it.matches(normalizedQuery) }
|
||||
val sessionRows =
|
||||
sessions
|
||||
.filter { session ->
|
||||
val title = commandSessionTitle(session.displayName)
|
||||
normalizedQuery.isEmpty() || title.lowercase().contains(normalizedQuery)
|
||||
}.take(5)
|
||||
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = ClawTheme.colors.canvas, contentColor = ClawTheme.colors.text) {
|
||||
ClawScaffold(contentPadding = PaddingValues(start = 20.dp, top = 14.dp, end = 20.dp, bottom = 20.dp)) {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
ClawTextField(value = query, onValueChange = { query = it }, placeholder = "Search OpenClaw")
|
||||
}
|
||||
|
||||
item {
|
||||
CommandSectionLabel(title = "Quick actions")
|
||||
}
|
||||
|
||||
if (actionRows.isEmpty()) {
|
||||
item {
|
||||
ClawEmptyState(title = "No actions found", body = "Try Chat, Voice, Sessions, Providers, or Settings.")
|
||||
}
|
||||
} else {
|
||||
item {
|
||||
CommandActionList(rows = actionRows)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
CommandSectionLabel(title = "Sessions")
|
||||
}
|
||||
|
||||
if (sessionRows.isEmpty()) {
|
||||
item {
|
||||
ClawPanel {
|
||||
Text(
|
||||
text = if (isConnected) "No matching sessions yet." else "Connect the Gateway to search sessions.",
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
item {
|
||||
CommandSessionList(
|
||||
rows =
|
||||
sessionRows.map { session ->
|
||||
CommandSessionRow(
|
||||
key = session.key,
|
||||
title = commandSessionTitle(session.displayName),
|
||||
subtitle = if (pendingRunCount > 0) "Assistant working" else "OpenClaw session",
|
||||
metadata = session.updatedAtMs?.let(::commandRelativeTime) ?: "now",
|
||||
)
|
||||
},
|
||||
onOpen = onOpenSession,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class CommandItem(
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val icon: ImageVector,
|
||||
val onClick: () -> Unit,
|
||||
) {
|
||||
fun matches(query: String): Boolean = query.isEmpty() || title.lowercase().contains(query) || subtitle.lowercase().contains(query)
|
||||
}
|
||||
|
||||
private data class CommandSessionRow(
|
||||
val key: String,
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val metadata: String,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun CommandActionList(rows: List<CommandItem>) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp)) {
|
||||
ClawSeparatedColumn(items = rows) { row ->
|
||||
CommandActionRow(row = row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CommandActionRow(row: CommandItem) {
|
||||
Surface(color = Color.Transparent, contentColor = ClawTheme.colors.text) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 52.dp)
|
||||
.clip(RoundedCornerShape(ClawTheme.radii.row))
|
||||
.clickable(onClick = row.onClick)
|
||||
.padding(horizontal = 2.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
Icon(imageVector = row.icon, contentDescription = null, modifier = Modifier.size(19.dp), tint = ClawTheme.colors.text)
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
|
||||
Text(text = row.title, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(text = row.subtitle, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = "Open ${row.title}",
|
||||
modifier = Modifier.size(17.dp),
|
||||
tint = ClawTheme.colors.textMuted,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CommandSessionList(
|
||||
rows: List<CommandSessionRow>,
|
||||
onOpen: (String) -> Unit,
|
||||
) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp)) {
|
||||
ClawSeparatedColumn(items = rows) { row ->
|
||||
CommandSessionListRow(row = row, onClick = { onOpen(row.key) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CommandSessionListRow(
|
||||
row: CommandSessionRow,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(color = ClawTheme.colors.canvas, contentColor = ClawTheme.colors.text) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 58.dp)
|
||||
.clip(RoundedCornerShape(ClawTheme.radii.row))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 2.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(30.dp),
|
||||
shape = CircleShape,
|
||||
color = ClawTheme.colors.canvas,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.borderStrong),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = Icons.Outlined.ChatBubbleOutline, contentDescription = null, modifier = Modifier.size(15.dp), tint = ClawTheme.colors.text)
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
|
||||
Text(text = row.title, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1)
|
||||
Text(text = row.subtitle, style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle, maxLines = 1)
|
||||
}
|
||||
Text(text = row.metadata, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = "Open session",
|
||||
modifier = Modifier.size(17.dp),
|
||||
tint = ClawTheme.colors.textMuted,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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(
|
||||
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 CommandSectionLabel(title: String) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(text = title.uppercase(), style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
|
||||
private fun providerCommandSubtitle(
|
||||
isConnected: Boolean,
|
||||
providers: List<GatewayModelProviderSummary>,
|
||||
models: List<GatewayModelSummary>,
|
||||
): String {
|
||||
if (!isConnected) return "Connect Gateway to load models"
|
||||
val readyProviderCount = providers.count { modelProviderReady(it.status) }
|
||||
if (readyProviderCount > 0) return "$readyProviderCount providers ready"
|
||||
if (models.isNotEmpty()) return "${models.size} models available"
|
||||
return "Configure model access"
|
||||
}
|
||||
|
||||
private fun commandSessionTitle(displayName: String?): String = displayName?.takeIf { it.isNotBlank() } ?: "Main session"
|
||||
|
||||
private fun commandRelativeTime(updatedAtMs: Long): String {
|
||||
val deltaMs = (System.currentTimeMillis() - updatedAtMs).coerceAtLeast(0L)
|
||||
val minutes = deltaMs / 60_000L
|
||||
if (minutes < 1) return "now"
|
||||
if (minutes < 60) return "${minutes}m"
|
||||
val hours = minutes / 60
|
||||
if (hours < 24) return "${hours}h"
|
||||
return "${hours / 24}d"
|
||||
}
|
||||
@@ -100,14 +100,8 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
containerColor = mobileCardSurface,
|
||||
title = { Text("Trust this gateway?", style = mobileHeadline, color = mobileText) },
|
||||
text = {
|
||||
val message =
|
||||
if (prompt.previousFingerprintSha256.isNullOrBlank()) {
|
||||
"First-time TLS connection.\n\nVerify this SHA-256 fingerprint before trusting:\n${prompt.fingerprintSha256}"
|
||||
} else {
|
||||
"The gateway TLS certificate changed. Only continue if you expected this.\n\nOld SHA-256 fingerprint:\n${prompt.previousFingerprintSha256}\n\nNew SHA-256 fingerprint:\n${prompt.fingerprintSha256}"
|
||||
}
|
||||
Text(
|
||||
message,
|
||||
"First-time TLS connection.\n\nVerify this SHA-256 fingerprint before trusting:\n${prompt.fingerprintSha256}",
|
||||
style = mobileCallout,
|
||||
color = mobileText,
|
||||
)
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.GatewayDreamDiaryEntry
|
||||
import ai.openclaw.app.GatewayDreamingSummary
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawSecondaryButton
|
||||
import ai.openclaw.app.ui.design.ClawStatus
|
||||
import ai.openclaw.app.ui.design.ClawStatusPill
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Surface
|
||||
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.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
internal fun DreamingSettingsScreen(
|
||||
viewModel: MainViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val summary by viewModel.dreamingSummary.collectAsState()
|
||||
val refreshing by viewModel.dreamingRefreshing.collectAsState()
|
||||
val errorText by viewModel.dreamingErrorText.collectAsState()
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
viewModel.refreshDreaming()
|
||||
}
|
||||
}
|
||||
|
||||
SettingsDetailFrame(
|
||||
title = "Dreaming",
|
||||
subtitle = "Memory consolidation and dream diary.",
|
||||
icon = Icons.Default.Storage,
|
||||
onBack = onBack,
|
||||
) {
|
||||
SettingsMetricPanel(
|
||||
rows =
|
||||
listOf(
|
||||
SettingsMetric("Status", if (summary.enabled) "On" else "Off"),
|
||||
SettingsMetric("Waiting", summary.shortTermCount.toString()),
|
||||
SettingsMetric("Signals", summary.totalSignalCount.toString()),
|
||||
SettingsMetric("Next Cycle", formatDreamingNextRun(summary.nextRunAtMs)),
|
||||
),
|
||||
)
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ClawSecondaryButton(
|
||||
text = if (refreshing) "Refreshing" else "Refresh",
|
||||
onClick = viewModel::refreshDreaming,
|
||||
enabled = isConnected && !refreshing,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
errorText?.let { error ->
|
||||
ClawPanel {
|
||||
Text(text = error, style = ClawTheme.type.body, color = ClawTheme.colors.warning)
|
||||
}
|
||||
}
|
||||
when {
|
||||
!isConnected ->
|
||||
ClawPanel {
|
||||
Text(text = "Connect the gateway to load dreaming.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
else -> DreamingPanel(summary = summary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DreamingPanel(summary: GatewayDreamingSummary) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
Column {
|
||||
DreamingHealthRow(
|
||||
title = "Memory Store",
|
||||
value = if (summary.storeHealthy) "Healthy" else "Needs attention",
|
||||
healthy = summary.storeHealthy,
|
||||
)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
DreamingHealthRow(
|
||||
title = "Signal Index",
|
||||
value = if (summary.phaseSignalHealthy) "Healthy" else "Needs attention",
|
||||
healthy = summary.phaseSignalHealthy,
|
||||
)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
DreamingHealthRow(
|
||||
title = "Promoted",
|
||||
value = "${summary.promotedToday} today · ${summary.promotedTotal} total",
|
||||
healthy = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
DreamDiaryPanel(summary = summary)
|
||||
}
|
||||
}
|
||||
|
||||
@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)) {
|
||||
Text(text = "DIARY", style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
|
||||
if (!summary.diaryFound) {
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(text = "No dream diary yet.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = "Entries appear after a dreaming cycle writes a narrative summary.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (summary.diaryEntries.isEmpty()) {
|
||||
ClawPanel {
|
||||
Text(text = "The diary is waiting for its first entry.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
return
|
||||
}
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
Column {
|
||||
summary.diaryEntries.forEachIndexed { index, entry ->
|
||||
DreamDiaryRow(entry = entry)
|
||||
if (index != summary.diaryEntries.lastIndex) {
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DreamDiaryRow(entry: GatewayDreamDiaryEntry) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(30.dp),
|
||||
shape = CircleShape,
|
||||
color = ClawTheme.colors.surfacePressed,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(text = "D", style = ClawTheme.type.label, color = ClawTheme.colors.text)
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
|
||||
Text(text = entry.date, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(text = entry.text, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDreamingNextRun(nextRunAtMs: Long?): String {
|
||||
val next = nextRunAtMs ?: return "Not scheduled"
|
||||
val deltaMinutes = ((next - System.currentTimeMillis()) / 60_000L).coerceAtLeast(0L)
|
||||
val hours = deltaMinutes / 60L
|
||||
return when {
|
||||
hours >= 24L -> "In ${hours / 24L}d"
|
||||
hours >= 1L -> "In ${hours}h"
|
||||
deltaMinutes >= 1L -> "In ${deltaMinutes}m"
|
||||
else -> "Soon"
|
||||
}
|
||||
}
|
||||
@@ -143,15 +143,27 @@ internal fun parseGatewayEndpointResult(rawInput: String): GatewayEndpointParseR
|
||||
?.trim()
|
||||
?.lowercase(Locale.US)
|
||||
.orEmpty()
|
||||
if (scheme !in setOf("ws", "wss", "http", "https")) {
|
||||
return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INVALID_URL)
|
||||
}
|
||||
val tls = scheme == "wss" || scheme == "https"
|
||||
val tls =
|
||||
when (scheme) {
|
||||
"ws", "http" -> false
|
||||
"wss", "https" -> true
|
||||
else -> true
|
||||
}
|
||||
if (!tls && !isLoopbackGatewayHost(host)) {
|
||||
return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INSECURE_REMOTE_URL)
|
||||
}
|
||||
val defaultPort = if (tls) 443 else 18789
|
||||
val displayPort = if (tls) 443 else 80
|
||||
val defaultPort =
|
||||
when (scheme) {
|
||||
"wss", "https" -> 443
|
||||
"ws", "http" -> 18789
|
||||
else -> 443
|
||||
}
|
||||
val displayPort =
|
||||
when (scheme) {
|
||||
"wss", "https" -> 443
|
||||
"ws", "http" -> 80
|
||||
else -> 443
|
||||
}
|
||||
val port = uri.port.takeIf { it in 1..65535 } ?: defaultPort
|
||||
val displayHost = if (host.contains(":")) "[$host]" else host
|
||||
val displayUrl =
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.GatewayHealthLogsSummary
|
||||
import ai.openclaw.app.GatewayLogEntry
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawSecondaryButton
|
||||
import ai.openclaw.app.ui.design.ClawStatus
|
||||
import ai.openclaw.app.ui.design.ClawStatusPill
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
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.filled.Settings
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
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.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
internal fun HealthLogsSettingsScreen(
|
||||
viewModel: MainViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val isNodeConnected by viewModel.isNodeConnected.collectAsState()
|
||||
val chatHealthOk by viewModel.chatHealthOk.collectAsState()
|
||||
val statusText by viewModel.statusText.collectAsState()
|
||||
val modelCount by viewModel.modelCatalog.collectAsState()
|
||||
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
|
||||
val talkStatus by viewModel.talkModeStatusText.collectAsState()
|
||||
val logsSummary by viewModel.healthLogsSummary.collectAsState()
|
||||
val logsRefreshing by viewModel.healthLogsRefreshing.collectAsState()
|
||||
val logsErrorText by viewModel.healthLogsErrorText.collectAsState()
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
viewModel.refreshHealthLogs()
|
||||
}
|
||||
}
|
||||
|
||||
SettingsDetailFrame(
|
||||
title = "Health",
|
||||
subtitle = "Gateway status, phone node readiness, and recent log stream.",
|
||||
icon = Icons.Default.Settings,
|
||||
onBack = onBack,
|
||||
) {
|
||||
SettingsMetricPanel(
|
||||
rows =
|
||||
listOf(
|
||||
SettingsMetric("Gateway", if (isConnected) "Online" else "Offline"),
|
||||
SettingsMetric("Node", if (isNodeConnected) "Online" else "Waiting"),
|
||||
SettingsMetric("Models", modelCount.size.toString()),
|
||||
SettingsMetric("Logs", logsSummary.entries.size.toString()),
|
||||
),
|
||||
)
|
||||
HealthStatusPanel(
|
||||
gateway = statusText,
|
||||
node = if (isNodeConnected) "Online" else "Waiting",
|
||||
chat = if (chatHealthOk) "Ready" else "Needs connection",
|
||||
models = "${modelCount.size} available",
|
||||
voice = talkStatus,
|
||||
runs = if (pendingRunCount > 0) "$pendingRunCount active" else "Idle",
|
||||
isConnected = isConnected,
|
||||
isNodeConnected = isNodeConnected,
|
||||
chatHealthOk = chatHealthOk,
|
||||
modelsReady = modelCount.isNotEmpty(),
|
||||
voiceReady = talkStatus.lowercase() != "off",
|
||||
)
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ClawSecondaryButton(
|
||||
text = if (logsRefreshing) "Refreshing" else "Refresh Logs",
|
||||
onClick = viewModel::refreshHealthLogs,
|
||||
enabled = isConnected && !logsRefreshing,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
logsErrorText?.let { error ->
|
||||
ClawPanel {
|
||||
Text(text = error, style = ClawTheme.type.body, color = ClawTheme.colors.warning)
|
||||
}
|
||||
}
|
||||
GatewayLogsPanel(isConnected = isConnected, summary = logsSummary)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HealthStatusPanel(
|
||||
gateway: String,
|
||||
node: String,
|
||||
chat: String,
|
||||
models: String,
|
||||
voice: String,
|
||||
runs: String,
|
||||
isConnected: Boolean,
|
||||
isNodeConnected: Boolean,
|
||||
chatHealthOk: Boolean,
|
||||
modelsReady: Boolean,
|
||||
voiceReady: Boolean,
|
||||
) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
Column {
|
||||
HealthStatusRow(title = "Gateway", value = gateway, healthy = isConnected)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
HealthStatusRow(title = "Phone Node", value = node, healthy = isNodeConnected)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
HealthStatusRow(title = "Chat", value = chat, healthy = chatHealthOk)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
HealthStatusRow(title = "Models", value = models, healthy = modelsReady)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
HealthStatusRow(title = "Voice", value = voice, healthy = voiceReady)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
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,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text = "RECENT LOGS", style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
|
||||
summary.fileName?.let { fileName ->
|
||||
Text(text = fileName, style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
when {
|
||||
!isConnected ->
|
||||
ClawPanel {
|
||||
Text(text = "Connect the gateway to load recent logs.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
summary.entries.isEmpty() ->
|
||||
ClawPanel {
|
||||
Text(text = "No recent log entries.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
else ->
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
val entries = summary.entries.takeLast(12)
|
||||
Column {
|
||||
entries.forEachIndexed { index, entry ->
|
||||
GatewayLogRow(entry = entry)
|
||||
if (index != entries.lastIndex) {
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (summary.truncated) {
|
||||
Text(text = "Showing the latest log chunk.", style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GatewayLogRow(entry: GatewayLogEntry) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
Text(text = compactLogTime(entry.time), style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle, modifier = Modifier.weight(0.72f), maxLines = 1)
|
||||
Column(modifier = Modifier.weight(2.7f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
|
||||
Text(text = entry.message, style = ClawTheme.type.caption, color = ClawTheme.colors.text, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
entry.subsystem?.let { subsystem ->
|
||||
Text(text = subsystem, style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
ClawStatusPill(text = entry.level?.uppercase() ?: "LOG", status = logLevelStatus(entry.level))
|
||||
}
|
||||
}
|
||||
|
||||
private fun compactLogTime(value: String?): String {
|
||||
val raw = value?.trim().orEmpty()
|
||||
if (raw.isEmpty()) return "--:--"
|
||||
val time =
|
||||
raw
|
||||
.substringAfter('T', raw)
|
||||
.substringBefore('.')
|
||||
.substringBefore('+')
|
||||
.substringBefore('Z')
|
||||
return time.takeIf { it.length >= 5 }?.take(5) ?: raw.take(5)
|
||||
}
|
||||
|
||||
private fun logLevelStatus(level: String?): ClawStatus =
|
||||
when (level?.lowercase()) {
|
||||
"error", "fatal" -> ClawStatus.Danger
|
||||
"warn" -> ClawStatus.Warning
|
||||
"info" -> ClawStatus.Success
|
||||
else -> ClawStatus.Neutral
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.GatewayDeviceTokenSummary
|
||||
import ai.openclaw.app.GatewayNodeSummary
|
||||
import ai.openclaw.app.GatewayNodesDevicesSummary
|
||||
import ai.openclaw.app.GatewayPairedDeviceSummary
|
||||
import ai.openclaw.app.GatewayPendingDeviceSummary
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.ui.design.ClawDetailRow
|
||||
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.ClawTextBadge
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
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.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
internal fun NodesDevicesSettingsScreen(
|
||||
viewModel: MainViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val summary by viewModel.nodesDevicesSummary.collectAsState()
|
||||
val refreshing by viewModel.nodesDevicesRefreshing.collectAsState()
|
||||
val errorText by viewModel.nodesDevicesErrorText.collectAsState()
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
viewModel.refreshNodesDevices()
|
||||
}
|
||||
}
|
||||
|
||||
SettingsDetailFrame(
|
||||
title = "Nodes & Devices",
|
||||
subtitle = "Live nodes, paired phones, and pending device requests.",
|
||||
icon = Icons.Default.Cloud,
|
||||
onBack = onBack,
|
||||
) {
|
||||
SettingsMetricPanel(
|
||||
rows =
|
||||
listOf(
|
||||
SettingsMetric("Nodes", summary.nodes.size.toString()),
|
||||
SettingsMetric("Online", summary.nodes.count { it.connected }.toString()),
|
||||
SettingsMetric("Devices", if (summary.devicePairingAvailable) summary.pairedDevices.size.toString() else "Locked"),
|
||||
SettingsMetric("Pending", summary.pendingDevices.size.toString()),
|
||||
),
|
||||
)
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ClawSecondaryButton(
|
||||
text = if (refreshing) "Refreshing" else "Refresh",
|
||||
onClick = viewModel::refreshNodesDevices,
|
||||
enabled = isConnected && !refreshing,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
errorText?.let {
|
||||
ClawPanel {
|
||||
Text(text = it, style = ClawTheme.type.body, color = ClawTheme.colors.warning)
|
||||
}
|
||||
}
|
||||
when {
|
||||
!isConnected ->
|
||||
ClawPanel {
|
||||
Text(text = "Connect the gateway to load nodes and paired devices.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
summary.isEmpty() ->
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(text = "No nodes or paired devices.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = "Linked phones and node hosts will appear here after pairing.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
else -> NodesDevicesPanel(summary = summary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NodesDevicesPanel(summary: GatewayNodesDevicesSummary) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
if (!summary.devicePairingAvailable) {
|
||||
ClawPanel {
|
||||
Text(text = "Pairing controls are not available from this connection.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
if (summary.pendingDevices.isNotEmpty()) {
|
||||
NodesSection(title = "Pending Requests") {
|
||||
summary.pendingDevices.forEachIndexed { index, device ->
|
||||
PendingDeviceRow(device = device)
|
||||
if (index != summary.pendingDevices.lastIndex) {
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (summary.nodes.isNotEmpty()) {
|
||||
NodesSection(title = "Nodes") {
|
||||
summary.nodes.forEachIndexed { index, node ->
|
||||
NodeRow(node = node)
|
||||
if (index != summary.nodes.lastIndex) {
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (summary.pairedDevices.isNotEmpty()) {
|
||||
NodesSection(title = "Paired Devices") {
|
||||
summary.pairedDevices.forEachIndexed { index, device ->
|
||||
PairedDeviceRow(device = device)
|
||||
if (index != summary.pairedDevices.lastIndex) {
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NodesSection(
|
||||
title: String,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(text = title.uppercase(), style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
Column {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NodeRow(node: GatewayNodeSummary) {
|
||||
DeviceListRow(
|
||||
badge = nodeBadge(node.displayName ?: node.id),
|
||||
title = node.displayName ?: node.id,
|
||||
subtitle = nodeSubtitle(node),
|
||||
statusText = if (node.connected) "Online" else "Offline",
|
||||
status = if (node.connected) ClawStatus.Success else ClawStatus.Warning,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PendingDeviceRow(device: GatewayPendingDeviceSummary) {
|
||||
DeviceListRow(
|
||||
badge = nodeBadge(device.displayName ?: device.deviceId),
|
||||
title = device.displayName ?: "New device",
|
||||
subtitle = pendingDeviceSubtitle(device),
|
||||
statusText = if (device.repair) "Repair" else "Review",
|
||||
status = ClawStatus.Warning,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PairedDeviceRow(device: GatewayPairedDeviceSummary) {
|
||||
DeviceListRow(
|
||||
badge = nodeBadge(device.displayName ?: device.deviceId),
|
||||
title = device.displayName ?: "Paired device",
|
||||
subtitle = pairedDeviceSubtitle(device),
|
||||
statusText = pairedDeviceStatusText(device.tokens),
|
||||
status = pairedDeviceStatus(device.tokens),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeviceListRow(
|
||||
badge: String,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
statusText: String,
|
||||
status: ClawStatus,
|
||||
) {
|
||||
ClawDetailRow(
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
leading = { ClawTextBadge(text = badge) },
|
||||
trailing = { ClawStatusPill(text = statusText, status = status) },
|
||||
)
|
||||
}
|
||||
|
||||
private fun GatewayNodesDevicesSummary.isEmpty(): Boolean = nodes.isEmpty() && pendingDevices.isEmpty() && pairedDevices.isEmpty()
|
||||
|
||||
private fun nodeSubtitle(node: GatewayNodeSummary): String {
|
||||
val kind = node.deviceFamily ?: "Node host"
|
||||
val version = node.version?.let { "OpenClaw $it" }
|
||||
val status = if (node.paired) "Paired" else "Unpaired"
|
||||
val commands =
|
||||
node.commands
|
||||
.take(2)
|
||||
.joinToString(", ")
|
||||
.takeIf { it.isNotBlank() }
|
||||
return listOfNotNull(kind, version, status, commands).joinToString(" · ")
|
||||
}
|
||||
|
||||
private fun pendingDeviceSubtitle(device: GatewayPendingDeviceSummary): String {
|
||||
val roles = formatDeviceList(device.roles, "role")
|
||||
val scopes = formatDeviceList(device.scopes, "scope")
|
||||
val requested = device.requestedAtMs?.let { "requested ${relativeDeviceTime(it)}" }
|
||||
return listOfNotNull(roles, scopes, requested, device.remoteIp).joinToString(" · ")
|
||||
}
|
||||
|
||||
private fun pairedDeviceSubtitle(device: GatewayPairedDeviceSummary): String {
|
||||
val roles = formatDeviceList(device.roles, "role")
|
||||
val scopes = formatDeviceList(device.scopes, "scope")
|
||||
val tokens = "${device.tokens.count { !it.revoked }}/${device.tokens.size} active tokens"
|
||||
return listOfNotNull(roles, scopes, tokens, device.remoteIp).joinToString(" · ")
|
||||
}
|
||||
|
||||
private fun pairedDeviceStatusText(tokens: List<GatewayDeviceTokenSummary>): String =
|
||||
when {
|
||||
tokens.isEmpty() -> "Paired"
|
||||
tokens.any { !it.revoked } -> "Active"
|
||||
else -> "Needs Token"
|
||||
}
|
||||
|
||||
private fun pairedDeviceStatus(tokens: List<GatewayDeviceTokenSummary>): ClawStatus =
|
||||
when {
|
||||
tokens.isEmpty() -> ClawStatus.Neutral
|
||||
tokens.any { !it.revoked } -> ClawStatus.Success
|
||||
else -> ClawStatus.Warning
|
||||
}
|
||||
|
||||
private fun formatDeviceList(
|
||||
values: List<String>,
|
||||
fallback: String,
|
||||
): String? =
|
||||
when (values.size) {
|
||||
0 -> null
|
||||
1 -> values.first()
|
||||
else -> "${values.size} ${fallback}s"
|
||||
}
|
||||
|
||||
private fun nodeBadge(value: String): String =
|
||||
value
|
||||
.split(' ', '-', '_')
|
||||
.filter { it.isNotBlank() }
|
||||
.take(2)
|
||||
.mapNotNull { it.firstOrNull()?.uppercaseChar()?.toString() }
|
||||
.joinToString("")
|
||||
.ifBlank { "N" }
|
||||
|
||||
private fun relativeDeviceTime(timeMs: Long): String {
|
||||
val minutes = ((System.currentTimeMillis() - timeMs).coerceAtLeast(0L)) / 60_000L
|
||||
if (minutes < 1) return "now"
|
||||
if (minutes < 60) return "${minutes}m ago"
|
||||
val hours = minutes / 60L
|
||||
if (hours < 24) return "${hours}h ago"
|
||||
return "${hours / 24L}d ago"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,558 +0,0 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.GatewayModelProviderSummary
|
||||
import ai.openclaw.app.GatewayModelSummary
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.providerDisplayName
|
||||
import ai.openclaw.app.ui.design.ClawEmptyState
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawPrimaryButton
|
||||
import ai.openclaw.app.ui.design.ClawScaffold
|
||||
import ai.openclaw.app.ui.design.ClawSecondaryButton
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Surface
|
||||
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.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@Composable
|
||||
internal fun ProvidersModelsScreen(
|
||||
viewModel: MainViewModel,
|
||||
onBack: () -> Unit,
|
||||
onAddProvider: () -> Unit,
|
||||
) {
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val models by viewModel.modelCatalog.collectAsState()
|
||||
val providers by viewModel.modelAuthProviders.collectAsState()
|
||||
val refreshing by viewModel.modelCatalogRefreshing.collectAsState()
|
||||
val errorText by viewModel.modelCatalogErrorText.collectAsState()
|
||||
val providerRows = providerRows(providers = providers, models = models)
|
||||
val modelGroups = sortedModelGroups(models)
|
||||
val setupRows = providerSetupRows(providerRows)
|
||||
var expandedModelProviders by rememberSaveable { mutableStateOf(emptyList<String>()) }
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
viewModel.refreshModelCatalog()
|
||||
}
|
||||
}
|
||||
|
||||
ClawScaffold(contentPadding = PaddingValues(start = 20.dp, top = 13.dp, end = 20.dp, bottom = 13.dp)) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(7.dp), contentPadding = PaddingValues(bottom = 112.dp)) {
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
ProviderHeaderIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", onClick = onBack)
|
||||
ProviderHeaderIconButton(icon = Icons.Default.Add, contentDescription = "Add provider", outlined = true, onClick = onAddProvider)
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(text = "Providers & Models", style = ClawTheme.type.display.copy(fontSize = 14.8.sp, lineHeight = 18.sp), color = ClawTheme.colors.text, maxLines = 1)
|
||||
Text(
|
||||
text = "Connect and manage AI providers\nBrowse models and their capabilities.",
|
||||
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp),
|
||||
color = ClawTheme.colors.textMuted,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
ProviderOverviewPanel(
|
||||
isConnected = isConnected,
|
||||
providerRows = providerRows,
|
||||
modelCount = models.size,
|
||||
onRefresh = viewModel::refreshModelCatalog,
|
||||
onSetup = onAddProvider,
|
||||
refreshing = refreshing,
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
ProviderSectionLabel(title = "Provider setup")
|
||||
}
|
||||
|
||||
item {
|
||||
ProviderSetupList(rows = setupRows, onSetup = onAddProvider)
|
||||
}
|
||||
|
||||
item {
|
||||
ProviderSectionLabel(title = "Connected providers")
|
||||
}
|
||||
|
||||
item {
|
||||
if (!isConnected && providerRows.isEmpty()) {
|
||||
ClawEmptyState(title = "Gateway offline", body = "Connect your Gateway to load provider readiness and model catalog.")
|
||||
} else {
|
||||
ProviderList(rows = providerRows, refreshing = refreshing)
|
||||
}
|
||||
}
|
||||
|
||||
errorText?.let { message ->
|
||||
item {
|
||||
ClawPanel {
|
||||
Text(text = message, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
ProviderSectionLabel(title = "Model catalog")
|
||||
}
|
||||
|
||||
if (modelGroups.isEmpty()) {
|
||||
item {
|
||||
ModelCatalogEmpty(
|
||||
title = if (refreshing) "Loading models" else "No models loaded",
|
||||
body = if (isConnected) "Refresh after configuring a provider on the Gateway." else "Connect the Gateway to browse models.",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(modelGroups, key = { it.first }) { entry ->
|
||||
val expanded = expandedModelProviders.contains(entry.first)
|
||||
ModelGroup(
|
||||
provider = entry.first,
|
||||
models = entry.second,
|
||||
expanded = expanded,
|
||||
onToggle = {
|
||||
expandedModelProviders =
|
||||
if (expanded) {
|
||||
expandedModelProviders - entry.first
|
||||
} else {
|
||||
expandedModelProviders + entry.first
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
ProviderAddButton(onClick = onAddProvider, modifier = Modifier.align(Alignment.BottomCenter))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class ProviderSetupRow(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val subtitle: String,
|
||||
val ready: Boolean,
|
||||
)
|
||||
|
||||
private data class ProviderRow(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val status: String,
|
||||
val ready: Boolean,
|
||||
val modelCount: Int,
|
||||
)
|
||||
|
||||
private fun providerRows(
|
||||
providers: List<GatewayModelProviderSummary>,
|
||||
models: List<GatewayModelSummary>,
|
||||
): List<ProviderRow> {
|
||||
val modelCounts = models.groupingBy { it.provider }.eachCount()
|
||||
val authRows =
|
||||
providers.map { provider ->
|
||||
val ready = modelProviderReady(provider.status)
|
||||
ProviderRow(
|
||||
id = provider.id,
|
||||
name = provider.displayName,
|
||||
status = if (ready) "Ready" else "Needs setup",
|
||||
ready = ready,
|
||||
modelCount = modelCounts[provider.id] ?: 0,
|
||||
)
|
||||
}
|
||||
val missingAuthRows =
|
||||
modelCounts.keys
|
||||
.filter { provider -> authRows.none { it.id == provider } }
|
||||
.map { provider ->
|
||||
ProviderRow(
|
||||
id = provider,
|
||||
name = providerDisplayName(provider),
|
||||
status = "Ready",
|
||||
ready = true,
|
||||
modelCount = modelCounts[provider] ?: 0,
|
||||
)
|
||||
}
|
||||
return (authRows + missingAuthRows).sortedWith(compareBy(::providerPriority, { it.name.lowercase() }))
|
||||
}
|
||||
|
||||
private fun providerSetupRows(providerRows: List<ProviderRow>): List<ProviderSetupRow> {
|
||||
val byId = providerRows.associateBy { it.id.trim().lowercase() }
|
||||
return listOf("openai", "anthropic", "google", "openrouter", "ollama").map { id ->
|
||||
val row = byId[id] ?: byId["ollama-local"].takeIf { id == "ollama" }
|
||||
ProviderSetupRow(
|
||||
id = id,
|
||||
name = providerDisplayName(id),
|
||||
subtitle = providerSetupSubtitle(id, row),
|
||||
ready = row?.ready == true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun providerSetupSubtitle(
|
||||
id: String,
|
||||
row: ProviderRow?,
|
||||
): String =
|
||||
when {
|
||||
row?.ready == true -> if (row.modelCount > 0) "${row.modelCount} models available" else "Ready"
|
||||
row != null -> "Finish setup to use ${row.name}"
|
||||
id == "ollama" -> "Use models running on your network"
|
||||
else -> "Add provider credentials on your Gateway"
|
||||
}
|
||||
|
||||
internal fun modelProviderReady(status: String): Boolean {
|
||||
val normalized = status.trim().lowercase()
|
||||
return normalized == "ok" ||
|
||||
normalized == "ready" ||
|
||||
normalized == "healthy" ||
|
||||
normalized == "configured" ||
|
||||
normalized == "static"
|
||||
}
|
||||
|
||||
private fun sortedModelGroups(models: List<GatewayModelSummary>): List<Pair<String, List<GatewayModelSummary>>> =
|
||||
models
|
||||
.groupBy { it.provider }
|
||||
.entries
|
||||
.sortedWith(compareBy({ providerPriority(it.key) }, { providerDisplayName(it.key).lowercase() }))
|
||||
.map { it.key to it.value }
|
||||
|
||||
private fun providerPriority(row: ProviderRow): Int = providerPriority(row.id)
|
||||
|
||||
private fun providerPriority(provider: String): Int =
|
||||
when (provider.trim().lowercase()) {
|
||||
"openai" -> 0
|
||||
"anthropic" -> 1
|
||||
"google" -> 2
|
||||
"openrouter" -> 3
|
||||
"ollama", "ollama-local" -> 4
|
||||
"codex", "openai-codex" -> 5
|
||||
else -> 100
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderList(
|
||||
rows: List<ProviderRow>,
|
||||
refreshing: Boolean,
|
||||
) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
Column {
|
||||
if (rows.isEmpty()) {
|
||||
ProviderListRow(ProviderRow(id = "loading", name = "Provider catalog", status = if (refreshing) "Loading" else "No providers", ready = false, modelCount = 0))
|
||||
} else {
|
||||
val visibleRows = rows.take(5)
|
||||
visibleRows.forEachIndexed { index, row ->
|
||||
ProviderListRow(row)
|
||||
if (index != visibleRows.lastIndex) {
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderOverviewPanel(
|
||||
isConnected: Boolean,
|
||||
providerRows: List<ProviderRow>,
|
||||
modelCount: Int,
|
||||
refreshing: Boolean,
|
||||
onRefresh: () -> Unit,
|
||||
onSetup: () -> Unit,
|
||||
) {
|
||||
val readyCount = providerRows.count { it.ready }
|
||||
val needsSetupCount = providerRows.count { !it.ready }
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp)) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ProviderMetricTile(label = "Ready", value = readyCount.toString(), modifier = Modifier.weight(1f))
|
||||
ProviderMetricTile(label = "Models", value = modelCount.toString(), modifier = Modifier.weight(1f))
|
||||
ProviderMetricTile(label = "Setup", value = needsSetupCount.toString(), modifier = Modifier.weight(1f))
|
||||
}
|
||||
Text(
|
||||
text = if (isConnected) "Choose a provider below, then finish credentials on your Gateway." else "Connect your Gateway before adding model providers.",
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
)
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ClawSecondaryButton(text = if (refreshing) "Refreshing" else "Refresh", onClick = onRefresh, enabled = isConnected && !refreshing, modifier = Modifier.weight(1f))
|
||||
ClawPrimaryButton(text = "Setup Provider", onClick = onSetup, enabled = isConnected, modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderMetricTile(
|
||||
label: String,
|
||||
value: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(ClawTheme.radii.panel),
|
||||
color = ClawTheme.colors.surface,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
contentColor = ClawTheme.colors.text,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 9.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(text = value, style = ClawTheme.type.title, color = ClawTheme.colors.text, maxLines = 1)
|
||||
Text(text = label, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderSetupList(
|
||||
rows: List<ProviderSetupRow>,
|
||||
onSetup: () -> Unit,
|
||||
) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
Column {
|
||||
rows.forEachIndexed { index, row ->
|
||||
ProviderSetupListRow(row = row, onClick = onSetup)
|
||||
if (index != rows.lastIndex) {
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderSetupListRow(
|
||||
row: ProviderSetupRow,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(onClick = onClick, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp).padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
ProviderBadge(text = row.name)
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
|
||||
Text(text = row.name, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1)
|
||||
Text(text = row.subtitle, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Box(modifier = Modifier.size(5.dp).clip(CircleShape).background(if (row.ready) ClawTheme.colors.success else ClawTheme.colors.warning))
|
||||
Text(text = if (row.ready) "Ready" else "Setup", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
Icon(imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Open ${row.name}", modifier = Modifier.size(17.dp), tint = ClawTheme.colors.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderListRow(row: ProviderRow) {
|
||||
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp).padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
ProviderBadge(text = row.name)
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
|
||||
Text(text = row.name, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1)
|
||||
Text(text = if (row.modelCount > 0) "${row.modelCount} models" else "Provider setup", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Box(modifier = Modifier.size(4.5.dp).clip(CircleShape).background(if (row.ready) ClawTheme.colors.success else ClawTheme.colors.warning))
|
||||
Text(text = row.status, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderBadge(text: String) {
|
||||
Surface(modifier = Modifier.size(30.dp), shape = RoundedCornerShape(ClawTheme.radii.row), color = ClawTheme.colors.surfacePressed, border = BorderStroke(1.dp, ClawTheme.colors.border)) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(text = providerInitials(text), style = ClawTheme.type.label, color = ClawTheme.colors.text, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun providerInitials(value: String): String =
|
||||
value
|
||||
.split(' ', '-', '_')
|
||||
.filter { it.isNotBlank() }
|
||||
.take(2)
|
||||
.mapNotNull { it.firstOrNull()?.uppercaseChar()?.toString() }
|
||||
.joinToString("")
|
||||
.ifBlank { "AI" }
|
||||
|
||||
@Composable
|
||||
private fun ModelCatalogEmpty(
|
||||
title: String,
|
||||
body: String,
|
||||
) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 11.dp, vertical = 10.dp)) {
|
||||
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(text = title, style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = body, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModelGroup(
|
||||
provider: String,
|
||||
models: List<GatewayModelSummary>,
|
||||
expanded: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
Column {
|
||||
Surface(onClick = onToggle, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
|
||||
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
ProviderBadge(text = providerDisplayName(provider))
|
||||
Text(text = providerDisplayName(provider), style = ClawTheme.type.body, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1)
|
||||
ProviderMiniTag(text = "${models.size} models")
|
||||
Icon(imageVector = if (expanded) Icons.Default.KeyboardArrowDown else Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = if (expanded) "Collapse ${providerDisplayName(provider)} models" else "Expand ${providerDisplayName(provider)} models", modifier = Modifier.size(14.dp), tint = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
val visibleModels = if (expanded) models else models.take(3)
|
||||
visibleModels.forEachIndexed { index, model ->
|
||||
ModelRow(model)
|
||||
if (index != visibleModels.lastIndex || models.size > visibleModels.size) {
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
}
|
||||
}
|
||||
if (models.size > visibleModels.size) {
|
||||
Surface(onClick = onToggle, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text = "View all models", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, modifier = Modifier.weight(1f))
|
||||
Icon(imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "View all models", modifier = Modifier.size(14.dp), tint = ClawTheme.colors.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModelRow(model: GatewayModelSummary) {
|
||||
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).padding(horizontal = 10.dp, vertical = 5.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(text = model.name, style = ClawTheme.type.mono, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
modelCapabilityLabels(model).take(3).forEach { label ->
|
||||
ProviderMiniTag(text = label)
|
||||
}
|
||||
Box(modifier = Modifier.size(4.5.dp).clip(CircleShape).background(ClawTheme.colors.success))
|
||||
}
|
||||
}
|
||||
|
||||
private fun modelCapabilityLabels(model: GatewayModelSummary): List<String> =
|
||||
buildList {
|
||||
if (model.supportsReasoning) add("Reasoning")
|
||||
if (model.supportsVision) add("Vision")
|
||||
if (model.supportsAudio) add("Voice")
|
||||
if (model.supportsDocuments) add("Docs")
|
||||
if ((model.contextTokens ?: 0L) >= 100_000L) add("Long context")
|
||||
if (isEmpty()) add("Fast")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderSectionLabel(title: String) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(text = title.uppercase(), style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderHeaderIconButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: String,
|
||||
outlined: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.size(ClawTheme.spacing.touchTarget),
|
||||
shape = CircleShape,
|
||||
color = Color.Transparent,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = if (outlined) BorderStroke(1.dp, ClawTheme.colors.borderStrong) else null,
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(if (outlined) 17.dp else 20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderAddButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = modifier.fillMaxWidth().height(ClawTheme.spacing.touchTarget),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
color = ClawTheme.colors.primary,
|
||||
contentColor = ClawTheme.colors.primaryText,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.Add, contentDescription = null, modifier = Modifier.size(17.dp))
|
||||
Spacer(modifier = Modifier.width(7.dp))
|
||||
Text(text = "Open Gateway Setup", style = ClawTheme.type.label, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderMiniTag(text: String) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(5.dp),
|
||||
color = Color.Transparent,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
contentColor = ClawTheme.colors.textMuted,
|
||||
) {
|
||||
Text(text = text, modifier = Modifier.padding(horizontal = 4.dp, vertical = 0.5.dp), style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), maxLines = 1)
|
||||
}
|
||||
}
|
||||
@@ -16,5 +16,5 @@ fun RootScreen(viewModel: MainViewModel) {
|
||||
return
|
||||
}
|
||||
|
||||
ShellScreen(viewModel = viewModel, modifier = Modifier.fillMaxSize())
|
||||
PostOnboardingTabs(viewModel = viewModel, modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
|
||||
@@ -1,334 +0,0 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.ui.design.ClawEmptyState
|
||||
import ai.openclaw.app.ui.design.ClawPrimaryButton
|
||||
import ai.openclaw.app.ui.design.ClawScaffold
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.StarBorder
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material.icons.filled.SwapVert
|
||||
import androidx.compose.material.icons.outlined.AccessTime
|
||||
import androidx.compose.material.icons.outlined.ChatBubbleOutline
|
||||
import androidx.compose.material.icons.outlined.MicNone
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Surface
|
||||
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.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@Composable
|
||||
internal fun SessionsScreen(
|
||||
viewModel: MainViewModel,
|
||||
onOpenCommand: () -> Unit,
|
||||
onOpenChat: () -> Unit,
|
||||
) {
|
||||
val sessions by viewModel.chatSessions.collectAsState()
|
||||
val chatSessionKey by viewModel.chatSessionKey.collectAsState()
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
var filter by rememberSaveable { mutableStateOf(SessionFilter.Recent) }
|
||||
var compactLayout by rememberSaveable { mutableStateOf(false) }
|
||||
var recentFirst by rememberSaveable { mutableStateOf(true) }
|
||||
val visibleSessions =
|
||||
sessions
|
||||
.let { rows ->
|
||||
when (filter) {
|
||||
SessionFilter.Recent -> rows
|
||||
SessionFilter.Live -> rows.filter { it.key == chatSessionKey }
|
||||
}
|
||||
}.let { rows ->
|
||||
if (recentFirst) {
|
||||
rows.sortedByDescending { it.updatedAtMs ?: 0L }
|
||||
} else {
|
||||
rows.sortedBy { it.updatedAtMs ?: 0L }
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
viewModel.refreshChatSessions(limit = 200)
|
||||
}
|
||||
}
|
||||
|
||||
ClawScaffold(contentPadding = PaddingValues(start = 20.dp, top = 14.dp, end = 20.dp, bottom = 20.dp)) {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(text = "Sessions", style = ClawTheme.type.display.copy(fontSize = 17.4.sp, lineHeight = 21.sp), color = ClawTheme.colors.text, modifier = Modifier.weight(1f))
|
||||
SessionPlainIconButton(icon = Icons.Default.Search, contentDescription = "Search sessions", onClick = onOpenCommand)
|
||||
SessionPlainIconButton(icon = Icons.Default.SwapVert, contentDescription = "Reverse session sort", onClick = { recentFirst = !recentFirst })
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
FilterPill(text = "Recent", icon = Icons.Outlined.AccessTime, active = filter == SessionFilter.Recent, onClick = { filter = SessionFilter.Recent })
|
||||
FilterPill(text = "Live", icon = Icons.Outlined.MicNone, active = filter == SessionFilter.Live, live = sessions.any { it.key == chatSessionKey }, onClick = { filter = SessionFilter.Live })
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(ClawTheme.radii.row))
|
||||
.clickable { recentFirst = !recentFirst }
|
||||
.padding(horizontal = 2.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(text = "Sort: ${if (recentFirst) "Newest" else "Oldest"}", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
Icon(imageVector = Icons.Default.KeyboardArrowDown, contentDescription = null, modifier = Modifier.size(11.dp), tint = ClawTheme.colors.textMuted)
|
||||
}
|
||||
SessionOutlineIconButton(icon = Icons.Default.Storage, contentDescription = "Toggle session layout", onClick = { compactLayout = !compactLayout })
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Text(text = if (compactLayout) "Layout: Compact" else "Layout: Detailed", style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle)
|
||||
}
|
||||
|
||||
if (visibleSessions.isEmpty()) {
|
||||
item {
|
||||
ClawEmptyState(
|
||||
title = emptySessionTitle(filter),
|
||||
body = emptySessionBody(filter),
|
||||
action = { ClawPrimaryButton(text = "Start Chat", onClick = onOpenChat) },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(visibleSessions, key = { it.key }) { session ->
|
||||
val active = session.key == chatSessionKey
|
||||
SessionRow(
|
||||
title = displaySessionTitle(session.displayName),
|
||||
subtitle = if (active) "Current session" else "OpenClaw session",
|
||||
metadata = session.updatedAtMs?.let(::relativeSessionTime) ?: "now",
|
||||
active = active,
|
||||
compact = compactLayout,
|
||||
onClick = {
|
||||
viewModel.switchChatSession(session.key)
|
||||
onOpenChat()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FilterPill(
|
||||
text: String,
|
||||
icon: ImageVector? = null,
|
||||
active: Boolean = false,
|
||||
live: Boolean = false,
|
||||
dropdown: Boolean = false,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick ?: {},
|
||||
enabled = onClick != null,
|
||||
shape = RoundedCornerShape(7.dp),
|
||||
color = if (active) ClawTheme.colors.surfaceRaised else Color.Transparent,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, if (active) ClawTheme.colors.borderStrong else ClawTheme.colors.border),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
icon?.let { Icon(imageVector = it, contentDescription = null, modifier = Modifier.size(12.dp), tint = ClawTheme.colors.text) }
|
||||
Text(text = text, style = ClawTheme.type.label, color = ClawTheme.colors.text, maxLines = 1)
|
||||
if (live) {
|
||||
Box(modifier = Modifier.size(4.dp).clip(CircleShape).background(ClawTheme.colors.success))
|
||||
}
|
||||
if (dropdown) {
|
||||
Icon(imageVector = Icons.Default.KeyboardArrowDown, contentDescription = null, modifier = Modifier.size(11.dp), tint = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionRow(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
metadata: String,
|
||||
active: Boolean,
|
||||
compact: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(onClick = onClick, color = ClawTheme.colors.canvas, contentColor = ClawTheme.colors.text) {
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp).padding(vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(7.dp),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(30.dp),
|
||||
shape = CircleShape,
|
||||
color = Color.Transparent,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.borderStrong),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = if (active) Icons.Default.StarBorder else Icons.Outlined.ChatBubbleOutline,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(15.dp),
|
||||
tint = ClawTheme.colors.text,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.5.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.text,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (active) {
|
||||
Box(modifier = Modifier.size(3.5.dp).clip(CircleShape).background(ClawTheme.colors.success))
|
||||
}
|
||||
}
|
||||
if (!compact) {
|
||||
Text(text = subtitle, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
SessionMiniTag(text = "Workspace")
|
||||
SessionMiniTag(text = if (active) "Active" else "OpenClaw")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Icon(imageVector = Icons.Outlined.ChatBubbleOutline, contentDescription = null, modifier = Modifier.size(13.dp), tint = ClawTheme.colors.textMuted)
|
||||
Text(text = metadata, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.size(ClawTheme.spacing.touchTarget),
|
||||
shape = RoundedCornerShape(7.dp),
|
||||
color = Color.Transparent,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.borderStrong),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(14.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionMiniTag(text: String) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(5.dp),
|
||||
color = Color.Transparent,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
contentColor = ClawTheme.colors.textMuted,
|
||||
) {
|
||||
Text(text = text, modifier = Modifier.padding(horizontal = 4.dp, vertical = 0.5.dp), style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
private enum class SessionFilter {
|
||||
Recent,
|
||||
Live,
|
||||
}
|
||||
|
||||
private fun emptySessionTitle(filter: SessionFilter): String =
|
||||
when (filter) {
|
||||
SessionFilter.Recent -> "No sessions yet"
|
||||
SessionFilter.Live -> "No live session"
|
||||
}
|
||||
|
||||
private fun emptySessionBody(filter: SessionFilter): String =
|
||||
when (filter) {
|
||||
SessionFilter.Recent -> "Start a new conversation and it will show up here."
|
||||
SessionFilter.Live -> "Open Chat to start or resume the current session."
|
||||
}
|
||||
|
||||
private fun relativeSessionTime(updatedAtMs: Long): String {
|
||||
val deltaMs = (System.currentTimeMillis() - updatedAtMs).coerceAtLeast(0L)
|
||||
val minutes = deltaMs / 60_000L
|
||||
if (minutes < 1) return "now"
|
||||
if (minutes < 60) return "${minutes}m"
|
||||
val hours = minutes / 60
|
||||
if (hours < 24) return "${hours}h"
|
||||
return "${hours / 24}d"
|
||||
}
|
||||
|
||||
private fun displaySessionTitle(displayName: String?): String = displayName?.takeIf { it.isNotBlank() } ?: "Main session"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -209,7 +209,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
|
||||
}
|
||||
val callLogPermissionAvailable = remember { SensitiveFeatureConfig.callLogEnabled }
|
||||
val photosPermissionAvailable = remember { SensitiveFeatureConfig.photosEnabled }
|
||||
val photosPermission =
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
Manifest.permission.READ_MEDIA_IMAGES
|
||||
@@ -246,11 +245,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
var photosPermissionGranted by
|
||||
remember {
|
||||
mutableStateOf(
|
||||
if (photosPermissionAvailable) {
|
||||
ContextCompat.checkSelfPermission(context, photosPermission) == PackageManager.PERMISSION_GRANTED
|
||||
} else {
|
||||
false
|
||||
},
|
||||
ContextCompat.checkSelfPermission(context, photosPermission) ==
|
||||
PackageManager.PERMISSION_GRANTED,
|
||||
)
|
||||
}
|
||||
val photosPermissionLauncher =
|
||||
@@ -351,11 +347,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
notificationListenerEnabled = isNotificationListenerEnabled(context)
|
||||
installedNotificationApps = queryInstalledApps(context, notificationForwardingPackages)
|
||||
photosPermissionGranted =
|
||||
if (photosPermissionAvailable) {
|
||||
ContextCompat.checkSelfPermission(context, photosPermission) == PackageManager.PERMISSION_GRANTED
|
||||
} else {
|
||||
false
|
||||
}
|
||||
ContextCompat.checkSelfPermission(context, photosPermission) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
contactsPermissionGranted =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) ==
|
||||
PackageManager.PERMISSION_GRANTED &&
|
||||
@@ -987,33 +980,31 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
}
|
||||
item {
|
||||
Column(modifier = Modifier.settingsRowModifier()) {
|
||||
if (photosPermissionAvailable) {
|
||||
ListItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Photos", style = mobileHeadline) },
|
||||
supportingContent = { Text("Access recent photos.", style = mobileCallout) },
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (photosPermissionGranted) {
|
||||
openAppSettings(context)
|
||||
} else {
|
||||
photosPermissionLauncher.launch(photosPermission)
|
||||
}
|
||||
},
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(
|
||||
if (photosPermissionGranted) "Manage" else "Grant",
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
HorizontalDivider(color = mobileBorder)
|
||||
}
|
||||
ListItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Photos", style = mobileHeadline) },
|
||||
supportingContent = { Text("Access recent photos.", style = mobileCallout) },
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (photosPermissionGranted) {
|
||||
openAppSettings(context)
|
||||
} else {
|
||||
photosPermissionLauncher.launch(photosPermission)
|
||||
}
|
||||
},
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(
|
||||
if (photosPermissionGranted) "Manage" else "Grant",
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
HorizontalDivider(color = mobileBorder)
|
||||
ListItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = listItemColors,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,155 +0,0 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.GatewaySkillSummary
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.ui.design.ClawDetailRow
|
||||
import ai.openclaw.app.ui.design.ClawListPanel
|
||||
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.ClawTextBadge
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
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.filled.Settings
|
||||
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.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
internal fun SkillsSettingsScreen(
|
||||
viewModel: MainViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val skillsSummary by viewModel.skillsSummary.collectAsState()
|
||||
val skillsRefreshing by viewModel.skillsRefreshing.collectAsState()
|
||||
val skillsErrorText by viewModel.skillsErrorText.collectAsState()
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val skills = skillsSummary.skills
|
||||
val readyCount = skills.count { skillReady(it) }
|
||||
val needsSetupCount = skills.count { skillNeedsSetup(it) }
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
viewModel.refreshSkills()
|
||||
}
|
||||
}
|
||||
|
||||
SettingsDetailFrame(
|
||||
title = "Skills",
|
||||
subtitle = "Installed capabilities available to OpenClaw.",
|
||||
icon = Icons.Default.Settings,
|
||||
onBack = onBack,
|
||||
) {
|
||||
SettingsMetricPanel(
|
||||
rows =
|
||||
listOf(
|
||||
SettingsMetric("Installed", skills.size.toString()),
|
||||
SettingsMetric("Ready", readyCount.toString()),
|
||||
SettingsMetric("Needs Setup", needsSetupCount.toString()),
|
||||
),
|
||||
)
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ClawSecondaryButton(
|
||||
text = if (skillsRefreshing) "Refreshing" else "Refresh",
|
||||
onClick = viewModel::refreshSkills,
|
||||
enabled = isConnected && !skillsRefreshing,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
skillsErrorText?.let { errorText ->
|
||||
ClawPanel {
|
||||
Text(text = errorText, style = ClawTheme.type.body, color = ClawTheme.colors.warning)
|
||||
}
|
||||
}
|
||||
when {
|
||||
!isConnected ->
|
||||
ClawPanel {
|
||||
Text(text = "Connect the gateway to load skills.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
skills.isEmpty() ->
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(text = "No skills installed.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = "Skills installed on the gateway will appear here.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
else -> SkillsPanel(skills = skills)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SkillsPanel(skills: List<GatewaySkillSummary>) {
|
||||
ClawListPanel(items = skills) { skill ->
|
||||
SkillListRow(skill = skill)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SkillListRow(skill: GatewaySkillSummary) {
|
||||
ClawDetailRow(
|
||||
title = skill.name,
|
||||
subtitle = skillSubtitle(skill),
|
||||
leading = { ClawTextBadge(text = skillBadge(skill)) },
|
||||
trailing = { ClawStatusPill(text = skillStatusText(skill), status = skillStatus(skill)) },
|
||||
)
|
||||
}
|
||||
|
||||
private fun skillReady(skill: GatewaySkillSummary): Boolean = !skill.disabled && skill.eligible && skill.missingCount == 0
|
||||
|
||||
private fun skillNeedsSetup(skill: GatewaySkillSummary): Boolean = !skill.disabled && (skill.blockedByAllowlist || !skill.eligible || skill.missingCount > 0)
|
||||
|
||||
private fun skillStatusText(skill: GatewaySkillSummary): String =
|
||||
when {
|
||||
skill.disabled -> "Off"
|
||||
skillNeedsSetup(skill) -> "Setup"
|
||||
else -> "Ready"
|
||||
}
|
||||
|
||||
private fun skillStatus(skill: GatewaySkillSummary): ClawStatus =
|
||||
when {
|
||||
skill.disabled -> ClawStatus.Neutral
|
||||
skillNeedsSetup(skill) -> ClawStatus.Warning
|
||||
else -> ClawStatus.Success
|
||||
}
|
||||
|
||||
private fun skillSubtitle(skill: GatewaySkillSummary): String {
|
||||
val issue =
|
||||
when {
|
||||
skill.disabled -> "Disabled"
|
||||
skill.blockedByAllowlist -> "Blocked"
|
||||
skill.missingCount > 0 -> "${skill.missingCount} missing"
|
||||
!skill.eligible -> "Needs setup"
|
||||
else -> null
|
||||
}
|
||||
return listOfNotNull(skill.description, skillSourceLabel(skill), issue).joinToString(" · ")
|
||||
}
|
||||
|
||||
private fun skillSourceLabel(skill: GatewaySkillSummary): String =
|
||||
when (skill.source) {
|
||||
"openclaw-bundled" -> if (skill.bundled) "Built-in" else "Bundled"
|
||||
"openclaw-managed" -> "Installed"
|
||||
"openclaw-workspace" -> "Workspace"
|
||||
"openclaw-extra" -> "Extra"
|
||||
else -> "Skill"
|
||||
}
|
||||
|
||||
private fun skillBadge(skill: GatewaySkillSummary): String {
|
||||
skill.emoji?.let { return it }
|
||||
return skill.name
|
||||
.split(' ', '-', '_')
|
||||
.filter { it.isNotBlank() }
|
||||
.take(2)
|
||||
.mapNotNull { it.firstOrNull()?.uppercaseChar()?.toString() }
|
||||
.joinToString("")
|
||||
.ifBlank { "S" }
|
||||
}
|
||||
@@ -1,954 +0,0 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.VoiceCaptureMode
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawPrimaryButton
|
||||
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.ClawTheme
|
||||
import ai.openclaw.app.voice.VoiceConversationEntry
|
||||
import ai.openclaw.app.voice.VoiceConversationRole
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeOff
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.GraphicEq
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.MicOff
|
||||
import androidx.compose.material.icons.filled.Phone
|
||||
import androidx.compose.material.icons.filled.PhoneDisabled
|
||||
import androidx.compose.material.icons.filled.RecordVoiceOver
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.TextFields
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Surface
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
@Composable
|
||||
fun VoiceScreen(
|
||||
viewModel: MainViewModel,
|
||||
onOpenCommand: () -> Unit,
|
||||
onOpenVoiceSettings: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val gatewayStatus by viewModel.statusText.collectAsState()
|
||||
val voiceCaptureMode by viewModel.voiceCaptureMode.collectAsState()
|
||||
val micEnabled by viewModel.micEnabled.collectAsState()
|
||||
val micCooldown by viewModel.micCooldown.collectAsState()
|
||||
val speakerEnabled by viewModel.speakerEnabled.collectAsState()
|
||||
val micStatusText by viewModel.micStatusText.collectAsState()
|
||||
val micLiveTranscript by viewModel.micLiveTranscript.collectAsState()
|
||||
val micQueuedMessages by viewModel.micQueuedMessages.collectAsState()
|
||||
val micConversation by viewModel.micConversation.collectAsState()
|
||||
val micIsSending by viewModel.micIsSending.collectAsState()
|
||||
val talkModeEnabled by viewModel.talkModeEnabled.collectAsState()
|
||||
val talkModeListening by viewModel.talkModeListening.collectAsState()
|
||||
val talkModeSpeaking by viewModel.talkModeSpeaking.collectAsState()
|
||||
val talkModeConversation by viewModel.talkModeConversation.collectAsState()
|
||||
|
||||
var pendingAction by remember { mutableStateOf<VoiceAction?>(null) }
|
||||
var hasMicPermission by remember { mutableStateOf(context.hasRecordAudioPermission()) }
|
||||
val requestMicPermission =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
hasMicPermission = granted
|
||||
if (granted) {
|
||||
when (pendingAction) {
|
||||
VoiceAction.Talk -> viewModel.setTalkModeEnabled(true)
|
||||
VoiceAction.Dictation -> viewModel.setMicEnabled(true)
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
pendingAction = null
|
||||
}
|
||||
|
||||
val activeConversation = if (voiceCaptureMode == VoiceCaptureMode.TalkMode) talkModeConversation else micConversation
|
||||
val voiceActive = micEnabled || micIsSending || talkModeEnabled
|
||||
val activeStatus =
|
||||
voiceStatusLabel(
|
||||
gatewayStatus = gatewayStatus,
|
||||
voiceCaptureMode = voiceCaptureMode,
|
||||
micStatusText = micStatusText,
|
||||
micQueuedMessages = micQueuedMessages.size,
|
||||
micIsSending = micIsSending,
|
||||
talkModeListening = talkModeListening,
|
||||
talkModeSpeaking = talkModeSpeaking,
|
||||
)
|
||||
|
||||
if (talkModeEnabled) {
|
||||
TalkSessionScreen(
|
||||
entries = talkModeConversation,
|
||||
listening = talkModeListening,
|
||||
speaking = talkModeSpeaking,
|
||||
speakerEnabled = speakerEnabled,
|
||||
onToggleSpeaker = { viewModel.setSpeakerEnabled(!speakerEnabled) },
|
||||
onEndTalk = { viewModel.setTalkModeEnabled(false) },
|
||||
onOpenVoiceSettings = onOpenVoiceSettings,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (voiceCaptureMode == VoiceCaptureMode.ManualMic || micEnabled || micIsSending) {
|
||||
DictationScreen(
|
||||
liveTranscript = micLiveTranscript,
|
||||
conversation = micConversation,
|
||||
listening = micEnabled,
|
||||
sending = micIsSending,
|
||||
statusText = activeStatus,
|
||||
gatewayStatus = gatewayStatus,
|
||||
onCancel = { viewModel.cancelMicCapture() },
|
||||
onSend = { viewModel.setMicEnabled(false) },
|
||||
onOpenVoiceSettings = onOpenVoiceSettings,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
VoiceHeader(
|
||||
statusText = if (voiceActive) activeStatus else "Your voice command center.",
|
||||
speakerEnabled = speakerEnabled,
|
||||
onToggleSpeaker = { viewModel.setSpeakerEnabled(!speakerEnabled) },
|
||||
onOpenCommand = onOpenCommand,
|
||||
)
|
||||
|
||||
VoiceHero(
|
||||
gatewayStatus = gatewayStatus,
|
||||
voiceCaptureMode = voiceCaptureMode,
|
||||
micEnabled = micEnabled,
|
||||
talkModeEnabled = talkModeEnabled,
|
||||
talkModeListening = talkModeListening,
|
||||
talkModeSpeaking = talkModeSpeaking,
|
||||
micLiveTranscript = micLiveTranscript,
|
||||
onStartTalk = {
|
||||
runVoiceAction(
|
||||
action = VoiceAction.Talk,
|
||||
hasMicPermission = hasMicPermission,
|
||||
requestPermission = {
|
||||
pendingAction = VoiceAction.Talk
|
||||
requestMicPermission.launch(Manifest.permission.RECORD_AUDIO)
|
||||
},
|
||||
run = { viewModel.setTalkModeEnabled(!talkModeEnabled) },
|
||||
)
|
||||
},
|
||||
onStartDictation = {
|
||||
if (micCooldown) return@VoiceHero
|
||||
runVoiceAction(
|
||||
action = VoiceAction.Dictation,
|
||||
hasMicPermission = hasMicPermission,
|
||||
requestPermission = {
|
||||
pendingAction = VoiceAction.Dictation
|
||||
requestMicPermission.launch(Manifest.permission.RECORD_AUDIO)
|
||||
},
|
||||
run = { viewModel.setMicEnabled(!micEnabled) },
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
if (!hasMicPermission) {
|
||||
VoicePermissionPanel(
|
||||
onRequestPermission = {
|
||||
pendingAction = VoiceAction.Talk
|
||||
requestMicPermission.launch(Manifest.permission.RECORD_AUDIO)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
VoiceTranscript(
|
||||
entries = activeConversation,
|
||||
showThinking = micIsSending && activeConversation.none { it.role == VoiceConversationRole.Assistant && it.isStreaming },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DictationScreen(
|
||||
liveTranscript: String?,
|
||||
conversation: List<VoiceConversationEntry>,
|
||||
listening: Boolean,
|
||||
sending: Boolean,
|
||||
statusText: String,
|
||||
gatewayStatus: String,
|
||||
onCancel: () -> Unit,
|
||||
onSend: () -> Unit,
|
||||
onOpenVoiceSettings: () -> Unit,
|
||||
) {
|
||||
val lastUserText = conversation.lastOrNull { it.role == VoiceConversationRole.User }?.text
|
||||
val draftText = liveTranscript?.takeIf { it.isNotBlank() } ?: lastUserText.orEmpty()
|
||||
val speechProviderReady = gatewayStatus.isVoiceGatewayReady()
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
VoicePlainIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to voice", onClick = onCancel)
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(text = "Dictation", style = ClawTheme.type.title.copy(fontSize = 16.sp, lineHeight = 20.sp), color = ClawTheme.colors.text)
|
||||
Text(text = "Transcribe then send", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
VoicePlainIconButton(icon = Icons.Default.Settings, contentDescription = "Dictation settings", onClick = onOpenVoiceSettings)
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().aspectRatio(0.82f),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.panel),
|
||||
color = ClawTheme.colors.canvas,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.borderStrong),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp, vertical = 12.dp), verticalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
text = draftText.ifBlank { if (sending) "Sending to chat..." else "Start speaking..." },
|
||||
style = ClawTheme.type.title.copy(fontSize = 15.sp, lineHeight = 19.sp),
|
||||
color = if (draftText.isBlank()) ClawTheme.colors.textSubtle else ClawTheme.colors.text,
|
||||
maxLines = 7,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
DictationWaveform(active = listening || sending)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(7.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(imageVector = Icons.Default.Mic, contentDescription = null, modifier = Modifier.size(15.dp), tint = if (listening) ClawTheme.colors.success else ClawTheme.colors.textMuted)
|
||||
Text(text = statusText, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Surface(
|
||||
modifier = Modifier.size(30.dp),
|
||||
shape = CircleShape,
|
||||
color = ClawTheme.colors.surfacePressed,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(16.dp), tint = ClawTheme.colors.text)
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(text = "Speech provider", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = gatewayStatus.voiceGatewayLabel(), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text =
|
||||
when {
|
||||
sending -> "Sending"
|
||||
speechProviderReady -> "Ready"
|
||||
else -> "Offline"
|
||||
},
|
||||
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp),
|
||||
color =
|
||||
when {
|
||||
sending -> ClawTheme.colors.warning
|
||||
speechProviderReady -> ClawTheme.colors.success
|
||||
else -> ClawTheme.colors.textMuted
|
||||
},
|
||||
)
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(6.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
when {
|
||||
sending -> ClawTheme.colors.warning
|
||||
speechProviderReady -> ClawTheme.colors.success
|
||||
else -> ClawTheme.colors.textSubtle
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Icon(imageVector = Icons.Default.Info, contentDescription = null, modifier = Modifier.size(16.dp), tint = ClawTheme.colors.textMuted)
|
||||
Text(text = "Tip: stop listening to send the captured turn.", style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
ClawSecondaryButton(text = "Cancel", icon = Icons.Default.Close, onClick = onCancel, modifier = Modifier.weight(0.95f))
|
||||
ClawPrimaryButton(text = if (sending) "Sending" else "Send to Chat", icon = Icons.AutoMirrored.Filled.Send, onClick = onSend, enabled = !sending, modifier = Modifier.weight(1.25f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DictationWaveform(active: Boolean) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
List(48) { index ->
|
||||
val height = if (active) 3 + ((index * 7) % 16) else 3 + (index % 3) * 2
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(width = 2.dp, height = height.dp)
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(if (active) ClawTheme.colors.text else ClawTheme.colors.textSubtle),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TalkSessionScreen(
|
||||
entries: List<VoiceConversationEntry>,
|
||||
listening: Boolean,
|
||||
speaking: Boolean,
|
||||
speakerEnabled: Boolean,
|
||||
onToggleSpeaker: () -> Unit,
|
||||
onEndTalk: () -> Unit,
|
||||
onOpenVoiceSettings: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(11.dp),
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
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 = 14.sp, lineHeight = 17.sp), color = ClawTheme.colors.text)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Box(modifier = Modifier.size(4.5.dp).clip(CircleShape).background(if (speaking || listening) ClawTheme.colors.success else ClawTheme.colors.textSubtle))
|
||||
Text(
|
||||
text =
|
||||
if (speaking) {
|
||||
"OpenClaw speaking"
|
||||
} else if (listening) {
|
||||
"Realtime voice"
|
||||
} else {
|
||||
"Connected"
|
||||
},
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
)
|
||||
}
|
||||
}
|
||||
VoicePlainIconButton(icon = Icons.Default.Info, contentDescription = "Talk settings", onClick = onOpenVoiceSettings)
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().height(58.dp),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
color = ClawTheme.colors.canvas,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.borderStrong),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
TalkWaveform(active = listening || speaking)
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(text = "Live transcript", style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
|
||||
TalkTranscript(entries = entries, modifier = Modifier.weight(1f))
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TalkControl(icon = if (speakerEnabled) Icons.AutoMirrored.Filled.VolumeUp else Icons.AutoMirrored.Filled.VolumeOff, label = if (speakerEnabled) "Mute" else "Unmute", onClick = onToggleSpeaker)
|
||||
TalkControl(icon = Icons.Default.PhoneDisabled, label = "End", primary = true, onClick = onEndTalk)
|
||||
TalkControl(icon = Icons.Default.GraphicEq, label = "Voice", onClick = onOpenVoiceSettings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TalkTranscript(
|
||||
entries: List<VoiceConversationEntry>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyColumn(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
if (entries.isEmpty()) {
|
||||
item {
|
||||
TalkTranscriptCard(label = "OpenClaw", text = "Listening for your next turn.", muted = true)
|
||||
}
|
||||
} else {
|
||||
items(entries.takeLast(6), key = { it.id }) { entry ->
|
||||
TalkTranscriptCard(
|
||||
label = if (entry.role == VoiceConversationRole.User) "You" else "OpenClaw",
|
||||
text = if (entry.isStreaming && entry.text.isBlank()) "Listening response..." else entry.text,
|
||||
muted = entry.isStreaming,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TalkTranscriptCard(
|
||||
label: String,
|
||||
text: String,
|
||||
muted: Boolean = false,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.panel),
|
||||
color = ClawTheme.colors.surface,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Text(text = label, style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = text, style = ClawTheme.type.body, color = if (muted) ClawTheme.colors.textMuted else ClawTheme.colors.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TalkControl(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
label: String,
|
||||
primary: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.size(ClawTheme.spacing.touchTarget),
|
||||
shape = CircleShape,
|
||||
color = if (primary) ClawTheme.colors.primary else ClawTheme.colors.canvas,
|
||||
contentColor = if (primary) ClawTheme.colors.primaryText else ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, if (primary) ClawTheme.colors.primary else ClawTheme.colors.border),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = icon, contentDescription = label, modifier = Modifier.size(if (primary) 20.dp else 18.dp))
|
||||
}
|
||||
}
|
||||
Text(text = label, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TalkWaveform(active: Boolean) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
listOf(4, 12, 24, 34, 46, 28, 12, 38, 44, 24, 12, 30, 42, 18, 6).forEachIndexed { index, height ->
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(width = 3.dp, height = (if (active) height else 6 + index % 4 * 5).dp)
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(if (active) ClawTheme.colors.text else ClawTheme.colors.textSubtle),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceHeader(
|
||||
statusText: String,
|
||||
speakerEnabled: Boolean,
|
||||
onToggleSpeaker: () -> Unit,
|
||||
onOpenCommand: () -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
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),
|
||||
)
|
||||
VoicePlainIconButton(icon = Icons.Default.Search, contentDescription = "Search voice", onClick = onOpenCommand)
|
||||
VoiceAvatar(text = "OC")
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(text = "Voice", style = ClawTheme.type.display.copy(fontSize = 16.sp, lineHeight = 20.sp), color = ClawTheme.colors.text)
|
||||
Text(
|
||||
text = statusText,
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
VoicePlainIconButton(
|
||||
icon = if (speakerEnabled) Icons.AutoMirrored.Filled.VolumeUp else Icons.AutoMirrored.Filled.VolumeOff,
|
||||
contentDescription = if (speakerEnabled) "Mute speaker" else "Unmute speaker",
|
||||
onClick = onToggleSpeaker,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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,
|
||||
voiceCaptureMode: VoiceCaptureMode,
|
||||
micEnabled: Boolean,
|
||||
talkModeEnabled: Boolean,
|
||||
talkModeListening: Boolean,
|
||||
talkModeSpeaking: Boolean,
|
||||
micLiveTranscript: String?,
|
||||
onStartTalk: () -> Unit,
|
||||
onStartDictation: () -> Unit,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
VoiceOrb(
|
||||
active = micEnabled || talkModeEnabled,
|
||||
listening = talkModeListening || voiceCaptureMode == VoiceCaptureMode.ManualMic,
|
||||
speaking = talkModeSpeaking,
|
||||
)
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(7.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (micEnabled || talkModeEnabled) ClawTheme.colors.success else ClawTheme.colors.textSubtle),
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
when {
|
||||
talkModeSpeaking -> "OpenClaw is replying"
|
||||
talkModeListening -> "Listening"
|
||||
talkModeEnabled -> "Talk is live"
|
||||
micEnabled -> "Dictation is listening"
|
||||
else -> "Ready to talk"
|
||||
},
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
if (!micLiveTranscript.isNullOrBlank()) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.panel),
|
||||
color = ClawTheme.colors.surface,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.borderStrong),
|
||||
) {
|
||||
Text(
|
||||
text = micLiveTranscript.trim(),
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 9.dp),
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.text,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
VoiceModeRow(
|
||||
title = if (talkModeEnabled) "End Talk" else "Realtime Talk",
|
||||
subtitle = if (talkModeEnabled) "Conversation is live" else "Natural conversation in real time",
|
||||
icon = if (talkModeEnabled) Icons.Default.PhoneDisabled else Icons.Default.RecordVoiceOver,
|
||||
onClick = onStartTalk,
|
||||
)
|
||||
VoiceModeRow(
|
||||
title = if (micEnabled) "Stop Dictation" else "Dictation",
|
||||
subtitle = if (micEnabled) "Listening for one turn" else "Convert speech to text",
|
||||
icon = if (micEnabled) Icons.Default.MicOff else Icons.Default.TextFields,
|
||||
onClick = onStartDictation,
|
||||
)
|
||||
}
|
||||
|
||||
VoiceProviderCard(gatewayStatus = gatewayStatus)
|
||||
|
||||
VoicePrimaryAction(
|
||||
text = if (talkModeEnabled) "End Talk" else "Start Talk",
|
||||
icon = if (talkModeEnabled) Icons.Default.PhoneDisabled else Icons.Default.Phone,
|
||||
onClick = onStartTalk,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceModeRow(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(onClick = onClick, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 60.dp).padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(34.dp),
|
||||
shape = CircleShape,
|
||||
color = ClawTheme.colors.surface,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(text = title, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1)
|
||||
Text(text = subtitle, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
}
|
||||
Icon(imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, modifier = Modifier.size(21.dp), tint = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceProviderCard(gatewayStatus: String) {
|
||||
val ready = gatewayStatus.isVoiceGatewayReady()
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.panel),
|
||||
color = ClawTheme.colors.surface,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 9.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(34.dp),
|
||||
shape = CircleShape,
|
||||
color = ClawTheme.colors.canvas,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.borderStrong),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(17.dp))
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(text = "Provider", style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1)
|
||||
Text(text = gatewayStatus.voiceGatewayLabel(), style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(7.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (ready) ClawTheme.colors.success else ClawTheme.colors.textSubtle),
|
||||
)
|
||||
Text(text = if (ready) "Ready" else "Offline", style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoicePrimaryAction(
|
||||
text: String,
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth().height(ClawTheme.spacing.touchTarget),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
color = ClawTheme.colors.primary,
|
||||
contentColor = ClawTheme.colors.primaryText,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(17.dp))
|
||||
Text(text = text, modifier = Modifier.padding(start = 8.dp), style = ClawTheme.type.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceOrb(
|
||||
active: Boolean,
|
||||
listening: Boolean,
|
||||
speaking: Boolean,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(132.dp),
|
||||
shape = CircleShape,
|
||||
color = if (active) ClawTheme.colors.surfacePressed else ClawTheme.colors.surface,
|
||||
border = BorderStroke(1.dp, if (active) ClawTheme.colors.borderStrong else ClawTheme.colors.border),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(
|
||||
imageVector =
|
||||
when {
|
||||
speaking -> Icons.Default.RecordVoiceOver
|
||||
listening -> Icons.Default.GraphicEq
|
||||
else -> Icons.Default.Mic
|
||||
},
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(38.dp),
|
||||
tint = ClawTheme.colors.text,
|
||||
)
|
||||
Waveform(active = active)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Waveform(active: Boolean) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(3.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
listOf(6, 11, 17, 23, 14, 9, 20, 14, 7).forEachIndexed { index, height ->
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(width = 2.dp, height = (if (active) height else 6 + index % 3 * 3).dp)
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(if (active) ClawTheme.colors.text else ClawTheme.colors.textSubtle),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceTranscript(
|
||||
entries: List<VoiceConversationEntry>,
|
||||
showThinking: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
LaunchedEffect(entries.size, showThinking) {
|
||||
if (entries.isNotEmpty() || showThinking) {
|
||||
listState.animateScrollToItem(0)
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
state = listState,
|
||||
reverseLayout = true,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
contentPadding = PaddingValues(bottom = 8.dp),
|
||||
) {
|
||||
if (showThinking) {
|
||||
item(key = "thinking") {
|
||||
VoiceThinkingCard()
|
||||
}
|
||||
}
|
||||
|
||||
items(entries.asReversed(), key = { it.id }) { entry ->
|
||||
VoiceTurnCard(entry = entry)
|
||||
}
|
||||
|
||||
if (entries.isEmpty() && !showThinking) {
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(text = "Live transcript", style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle)
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 10.dp, vertical = 9.dp)) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(text = "No transcript yet", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(
|
||||
text = "Your words and OpenClaw replies will appear here.",
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceTurnCard(entry: VoiceConversationEntry) {
|
||||
val isUser = entry.role == VoiceConversationRole.User
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(if (isUser) 0.82f else 0.92f),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.panel),
|
||||
color = if (isUser) ClawTheme.colors.surfacePressed else ClawTheme.colors.surfaceRaised,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, if (entry.isStreaming) ClawTheme.colors.borderStrong else ClawTheme.colors.border),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Text(
|
||||
text = if (isUser) "You" else "OpenClaw",
|
||||
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp, fontWeight = FontWeight.SemiBold),
|
||||
color = ClawTheme.colors.textSubtle,
|
||||
)
|
||||
Text(
|
||||
text = if (entry.isStreaming && entry.text.isBlank()) "Listening..." else entry.text,
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.text,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceThinkingCard() {
|
||||
ClawPanel {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
ClawStatusPill(text = "Sending", status = ClawStatus.Warning)
|
||||
Text(text = "OpenClaw is preparing a response.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoicePermissionPanel(onRequestPermission: () -> Unit) {
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ClawStatusPill(text = "Permission needed", status = ClawStatus.Warning)
|
||||
Text(text = "Microphone access is needed.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(
|
||||
text = "OpenClaw only listens when you start Talk or Dictation.",
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
)
|
||||
ClawSecondaryButton(text = "Enable Microphone", icon = Icons.Default.Mic, onClick = onRequestPermission)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum class VoiceAction {
|
||||
Talk,
|
||||
Dictation,
|
||||
}
|
||||
|
||||
private fun runVoiceAction(
|
||||
action: VoiceAction,
|
||||
hasMicPermission: Boolean,
|
||||
requestPermission: () -> Unit,
|
||||
run: () -> Unit,
|
||||
) {
|
||||
if (hasMicPermission) {
|
||||
run()
|
||||
} else {
|
||||
requestPermission()
|
||||
}
|
||||
}
|
||||
|
||||
private fun voiceStatusLabel(
|
||||
gatewayStatus: String,
|
||||
voiceCaptureMode: VoiceCaptureMode,
|
||||
micStatusText: String,
|
||||
micQueuedMessages: Int,
|
||||
micIsSending: Boolean,
|
||||
talkModeListening: Boolean,
|
||||
talkModeSpeaking: Boolean,
|
||||
): String =
|
||||
when {
|
||||
voiceCaptureMode == VoiceCaptureMode.TalkMode && talkModeSpeaking -> "OpenClaw is speaking"
|
||||
voiceCaptureMode == VoiceCaptureMode.TalkMode && talkModeListening -> "Listening"
|
||||
voiceCaptureMode == VoiceCaptureMode.TalkMode -> "Talk is live"
|
||||
micIsSending -> "Sending dictation"
|
||||
voiceCaptureMode == VoiceCaptureMode.ManualMic -> micStatusText.ifBlank { "Listening" }
|
||||
micQueuedMessages > 0 -> "$micQueuedMessages queued"
|
||||
!gatewayStatus.isVoiceGatewayReady() -> "Gateway offline"
|
||||
else -> "Ready to talk"
|
||||
}
|
||||
|
||||
private fun String.isVoiceGatewayReady(): Boolean {
|
||||
val status = lowercase()
|
||||
return !status.contains("offline") && !status.contains("not connected") && !status.contains("failed") && !status.contains("error")
|
||||
}
|
||||
|
||||
private fun String.voiceGatewayLabel(): String = if (isVoiceGatewayReady()) "Connected and ready" else "Gateway not connected"
|
||||
|
||||
private fun Context.hasRecordAudioPermission(): Boolean = ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
|
||||
@@ -96,10 +96,8 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
|
||||
val talkModeEnabled by viewModel.talkModeEnabled.collectAsState()
|
||||
val talkModeListening by viewModel.talkModeListening.collectAsState()
|
||||
val talkModeSpeaking by viewModel.talkModeSpeaking.collectAsState()
|
||||
val talkModeConversation by viewModel.talkModeConversation.collectAsState()
|
||||
|
||||
val activeConversation = if (voiceCaptureMode == VoiceCaptureMode.TalkMode) talkModeConversation else micConversation
|
||||
val hasStreamingAssistant = activeConversation.any { it.role == VoiceConversationRole.Assistant && it.isStreaming }
|
||||
val hasStreamingAssistant = micConversation.any { it.role == VoiceConversationRole.Assistant && it.isStreaming }
|
||||
val showThinkingBubble = micIsSending && !hasStreamingAssistant
|
||||
|
||||
var hasMicPermission by remember { mutableStateOf(context.hasRecordAudioPermission()) }
|
||||
@@ -133,8 +131,8 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
|
||||
pendingVoicePermissionAction = null
|
||||
}
|
||||
|
||||
LaunchedEffect(voiceCaptureMode, activeConversation.size, showThinkingBubble) {
|
||||
val total = activeConversation.size + if (showThinkingBubble) 1 else 0
|
||||
LaunchedEffect(micConversation.size, showThinkingBubble) {
|
||||
val total = micConversation.size + if (showThinkingBubble) 1 else 0
|
||||
if (total > 0) {
|
||||
listState.animateScrollToItem(total - 1)
|
||||
}
|
||||
@@ -156,7 +154,7 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
|
||||
contentPadding = PaddingValues(vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
if (activeConversation.isEmpty() && !showThinkingBubble) {
|
||||
if (micConversation.isEmpty() && !showThinkingBubble) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier.fillParentMaxHeight().fillMaxWidth(),
|
||||
@@ -187,7 +185,7 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
items(items = activeConversation, key = { it.id }) { entry ->
|
||||
items(items = micConversation, key = { it.id }) { entry ->
|
||||
VoiceTurnBubble(entry = entry)
|
||||
}
|
||||
|
||||
@@ -349,8 +347,10 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
|
||||
voiceCaptureMode == VoiceCaptureMode.TalkMode && talkModeSpeaking -> "Talk speaking"
|
||||
voiceCaptureMode == VoiceCaptureMode.TalkMode && talkModeListening -> "Talk listening"
|
||||
voiceCaptureMode == VoiceCaptureMode.TalkMode -> "Talk on"
|
||||
micEnabled || micIsSending || micCooldown -> micStatusText
|
||||
queueCount > 0 -> "$queueCount queued"
|
||||
micIsSending -> "Sending"
|
||||
micCooldown -> "Cooldown"
|
||||
micEnabled -> "Listening"
|
||||
else -> "Mic off"
|
||||
}
|
||||
val stateColor =
|
||||
|
||||
@@ -141,7 +141,7 @@ fun ChatComposer(
|
||||
|
||||
if (!healthOk) {
|
||||
Text(
|
||||
text = "Gateway is offline. Open Settings to reconnect.",
|
||||
text = "Gateway is offline. Connect first in the Connect tab.",
|
||||
style = mobileCallout,
|
||||
color = ai.openclaw.app.ui.mobileWarning,
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user