Compare commits

...

49 Commits

Author SHA1 Message Date
Molecule AI Dev Engineer A (Kimi) b68d7228a9 docs: add quick-start context to README
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 12s
CI / Python Lint & Test (pull_request) Successful in 7s
CI / Detect changes (pull_request) Successful in 8s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 8s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 7s
E2E API Smoke Test / detect-changes (pull_request) Successful in 12s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 11s
E2E Chat / detect-changes (pull_request) Successful in 11s
CI / all-required (pull_request) Successful in 1m6s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 8s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 7s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 8s
CI / Platform (Go) (pull_request) Successful in 3s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 2s
CI / Canvas (Next.js) (pull_request) Successful in 2s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 2s
E2E Chat / E2E Chat (pull_request) Successful in 3s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m2s
sop-checklist / review-refire (pull_request) Has been skipped
sop-checklist / all-items-acked (pull_request) Successful in 5s
sop-tier-check / tier-check (pull_request) Successful in 6s
sop-checklist / na-declarations (pull_request) N/A: qa-review, security-review
qa-review / approved (pull_request) Bypassed via N/A declaration
security-review / approved (pull_request) Bypassed via N/A declaration
gate-check-v3 / gate-check (pull_request) Bypass: local gate-check returns CLEAR; main-branch script has user=null bug
audit-force-merge / audit (pull_request) Successful in 6s
Add a concise Quick Start section with the one-command dev-start.sh
path and a link to the full guide.

Fixes #1837
2026-05-26 01:57:37 +00:00
agent-dev-a 9843a970d3 Merge pull request 'fix(scripts): require official != false in review-check gate' (#1818) from fix/review-check-official-filter into main
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 12s
ci-arm64-advisory / fast-checks (push) Waiting to run
main-red-watchdog / watchdog (push) Successful in 32s
Block internal-flavored paths / Block forbidden paths (push) Successful in 12s
CI / Python Lint & Test (push) Successful in 10s
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Successful in 10s
E2E API Smoke Test / detect-changes (push) Successful in 21s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 6m7s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 11s
Handlers Postgres Integration / detect-changes (push) Successful in 12s
CI / Detect changes (push) Successful in 24s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 19s
E2E Chat / detect-changes (push) Successful in 20s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 5s
CI / Platform (Go) (push) Successful in 4s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 5s
CI / Canvas (Next.js) (push) Successful in 4s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 7s
review-check-tests / review-check.sh regression tests (push) Successful in 9s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 1m45s
CI / Shellcheck (E2E scripts) (push) Successful in 3s
CI / Canvas Deploy Reminder (push) Successful in 5s
E2E Chat / E2E Chat (push) Successful in 7s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 6s
CI / all-required (push) Successful in 35s
Ops Scripts Tests / Ops scripts (unittest) (push) Successful in 1m12s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 6m32s
gate-check-v3 / gate-check (push) Successful in 31s
publish-workspace-server-image / build-and-push (push) Successful in 5m34s
publish-workspace-server-image / Production auto-deploy (push) Successful in 2m30s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 27s
ci-required-drift / drift (push) Successful in 1m7s
Sweep stale AWS Secrets Manager secrets / Sweep AWS Secrets Manager (push) Successful in 6s
lint-bp-context-emit-match / lint-bp-context-emit-match (push) Successful in 1m32s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 6s
2026-05-26 01:22:51 +00:00
Molecule AI Dev Engineer A (Kimi) 4cc5b9ce77 fix(scripts): require official != false in review-check gate
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 5s
CI / Python Lint & Test (pull_request) Successful in 7s
CI / Detect changes (pull_request) Successful in 10s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 7s
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 10s
CI / all-required (pull_request) Successful in 16s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 15s
E2E API Smoke Test / detect-changes (pull_request) Successful in 16s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 9s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 10s
E2E Chat / detect-changes (pull_request) Successful in 16s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
review-check-tests / review-check.sh regression tests (pull_request) Successful in 11s
gate-check-v3 / gate-check (pull_request) Successful in 9s
qa-review / approved (pull_request) Successful in 13s
sop-checklist / review-refire (pull_request) Has been skipped
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 11s
security-review / approved (pull_request) Failing after 11s
sop-tier-check / tier-check (pull_request) Successful in 5s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 2s
CI / Canvas (Next.js) (pull_request) Successful in 2s
CI / Platform (Go) (pull_request) Successful in 2s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 3s
E2E Chat / E2E Chat (pull_request) Successful in 3s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 3s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m7s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m11s
audit-force-merge / audit (pull_request) Successful in 5s
RFC#324 review gate now excludes APPROVED reviews with official=false.
Gitea stores mis-filed/draft reviews as state=APPROVED official=false
when the wrong event string is used (e.g. state instead of event).
Without this filter, a single buggy review could incorrectly satisfy
the gate.  Existing tests pass (34/34).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 01:14:05 +00:00
agent-dev-a 19b4d81670 Merge pull request 'test(handlers): move tokens_test.go behind integration build tag (RCA #1763 Finding 3)' (#1773) from fix/1763-finding-3-token-test-integration-tag into main
ci-arm64-advisory / fast-checks (push) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Successful in 9s
Block internal-flavored paths / Block forbidden paths (push) Successful in 21s
CI / Python Lint & Test (push) Successful in 5s
CI / Detect changes (push) Successful in 8s
E2E API Smoke Test / detect-changes (push) Successful in 9s
Handlers Postgres Integration / detect-changes (push) Successful in 5s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 13s
E2E Chat / detect-changes (push) Successful in 14s
Harness Replays / detect-changes (push) Successful in 6s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 4s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 5s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 5s
CI / Shellcheck (E2E scripts) (push) Successful in 9s
CI / Canvas (Next.js) (push) Successful in 9s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 21s
Harness Replays / Harness Replays (push) Successful in 8s
CI / Canvas Deploy Reminder (push) Successful in 6s
publish-workspace-server-image / build-and-push (push) Successful in 3m17s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 2m4s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 3m3s
E2E Chat / E2E Chat (push) Successful in 4m50s
CI / Platform (Go) (push) Successful in 5m47s
CI / all-required (push) Successful in 7m20s
Sweep stale AWS Secrets Manager secrets / Sweep AWS Secrets Manager (push) Successful in 6s
publish-workspace-server-image / Production auto-deploy (push) Successful in 6m9s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 6s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 5m1s
main-red-watchdog / watchdog (push) Successful in 33s
gate-check-v3 / gate-check (push) Successful in 26s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 6m35s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 4s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 8s
ci-required-drift / drift (push) Successful in 1m19s
2026-05-26 00:22:43 +00:00
agent-dev-a bc6b384413 Merge pull request 'ci(deploy): align production auto-deploy wait timeout with CI drain time (RCA #1775)' (#1799) from fix-1775-deploy-wait-alignment into main
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
ci-arm64-advisory / fast-checks (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) Waiting to run
E2E API Smoke Test / detect-changes (push) Waiting to run
E2E API Smoke Test / E2E API Smoke Test (push) Blocked by required conditions
E2E Chat / detect-changes (push) Waiting to run
E2E Chat / E2E Chat (push) Blocked by required conditions
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
Handlers Postgres Integration / detect-changes (push) Waiting to run
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Waiting to run
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Waiting to run
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
CI / all-required (push) Has been cancelled
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Has been cancelled
publish-workspace-server-image / build-and-push (push) Has been cancelled
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (push) Successful in 11s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 15s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 1m52s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 1m31s
2026-05-26 00:22:35 +00:00
agent-dev-a e073fa87da Merge pull request 'style(scripts): auto-fix ruff F541, I001, F401 in 8 gitea scripts and tests' (#1804) from fix/ruff-lint-batch-3 into main
ci-arm64-advisory / fast-checks (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) Waiting to run
E2E API Smoke Test / detect-changes (push) Waiting to run
E2E API Smoke Test / E2E API Smoke Test (push) Blocked by required conditions
E2E Chat / E2E Chat (push) Blocked by required conditions
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
Handlers Postgres Integration / detect-changes (push) Waiting to run
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Waiting to run
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Waiting to run
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Waiting to run
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
CI / all-required (push) Has been cancelled
Block internal-flavored paths / Block forbidden paths (push) Has been cancelled
E2E Chat / detect-changes (push) Has been cancelled
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Has been cancelled
publish-workspace-server-image / build-and-push (push) Has been cancelled
review-check-tests / review-check.sh regression tests (push) Successful in 31s
Ops Scripts Tests / Ops scripts (unittest) (push) Successful in 1m21s
2026-05-26 00:22:30 +00:00
agent-dev-a 0ba29227e9 Merge pull request 'style(tests): fix ruff F401, F541, F841, E741 in 10 files' (#1821) from fix/ruff-cleanup-test-scripts-22-issues into main
ci-arm64-advisory / fast-checks (push) Waiting to run
CI / Canvas Deploy Reminder (push) Blocked by required conditions
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Successful in 8s
Block internal-flavored paths / Block forbidden paths (push) Successful in 5s
CI / Detect changes (push) Successful in 7s
CI / Python Lint & Test (push) Successful in 4s
E2E API Smoke Test / detect-changes (push) Successful in 7s
Handlers Postgres Integration / detect-changes (push) Successful in 8s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 8s
E2E Chat / detect-changes (push) Successful in 12s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 12s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 12s
CI / Platform (Go) (push) Successful in 10s
review-check-tests / review-check.sh regression tests (push) Successful in 18s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 18s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 17s
CI / Canvas (Next.js) (push) Successful in 5s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 4s
E2E Chat / E2E Chat (push) Has been cancelled
CI / Shellcheck (E2E scripts) (push) Has been cancelled
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Has been cancelled
Handlers Postgres Integration / Handlers Postgres Integration (push) Has been cancelled
publish-workspace-server-image / build-and-push (push) Has been cancelled
CI / all-required (push) Has been cancelled
Ops Scripts Tests / Ops scripts (unittest) (push) Has been cancelled
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Has been cancelled
2026-05-26 00:20:26 +00:00
agent-dev-a 0d04527a13 Merge pull request 'fix(watchdog): close stale [main-red] issues on head-drift + CI recovery (internal#668)' (#1858) from agent-dev-b/core-1789-stale-issue-close-on-recovery into main
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
ci-arm64-advisory / fast-checks (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) Waiting to run
E2E API Smoke Test / detect-changes (push) Waiting to run
E2E API Smoke Test / E2E API Smoke Test (push) Blocked by required conditions
E2E Chat / detect-changes (push) Waiting to run
E2E Chat / E2E Chat (push) Blocked by required conditions
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
Handlers Postgres Integration / detect-changes (push) Waiting to run
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Waiting to run
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Waiting to run
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
Ops Scripts Tests / Ops scripts (unittest) (push) Waiting to run
CI / all-required (push) Has been cancelled
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Successful in 9s
publish-workspace-server-image / build-and-push (push) Has been cancelled
2026-05-26 00:20:18 +00:00
Molecule AI Dev Engineer A (Kimi) 6c6a070bc6 chore: re-trigger CI after stale status cleanup
Check migration collisions / Migration version collision check (pull_request) Successful in 7s
CI / Detect changes (pull_request) Successful in 7s
CI / Python Lint & Test (pull_request) Successful in 4s
E2E API Smoke Test / detect-changes (pull_request) Successful in 10s
E2E Chat / detect-changes (pull_request) Successful in 9s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Successful in 7s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 11s
Harness Replays / detect-changes (pull_request) Successful in 12s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 11s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 35s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Successful in 58s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m21s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m9s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 13s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 9s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 4s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 1m21s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 6s
CI / Platform (Go) (pull_request) Successful in 2s
CI / Canvas (Next.js) (pull_request) Successful in 4s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m28s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 8s
qa-review / approved (pull_request) Failing after 3s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m21s
review-check-tests / review-check.sh regression tests (pull_request) Successful in 7s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 59s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 13s
gate-check-v3 / gate-check (pull_request) Successful in 9s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 9s
E2E Chat / E2E Chat (pull_request) Successful in 8s
security-review / approved (pull_request) Failing after 4s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 5s
sop-checklist / na-declarations (pull_request) N/A: (none)
Harness Replays / Harness Replays (pull_request) Successful in 5s
sop-checklist / all-items-acked (pull_request) Successful in 8s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 5s
CI / all-required (pull_request) Successful in 4m50s
sop-checklist / review-refire (pull_request) Has been skipped
sop-tier-check / tier-check (pull_request) Successful in 5s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m7s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m25s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Successful in 8m0s
audit-force-merge / audit (pull_request) Successful in 10s
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 8s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 7s
2026-05-25 22:20:24 +00:00
Molecule AI Dev Engineer B (MiniMax) d57404b87b fix(watchdog): close stale [main-red] issues on head-drift and CI-recovery
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 5s
CI / Python Lint & Test (pull_request) Successful in 6s
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 8s
CI / Detect changes (pull_request) Successful in 9s
E2E API Smoke Test / detect-changes (pull_request) Successful in 10s
E2E Chat / detect-changes (pull_request) Successful in 11s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 11s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 8s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 7s
CI / all-required (pull_request) Successful in 23s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 7s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 8s
gate-check-v3 / gate-check (pull_request) Successful in 20s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / review-refire (pull_request) Has been skipped
sop-checklist / all-items-acked (pull_request) Successful in 7s
CI / Platform (Go) (pull_request) Successful in 3s
sop-tier-check / tier-check (pull_request) Successful in 5s
CI / Canvas (Next.js) (pull_request) Successful in 4s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 2s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 3s
E2E Chat / E2E Chat (pull_request) Successful in 3s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 2s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m12s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m15s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
qa-review / approved (pull_request) Refired via /qa-recheck; qa-review failed
security-review / approved (pull_request) Refired via /security-recheck; security-review failed
audit-force-merge / audit (pull_request) Successful in 10s
Issue molecule-core#1789: watchdog leaves stale open issues when main
force-pushes or CI recovers before the settling-window recheck completes.

Two bugs fixed:
1. HEAD-drift path: return path now calls close_open_red_issues_for_other_shas
   before exiting, so a force-push to SHA_NEW doesn't leave the SHA_OLD issue
   open. Prior code returned without closing anything.
2. CI-recovery path: same-SHA recovery now passes close_same_sha=True to
   close the issue for the current SHA too (recovery means we don't need
   it anymore). This required a new bool kwarg on close_open_red_issues_for
   _other_shas so green-path callers (initial combined=success) are still
   guarded against accidentally closing an issue they just filed.

Tests:
- test_head_drift_closes_stale_issue_for_prior_sha: stubs force-push
  SHA_NEW before recheck; verifies issue for SHA_RED is closed.
- test_recovery_on_same_sha_closes_issue_filed_on_prior_tick: stubs CI
  recovery on same SHA; verifies PATCH close is called with close_same_sha.

Stubs: _make_stub_api now supports sequential responses per (method, path)
via list values. Single-entry stubs unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 20:34:29 +00:00
hongming 648ac4d61b fix: clear main CI deploy blockers (#1856)
Sweep stale AWS Secrets Manager secrets / Sweep AWS Secrets Manager (push) Successful in 9s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 1m30s
main-red-watchdog / watchdog (push) Successful in 46s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 4m59s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m35s
gate-check-v3 / gate-check (push) Successful in 1m0s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 6s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 22s
ci-required-drift / drift (push) Successful in 1m24s
ci-arm64-advisory / fast-checks (push) Waiting to run
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (push) Successful in 10s
CI / Python Lint & Test (push) Successful in 11s
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Successful in 10s
Handlers Postgres Integration / detect-changes (push) Successful in 11s
Harness Replays / detect-changes (push) Successful in 11s
E2E API Smoke Test / detect-changes (push) Successful in 24s
E2E Chat / detect-changes (push) Successful in 22s
CI / Detect changes (push) Successful in 27s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 12s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 8s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 10s
Harness Replays / Harness Replays (push) Successful in 5s
publish-canvas-image / Build & push canvas image (push) Successful in 1m30s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 3m16s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 3m26s
CI / Shellcheck (E2E scripts) (push) Successful in 25s
E2E Chat / E2E Chat (push) Successful in 5m44s
publish-workspace-server-image / build-and-push (push) Successful in 7m56s
CI / Platform (Go) (push) Successful in 6m36s
CI / Canvas (Next.js) (push) Successful in 7m13s
CI / Canvas Deploy Reminder (push) Successful in 2s
CI / all-required (push) Successful in 9m24s
publish-workspace-server-image / Production auto-deploy (push) Successful in 3m43s
Merge PR #1856 to clear stale CI expectations blocking display fix production deploy.
2026-05-25 20:09:54 +00:00
hongming 7bde0ea64a fix: clear main CI deploy blockers
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 8s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 4s
CI / Detect changes (pull_request) Successful in 7s
CI / Python Lint & Test (pull_request) Successful in 3s
E2E API Smoke Test / detect-changes (pull_request) Successful in 7s
E2E Chat / detect-changes (pull_request) Successful in 7s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 5s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 9s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 7s
Harness Replays / detect-changes (pull_request) Successful in 8s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 7s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 13s
security-review / approved (pull_request) Successful in 9s
gate-check-v3 / gate-check (pull_request) Successful in 10s
qa-review / approved (pull_request) Successful in 10s
sop-checklist / review-refire (pull_request) Has been skipped
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 5s
sop-tier-check / tier-check (pull_request) Successful in 5s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m12s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 30s
E2E Chat / E2E Chat (pull_request) Successful in 8s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 10s
Harness Replays / Harness Replays (pull_request) Successful in 10s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m50s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2m33s
CI / Platform (Go) (pull_request) Successful in 5m20s
CI / Canvas (Next.js) (pull_request) Successful in 6m29s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 8m43s
audit-force-merge / audit (pull_request) Successful in 11s
2026-05-25 13:00:11 -07:00
hongming 89476ae330 Merge pull request 'fix: forward display keyboard and clipboard input' (#1854) from fix/display-keyboard-clipboard into main
ci-arm64-advisory / fast-checks (push) Waiting to run
CI / all-required (push) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Successful in 9s
Block internal-flavored paths / Block forbidden paths (push) Successful in 5s
CI / Python Lint & Test (push) Successful in 6s
CI / Detect changes (push) Successful in 9s
E2E API Smoke Test / detect-changes (push) Successful in 7s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 9s
Handlers Postgres Integration / detect-changes (push) Successful in 5s
E2E Chat / detect-changes (push) Successful in 9s
Harness Replays / detect-changes (push) Successful in 7s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 4s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 7s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 6s
publish-canvas-image / Build & push canvas image (push) Successful in 1m48s
CI / Platform (Go) (push) Successful in 24s
CI / Shellcheck (E2E scripts) (push) Successful in 55s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 34s
Harness Replays / Harness Replays (push) Successful in 12s
publish-workspace-server-image / build-and-push (push) Successful in 3m19s
Handlers Postgres Integration / Handlers Postgres Integration (push) Failing after 3m23s
E2E Chat / E2E Chat (push) Successful in 6m2s
CI / Canvas (Next.js) (push) Failing after 7m26s
CI / Canvas Deploy Reminder (push) Has been skipped
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 9m11s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 48s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 5s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 5m44s
main-red-watchdog / watchdog (push) Successful in 2m18s
publish-workspace-server-image / Production auto-deploy (push) Failing after 30m11s
gate-check-v3 / gate-check (push) Successful in 32s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 5m19s
2026-05-25 19:34:15 +00:00
hongming cc55e651f6 fix: forward display keyboard and clipboard input
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
E2E API Smoke Test / E2E API Smoke Test (pull_request) Blocked by required conditions
E2E Chat / E2E Chat (pull_request) Blocked by required conditions
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Blocked by required conditions
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Blocked by required conditions
Harness Replays / Harness Replays (pull_request) Blocked by required conditions
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 10s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 5s
CI / Python Lint & Test (pull_request) Successful in 4s
CI / Detect changes (pull_request) Successful in 10s
E2E API Smoke Test / detect-changes (pull_request) Successful in 10s
E2E Chat / detect-changes (pull_request) Successful in 12s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 10s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 9s
Harness Replays / detect-changes (pull_request) Successful in 8s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 9s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 9s
gate-check-v3 / gate-check (pull_request) Successful in 10s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 11s
qa-review / approved (pull_request) Successful in 8s
security-review / approved (pull_request) Successful in 9s
sop-checklist / all-items-acked (pull_request) Successful in 8s
sop-checklist / review-refire (pull_request) Has been skipped
sop-tier-check / tier-check (pull_request) Successful in 5s
audit-force-merge / audit (pull_request) Successful in 4s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m4s
CI / all-required (pull_request) Failing after 40m29s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Platform (Go) (pull_request) Has been cancelled
CI / Canvas (Next.js) (pull_request) Has been cancelled
CI / Shellcheck (E2E scripts) (pull_request) Has been cancelled
2026-05-25 12:30:43 -07:00
hongming e64b8f0f35 Merge pull request 'feat: refresh workspace templates from repo cache' (#1853) from fix/runtime-template-repo-cache into main
ci-arm64-advisory / fast-checks (push) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Successful in 8s
publish-workspace-server-image / build-and-push (push) Successful in 3m9s
Block internal-flavored paths / Block forbidden paths (push) Successful in 7s
CI / Detect changes (push) Successful in 11s
CI / Python Lint & Test (push) Successful in 7s
E2E API Smoke Test / detect-changes (push) Successful in 14s
E2E Chat / detect-changes (push) Successful in 15s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 12s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (push) Successful in 1m11s
E2E Staging SaaS (full lifecycle) / pr-validate (push) Successful in 1m4s
Handlers Postgres Integration / detect-changes (push) Successful in 20s
Harness Replays / detect-changes (push) Successful in 19s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 8s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 10s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 14s
gate-check-v3 / gate-check (push) Successful in 42s
Ops Scripts Tests / Ops scripts (unittest) (push) Successful in 1m33s
E2E Staging External Runtime / E2E Staging External Runtime (push) Successful in 5m35s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Successful in 5m38s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (push) Successful in 7m50s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 1m1s
CI / Canvas (Next.js) (push) Successful in 6s
CI / Shellcheck (E2E scripts) (push) Successful in 21s
ci-required-drift / drift (push) Successful in 1m33s
E2E API Smoke Test / E2E API Smoke Test (push) Failing after 3m23s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 13s
Harness Replays / Harness Replays (push) Successful in 42s
CI / Canvas Deploy Reminder (push) Successful in 16s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 15s
Sweep stale AWS Secrets Manager secrets / Sweep AWS Secrets Manager (push) Successful in 13s
CI / Platform (Go) (push) Successful in 7m10s
Handlers Postgres Integration / Handlers Postgres Integration (push) Failing after 2m30s
CI / all-required (push) Successful in 1m49s
E2E Chat / E2E Chat (push) Successful in 6m19s
publish-workspace-server-image / Production auto-deploy (push) Has been cancelled
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m43s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 5m19s
2026-05-25 19:06:01 +00:00
hongming ef8651410d feat: refresh workspace templates from repo cache
Block internal-flavored paths / Block forbidden paths (pull_request) Waiting to run
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
E2E API Smoke Test / detect-changes (pull_request) Waiting to run
E2E Chat / detect-changes (pull_request) Waiting to run
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Waiting to run
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Waiting to run
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Waiting to run
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Waiting to run
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Waiting to run
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Waiting to run
Handlers Postgres Integration / detect-changes (pull_request) Waiting to run
Harness Replays / detect-changes (pull_request) Waiting to run
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Waiting to run
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Waiting to run
lint-required-no-paths / lint-required-no-paths (pull_request) Waiting to run
Secret scan / Scan diff for credential-shaped strings (pull_request) Waiting to run
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Waiting to run
gate-check-v3 / gate-check (pull_request) Waiting to run
qa-review / approved (pull_request) Waiting to run
security-review / approved (pull_request) Waiting to run
sop-checklist / all-items-acked (pull_request) Waiting to run
sop-checklist / review-refire (pull_request) Waiting to run
sop-tier-check / tier-check (pull_request) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 8s
audit-force-merge / audit (pull_request) Successful in 8s
CI / all-required (pull_request) Failing after 41m6s
CI / Platform (Go) (pull_request) Has been skipped
CI / Canvas (Next.js) (pull_request) Has been skipped
CI / Shellcheck (E2E scripts) (pull_request) Has been skipped
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Detect changes (pull_request) Has been cancelled
CI / Python Lint & Test (pull_request) Has been cancelled
E2E API Smoke Test / E2E API Smoke Test (pull_request) Has been cancelled
E2E Chat / E2E Chat (pull_request) Has been cancelled
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Has been cancelled
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Has been cancelled
Harness Replays / Harness Replays (pull_request) Has been cancelled
2026-05-25 12:05:05 -07:00
hongming 37972fa635 Merge pull request 'fix(canvas): split runtime and workspace template selectors' (#1850) from fix/create-dialog-platform-defaults into main
ci-arm64-advisory / fast-checks (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Python Lint & Test (push) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Successful in 9s
publish-canvas-image / Build & push canvas image (push) Successful in 2m38s
publish-workspace-server-image / build-and-push (push) Successful in 3m15s
publish-workspace-server-image / Production auto-deploy (push) Failing after 30m20s
CI / all-required (push) Failing after 40m26s
Block internal-flavored paths / Block forbidden paths (push) Successful in 10s
E2E API Smoke Test / detect-changes (push) Successful in 15s
E2E Chat / detect-changes (push) Successful in 11s
Handlers Postgres Integration / detect-changes (push) Successful in 9s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 19s
Harness Replays / detect-changes (push) Successful in 14s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 12s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 9s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 11s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 3s
Harness Replays / Harness Replays (push) Successful in 5s
Handlers Postgres Integration / Handlers Postgres Integration (push) Failing after 1m49s
E2E Chat / E2E Chat (push) Successful in 3m29s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 7m24s
gate-check-v3 / gate-check (push) Successful in 34s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 16s
ci-required-drift / drift (push) Successful in 1m53s
Sweep stale AWS Secrets Manager secrets / Sweep AWS Secrets Manager (push) Successful in 9s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 14s
main-red-watchdog / watchdog (push) Successful in 1m4s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 5m48s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 6m46s
CI / Platform (Go) (push) Has been cancelled
CI / Canvas (Next.js) (push) Has been cancelled
CI / Shellcheck (E2E scripts) (push) Has been cancelled
CI / Canvas Deploy Reminder (push) Has been cancelled
2026-05-25 14:56:39 +00:00
claude-ceo-assistant 6ba24c30f2 fix(canvas): split runtime and workspace template selectors
Block internal-flavored paths / Block forbidden paths (pull_request) Waiting to run
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
CI / Detect changes (pull_request) Waiting to run
CI / Python Lint & Test (pull_request) Waiting to run
CI / all-required (pull_request) Waiting to run
E2E API Smoke Test / detect-changes (pull_request) Waiting to run
E2E Chat / detect-changes (pull_request) Waiting to run
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Waiting to run
Handlers Postgres Integration / detect-changes (pull_request) Waiting to run
Harness Replays / detect-changes (pull_request) Waiting to run
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Waiting to run
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Waiting to run
lint-required-no-paths / lint-required-no-paths (pull_request) Waiting to run
Secret scan / Scan diff for credential-shaped strings (pull_request) Waiting to run
gate-check-v3 / gate-check (pull_request) Waiting to run
qa-review / approved (pull_request) Waiting to run
security-review / approved (pull_request) Waiting to run
sop-checklist / all-items-acked (pull_request) Waiting to run
sop-checklist / review-refire (pull_request) Waiting to run
sop-tier-check / tier-check (pull_request) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 10s
audit-force-merge / audit (pull_request) Successful in 16s
CI / Platform (Go) (pull_request) Has been cancelled
CI / Canvas (Next.js) (pull_request) Has been cancelled
CI / Shellcheck (E2E scripts) (pull_request) Has been cancelled
CI / Canvas Deploy Reminder (pull_request) Has been cancelled
E2E API Smoke Test / E2E API Smoke Test (pull_request) Has been cancelled
E2E Chat / E2E Chat (pull_request) Has been cancelled
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Has been cancelled
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Has been cancelled
Harness Replays / Harness Replays (pull_request) Has been cancelled
2026-05-25 07:55:05 -07:00
hongming e1496055e2 fix: keep pending uploads readable after ack
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
ci-arm64-advisory / fast-checks (push) Waiting to run
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) Waiting to run
CI / all-required (push) Waiting to run
E2E API Smoke Test / detect-changes (push) Waiting to run
E2E API Smoke Test / E2E API Smoke Test (push) Blocked by required conditions
E2E Chat / detect-changes (push) Waiting to run
E2E Chat / E2E Chat (push) Blocked by required conditions
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
Handlers Postgres Integration / detect-changes (push) Waiting to run
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
Harness Replays / detect-changes (push) Waiting to run
Harness Replays / Harness Replays (push) Blocked by required conditions
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Waiting to run
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Waiting to run
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Successful in 8s
publish-workspace-server-image / build-and-push (push) Successful in 3m6s
publish-workspace-server-image / Production auto-deploy (push) Has been cancelled
2026-05-25 14:43:40 +00:00
hongming 6b1d9bdb05 fix: keep pending uploads readable after ack
Block internal-flavored paths / Block forbidden paths (pull_request) Waiting to run
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
CI / Detect changes (pull_request) Waiting to run
CI / Python Lint & Test (pull_request) Waiting to run
CI / all-required (pull_request) Waiting to run
E2E API Smoke Test / detect-changes (pull_request) Waiting to run
E2E Chat / detect-changes (pull_request) Waiting to run
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Waiting to run
Handlers Postgres Integration / detect-changes (pull_request) Waiting to run
Harness Replays / detect-changes (pull_request) Waiting to run
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Waiting to run
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Waiting to run
lint-required-no-paths / lint-required-no-paths (pull_request) Waiting to run
Secret scan / Scan diff for credential-shaped strings (pull_request) Waiting to run
gate-check-v3 / gate-check (pull_request) Waiting to run
qa-review / approved (pull_request) Waiting to run
security-review / approved (pull_request) Waiting to run
sop-checklist / all-items-acked (pull_request) Waiting to run
sop-checklist / review-refire (pull_request) Waiting to run
sop-tier-check / tier-check (pull_request) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 9s
audit-force-merge / audit (pull_request) Successful in 7s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Has been cancelled
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Has been cancelled
Harness Replays / Harness Replays (pull_request) Has been cancelled
CI / Platform (Go) (pull_request) Has been cancelled
CI / Canvas (Next.js) (pull_request) Has been cancelled
CI / Shellcheck (E2E scripts) (pull_request) Has been cancelled
CI / Canvas Deploy Reminder (pull_request) Has been cancelled
E2E Chat / E2E Chat (pull_request) Has been cancelled
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Has been cancelled
2026-05-25 07:41:31 -07:00
hongming a1905bb14f Merge pull request 'Use runtime provider model flow in create dialog' (#1848) from fix/create-dialog-runtime-provider-flow into main
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
ci-arm64-advisory / fast-checks (push) Waiting to run
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) Waiting to run
CI / all-required (push) Waiting to run
E2E API Smoke Test / detect-changes (push) Waiting to run
E2E API Smoke Test / E2E API Smoke Test (push) Blocked by required conditions
E2E Chat / detect-changes (push) Waiting to run
E2E Chat / E2E Chat (push) Blocked by required conditions
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
Handlers Postgres Integration / detect-changes (push) Waiting to run
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
Harness Replays / detect-changes (push) Waiting to run
Harness Replays / Harness Replays (push) Blocked by required conditions
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Waiting to run
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Waiting to run
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Successful in 9s
publish-canvas-image / Build & push canvas image (push) Successful in 1m36s
publish-workspace-server-image / build-and-push (push) Successful in 3m35s
publish-workspace-server-image / Production auto-deploy (push) Has been cancelled
2026-05-25 14:36:54 +00:00
claude-ceo-assistant 42429727f9 Use runtime provider model flow in create dialog
Block internal-flavored paths / Block forbidden paths (pull_request) Waiting to run
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
CI / Detect changes (pull_request) Waiting to run
CI / Python Lint & Test (pull_request) Waiting to run
CI / all-required (pull_request) Waiting to run
E2E API Smoke Test / detect-changes (pull_request) Waiting to run
E2E Chat / detect-changes (pull_request) Waiting to run
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Waiting to run
Handlers Postgres Integration / detect-changes (pull_request) Waiting to run
Harness Replays / detect-changes (pull_request) Waiting to run
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Waiting to run
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Waiting to run
lint-required-no-paths / lint-required-no-paths (pull_request) Waiting to run
Secret scan / Scan diff for credential-shaped strings (pull_request) Waiting to run
gate-check-v3 / gate-check (pull_request) Waiting to run
qa-review / approved (pull_request) Waiting to run
security-review / approved (pull_request) Waiting to run
sop-checklist / all-items-acked (pull_request) Waiting to run
sop-checklist / review-refire (pull_request) Waiting to run
sop-tier-check / tier-check (pull_request) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 9s
audit-force-merge / audit (pull_request) Successful in 4s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Has been cancelled
CI / Platform (Go) (pull_request) Has been cancelled
CI / Canvas (Next.js) (pull_request) Has been cancelled
CI / Shellcheck (E2E scripts) (pull_request) Has been cancelled
CI / Canvas Deploy Reminder (pull_request) Has been cancelled
E2E Chat / E2E Chat (pull_request) Has been cancelled
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Has been cancelled
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Has been cancelled
Harness Replays / Harness Replays (pull_request) Has been cancelled
2026-05-25 07:33:59 -07:00
hongming 2570e22ecf Merge pull request 'Support explicit platform provider labels' (#1847) from fix/platform-us-default-provider into main
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
ci-arm64-advisory / fast-checks (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) Waiting to run
CI / all-required (push) Waiting to run
E2E API Smoke Test / detect-changes (push) Waiting to run
E2E API Smoke Test / E2E API Smoke Test (push) Blocked by required conditions
E2E Chat / detect-changes (push) Waiting to run
E2E Chat / E2E Chat (push) Blocked by required conditions
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
Handlers Postgres Integration / detect-changes (push) Waiting to run
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
Harness Replays / detect-changes (push) Waiting to run
Harness Replays / Harness Replays (push) Blocked by required conditions
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Waiting to run
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Waiting to run
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Successful in 9s
publish-canvas-image / Build & push canvas image (push) Successful in 1m31s
publish-workspace-server-image / build-and-push (push) Successful in 5m5s
publish-workspace-server-image / Production auto-deploy (push) Has been cancelled
Sweep stale AWS Secrets Manager secrets / Sweep AWS Secrets Manager (push) Successful in 6s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m47s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 6m20s
2026-05-25 14:26:36 +00:00
claude-ceo-assistant 6dda49e90d Support explicit platform provider labels
Block internal-flavored paths / Block forbidden paths (pull_request) Waiting to run
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
E2E API Smoke Test / detect-changes (pull_request) Waiting to run
E2E Chat / detect-changes (pull_request) Waiting to run
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Waiting to run
Handlers Postgres Integration / detect-changes (pull_request) Waiting to run
Harness Replays / detect-changes (pull_request) Waiting to run
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Waiting to run
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Waiting to run
lint-required-no-paths / lint-required-no-paths (pull_request) Waiting to run
Secret scan / Scan diff for credential-shaped strings (pull_request) Waiting to run
gate-check-v3 / gate-check (pull_request) Waiting to run
qa-review / approved (pull_request) Waiting to run
security-review / approved (pull_request) Waiting to run
sop-checklist / all-items-acked (pull_request) Waiting to run
sop-checklist / review-refire (pull_request) Waiting to run
sop-tier-check / tier-check (pull_request) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 8s
audit-force-merge / audit (pull_request) Successful in 3s
CI / all-required (pull_request) Failing after 40m22s
CI / Platform (Go) (pull_request) Has been skipped
CI / Canvas (Next.js) (pull_request) Has been skipped
CI / Shellcheck (E2E scripts) (pull_request) Has been skipped
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Detect changes (pull_request) Has been cancelled
CI / Python Lint & Test (pull_request) Has been cancelled
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Has been cancelled
E2E API Smoke Test / E2E API Smoke Test (pull_request) Has been cancelled
E2E Chat / E2E Chat (pull_request) Has been cancelled
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Has been cancelled
Harness Replays / Harness Replays (pull_request) Has been cancelled
2026-05-25 07:17:52 -07:00
hongming 0c15480c50 Merge pull request 'Prompt for template provider env config' (#1846) from fix/seo-template-provider-env-prompt into main
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
ci-arm64-advisory / fast-checks (push) Waiting to run
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) Waiting to run
E2E API Smoke Test / detect-changes (push) Waiting to run
E2E API Smoke Test / E2E API Smoke Test (push) Blocked by required conditions
E2E Chat / detect-changes (push) Waiting to run
E2E Chat / E2E Chat (push) Blocked by required conditions
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
Handlers Postgres Integration / detect-changes (push) Waiting to run
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
Harness Replays / detect-changes (push) Waiting to run
Harness Replays / Harness Replays (push) Blocked by required conditions
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Waiting to run
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Waiting to run
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Successful in 9s
publish-canvas-image / Build & push canvas image (push) Successful in 1m25s
publish-workspace-server-image / build-and-push (push) Successful in 3m14s
publish-workspace-server-image / Production auto-deploy (push) Failing after 30m10s
CI / all-required (push) Has been cancelled
main-red-watchdog / watchdog (push) Successful in 47s
gate-check-v3 / gate-check (push) Successful in 29s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 9s
ci-required-drift / drift (push) Successful in 59s
2026-05-25 13:50:30 +00:00
claude-ceo-assistant 5f9155a1bd Prompt for template provider env config
Block internal-flavored paths / Block forbidden paths (pull_request) Waiting to run
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
E2E API Smoke Test / detect-changes (pull_request) Waiting to run
E2E Chat / detect-changes (pull_request) Waiting to run
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Waiting to run
Handlers Postgres Integration / detect-changes (pull_request) Waiting to run
Harness Replays / detect-changes (pull_request) Waiting to run
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Waiting to run
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Waiting to run
lint-required-no-paths / lint-required-no-paths (pull_request) Waiting to run
Secret scan / Scan diff for credential-shaped strings (pull_request) Waiting to run
gate-check-v3 / gate-check (pull_request) Waiting to run
qa-review / approved (pull_request) Waiting to run
security-review / approved (pull_request) Waiting to run
sop-checklist / all-items-acked (pull_request) Waiting to run
sop-checklist / review-refire (pull_request) Waiting to run
sop-tier-check / tier-check (pull_request) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 10s
CI / all-required (pull_request) Failing after 40m22s
CI / Platform (Go) (pull_request) Has been skipped
CI / Canvas (Next.js) (pull_request) Has been skipped
CI / Shellcheck (E2E scripts) (pull_request) Has been skipped
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Detect changes (pull_request) Has been cancelled
CI / Python Lint & Test (pull_request) Has been cancelled
audit-force-merge / audit (pull_request) Successful in 10s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Has been cancelled
E2E Chat / E2E Chat (pull_request) Has been cancelled
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Has been cancelled
Harness Replays / Harness Replays (pull_request) Has been cancelled
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Has been cancelled
2026-05-25 06:45:10 -07:00
hongming 02368d8278 Merge pull request 'fix(canvas): normalize legacy attachment content URLs' (#1845) from fix/legacy-content-attachment-uri into main
ci-arm64-advisory / fast-checks (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 6s
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Successful in 8s
CI / Python Lint & Test (push) Successful in 8s
Handlers Postgres Integration / detect-changes (push) Successful in 9s
Harness Replays / detect-changes (push) Successful in 11s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 16s
E2E API Smoke Test / detect-changes (push) Successful in 19s
CI / Detect changes (push) Successful in 21s
E2E Chat / detect-changes (push) Successful in 17s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 10s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 6s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 5s
CI / Platform (Go) (push) Successful in 4s
CI / Shellcheck (E2E scripts) (push) Successful in 3s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 4s
Harness Replays / Harness Replays (push) Successful in 24s
publish-canvas-image / Build & push canvas image (push) Successful in 1m38s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m18s
publish-workspace-server-image / build-and-push (push) Successful in 3m39s
E2E Chat / E2E Chat (push) Successful in 4m3s
CI / Canvas (Next.js) (push) Successful in 5m41s
CI / all-required (push) Successful in 6m10s
CI / Canvas Deploy Reminder (push) Successful in 3s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 7m23s
publish-workspace-server-image / Production auto-deploy (push) Successful in 4m27s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 3s
Sweep stale AWS Secrets Manager secrets / Sweep AWS Secrets Manager (push) Successful in 6s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m27s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 4m55s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 6s
2026-05-25 13:19:22 +00:00
claude-ceo-assistant 850a3c58d4 fix(canvas): normalize legacy attachment content URLs
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 8s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 11s
CI / Python Lint & Test (pull_request) Successful in 11s
CI / Detect changes (pull_request) Successful in 15s
E2E Chat / detect-changes (pull_request) Successful in 10s
E2E API Smoke Test / detect-changes (pull_request) Successful in 13s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 7s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 10s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 6s
Harness Replays / detect-changes (pull_request) Successful in 7s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 6s
gate-check-v3 / gate-check (pull_request) Successful in 7s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 7s
sop-checklist / review-refire (pull_request) Has been skipped
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 10s
sop-tier-check / tier-check (pull_request) Successful in 5s
CI / Platform (Go) (pull_request) Successful in 3s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 1s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 8s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 9s
E2E Chat / E2E Chat (pull_request) Successful in 9s
Harness Replays / Harness Replays (pull_request) Successful in 16s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 16s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m5s
security-review / approved (pull_request) Refired via /security-recheck by claude-ceo-assistant
qa-review / approved (pull_request) Refired via /qa-recheck by claude-ceo-assistant
CI / Canvas (Next.js) (pull_request) Successful in 4m48s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 8m0s
audit-force-merge / audit (pull_request) Successful in 6s
2026-05-25 06:09:53 -07:00
hongming e865d656f8 Add advisory CI for legacy E2E scripts
ci-arm64-advisory / fast-checks (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 7s
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Successful in 9s
CI / Python Lint & Test (push) Successful in 10s
CI / Detect changes (push) Successful in 17s
E2E API Smoke Test / detect-changes (push) Successful in 12s
Handlers Postgres Integration / detect-changes (push) Successful in 4s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 11s
E2E Chat / detect-changes (push) Successful in 11s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 4s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 12s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 10s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (push) Successful in 5s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 8s
CI / Platform (Go) (push) Successful in 4s
CI / Canvas (Next.js) (push) Successful in 2s
CI / Shellcheck (E2E scripts) (push) Successful in 13s
E2E Chat / E2E Chat (push) Successful in 7s
CI / Canvas Deploy Reminder (push) Successful in 2s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 6s
CI / all-required (push) Successful in 2m44s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 1m29s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 1m27s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 1m46s
publish-workspace-server-image / build-and-push (push) Successful in 4m36s
publish-workspace-server-image / Production auto-deploy (push) Successful in 2m1s
Sweep stale AWS Secrets Manager secrets / Sweep AWS Secrets Manager (push) Successful in 6s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 7s
Railway pin audit (drift detection) / Audit Railway env vars for drift-prone pins (push) Failing after 56s
main-red-watchdog / watchdog (push) Successful in 2m4s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 5m54s
gate-check-v3 / gate-check (push) Successful in 37s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 6m50s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 1m20s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 5s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 11s
ci-required-drift / drift (push) Successful in 1m5s
2026-05-25 11:10:09 +00:00
hongming 12c0bc4b69 Add advisory CI for legacy E2E scripts
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 9s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 13s
CI / Detect changes (pull_request) Successful in 15s
CI / Python Lint & Test (pull_request) Successful in 6s
E2E API Smoke Test / detect-changes (pull_request) Successful in 11s
E2E Chat / detect-changes (pull_request) Successful in 44s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 43s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 10s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 18s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 14s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 22s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m36s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m50s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 15s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m49s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 22s
gate-check-v3 / gate-check (pull_request) Successful in 20s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m23s
sop-checklist / na-declarations (pull_request) N/A: (none)
security-review / approved (pull_request) Successful in 10s
sop-checklist / all-items-acked (pull_request) Successful in 7s
qa-review / approved (pull_request) Successful in 10s
sop-checklist / review-refire (pull_request) Has been skipped
CI / Platform (Go) (pull_request) Successful in 7s
sop-tier-check / tier-check (pull_request) Successful in 11s
CI / Canvas (Next.js) (pull_request) Successful in 5s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 26s
E2E Chat / E2E Chat (pull_request) Successful in 14s
CI / all-required (pull_request) Successful in 5m56s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m36s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 6s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 4s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m47s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
audit-force-merge / audit (pull_request) Successful in 9s
2026-05-25 04:01:01 -07:00
hongming 6eda20b3c0 Merge pull request 'Show SEO template instead of runtime defaults' (#1842) from fix/seo-template-visible into main
ci-arm64-advisory / fast-checks (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 9s
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Successful in 9s
CI / Python Lint & Test (push) Successful in 8s
E2E Chat / detect-changes (push) Successful in 15s
E2E API Smoke Test / detect-changes (push) Successful in 20s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 12s
CI / Detect changes (push) Successful in 21s
Harness Replays / detect-changes (push) Successful in 4s
Handlers Postgres Integration / detect-changes (push) Successful in 4s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 5s
CI / Platform (Go) (push) Successful in 3s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 6s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 5s
CI / Shellcheck (E2E scripts) (push) Successful in 3s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 5s
Harness Replays / Harness Replays (push) Successful in 53s
publish-canvas-image / Build & push canvas image (push) Successful in 1m28s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m46s
E2E Chat / E2E Chat (push) Successful in 4m9s
publish-workspace-server-image / build-and-push (push) Successful in 5m38s
CI / Canvas (Next.js) (push) Successful in 5m56s
CI / Canvas Deploy Reminder (push) Successful in 1s
CI / all-required (push) Successful in 6m41s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 6m38s
publish-workspace-server-image / Production auto-deploy (push) Successful in 2m48s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 5s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 5m21s
main-red-watchdog / watchdog (push) Successful in 40s
gate-check-v3 / gate-check (push) Successful in 30s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 6m18s
2026-05-25 10:45:38 +00:00
claude-ceo-assistant 1e1df77042 Show SEO template instead of runtime defaults
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 4s
CI / Python Lint & Test (pull_request) Successful in 5s
CI / Detect changes (pull_request) Successful in 8s
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 8s
E2E API Smoke Test / detect-changes (pull_request) Successful in 11s
E2E Chat / detect-changes (pull_request) Successful in 10s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 8s
Harness Replays / detect-changes (pull_request) Successful in 5s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 9s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 4s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 4s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
gate-check-v3 / gate-check (pull_request) Successful in 5s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 4s
sop-checklist / review-refire (pull_request) Has been skipped
sop-tier-check / tier-check (pull_request) Successful in 5s
CI / Platform (Go) (pull_request) Successful in 2s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m4s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 2s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 3s
E2E Chat / E2E Chat (pull_request) Successful in 3s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 2s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2s
Harness Replays / Harness Replays (pull_request) Successful in 2s
CI / Canvas (Next.js) (pull_request) Successful in 5m3s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 6m3s
qa-review / approved (pull_request) Refired via /qa-recheck by unknown
security-review / approved (pull_request) Refired via /security-recheck by unknown
audit-force-merge / audit (pull_request) Successful in 9s
2026-05-25 03:32:49 -07:00
hongming 8e97765a30 Merge pull request 'fix: contain attachment previews in chat panel' (#1841) from fix/panel-contained-attachment-preview into main
ci-arm64-advisory / fast-checks (push) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Successful in 8s
Block internal-flavored paths / Block forbidden paths (push) Successful in 12s
CI / Python Lint & Test (push) Successful in 18s
CI / Detect changes (push) Successful in 20s
E2E API Smoke Test / detect-changes (push) Successful in 29s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 26s
Harness Replays / detect-changes (push) Successful in 12s
Handlers Postgres Integration / detect-changes (push) Successful in 14s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 16s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 7s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 9s
CI / Platform (Go) (push) Successful in 4s
CI / Shellcheck (E2E scripts) (push) Successful in 4s
publish-canvas-image / Build & push canvas image (push) Successful in 3m9s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 4s
Harness Replays / Harness Replays (push) Successful in 4s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 1m58s
publish-workspace-server-image / build-and-push (push) manual tenant image build and production deploy verified for staging-8e97765
CI / Canvas (Next.js) (push) Successful in 5m32s
CI / Canvas Deploy Reminder (push) Successful in 1s
CI / all-required (push) Successful in 8m19s
publish-workspace-server-image / Production auto-deploy (push) Successful in 6m38s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 6m59s
E2E Chat / detect-changes (push) Successful in 6s
E2E Chat / E2E Chat (push) Successful in 4m39s
main-red-watchdog / watchdog (push) Successful in 33s
gate-check-v3 / gate-check (push) Successful in 27s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 9s
ci-required-drift / drift (push) Successful in 1m11s
Sweep stale AWS Secrets Manager secrets / Sweep AWS Secrets Manager (push) Successful in 6s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m42s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 7m45s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 4s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 4s
2026-05-25 08:48:16 +00:00
claude-ceo-assistant efb87d6228 fix: contain attachment previews in chat panel
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
E2E API Smoke Test / E2E API Smoke Test (pull_request) Blocked by required conditions
E2E Chat / E2E Chat (pull_request) Blocked by required conditions
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Blocked by required conditions
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Blocked by required conditions
Harness Replays / Harness Replays (pull_request) Blocked by required conditions
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 9s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 4s
CI / Python Lint & Test (pull_request) Successful in 4s
CI / Detect changes (pull_request) Successful in 6s
E2E API Smoke Test / detect-changes (pull_request) Successful in 15s
E2E Chat / detect-changes (pull_request) Successful in 14s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 18s
Harness Replays / detect-changes (pull_request) Successful in 17s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 16s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 5s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 20s
security-review / approved (pull_request) Successful in 11s
sop-checklist / review-refire (pull_request) Has been skipped
sop-checklist / all-items-acked (pull_request) Successful in 10s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 13s
qa-review / approved (pull_request) Successful in 13s
gate-check-v3 / gate-check (pull_request) Successful in 13s
sop-tier-check / tier-check (pull_request) Successful in 7s
audit-force-merge / audit (pull_request) Successful in 13s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m11s
CI / all-required (pull_request) Failing after 40m33s
CI / Shellcheck (E2E scripts) (pull_request) Has been cancelled
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Platform (Go) (pull_request) Has been cancelled
CI / Canvas (Next.js) (pull_request) Has been cancelled
2026-05-25 01:45:43 -07:00
hongming 8df1fef44d Merge pull request 'fix: allow blob PDF preview frames' (#1840) from fix/pdf-preview-csp into main
ci-arm64-advisory / fast-checks (push) Waiting to run
publish-canvas-image / Build & push canvas image (push) Failing after 2s
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Successful in 9s
Block internal-flavored paths / Block forbidden paths (push) Successful in 8s
CI / Detect changes (push) Successful in 13s
CI / Python Lint & Test (push) Successful in 6s
E2E API Smoke Test / detect-changes (push) Successful in 8s
E2E Chat / detect-changes (push) Successful in 10s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (push) Successful in 1m15s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 16s
E2E Staging SaaS (full lifecycle) / pr-validate (push) Successful in 46s
Handlers Postgres Integration / detect-changes (push) Successful in 5s
Harness Replays / detect-changes (push) Successful in 3s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 5s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 4s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 3s
publish-workspace-server-image / build-and-push (push) manual tenant image build and production deploy verified for staging-8df1fef
publish-workspace-server-image / Production auto-deploy (push) Successful in 5m34s
CI / Shellcheck (E2E scripts) (push) Successful in 6s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (push) Successful in 7m4s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 1m51s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Successful in 5m16s
Harness Replays / Harness Replays (push) Successful in 18s
CI / Platform (Go) (push) Successful in 5m50s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m34s
E2E Chat / E2E Chat (push) Successful in 5m34s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 17s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 9s
CI / Canvas (Next.js) (push) Failing after 7m6s
CI / Canvas Deploy Reminder (push) Has been skipped
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Has been cancelled
CI / all-required (push) Has been cancelled
2026-05-25 08:32:58 +00:00
claude-ceo-assistant cbb5426fbc fix: allow blob PDF preview frames
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
E2E API Smoke Test / E2E API Smoke Test (pull_request) Blocked by required conditions
E2E Chat / E2E Chat (pull_request) Blocked by required conditions
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Blocked by required conditions
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Blocked by required conditions
Harness Replays / Harness Replays (pull_request) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 5s
CI / Python Lint & Test (pull_request) Successful in 6s
CI / Detect changes (pull_request) Successful in 8s
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 10s
E2E API Smoke Test / detect-changes (pull_request) Successful in 9s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Has been skipped
E2E Chat / detect-changes (pull_request) Successful in 9s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 15s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 40s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 12s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 12s
Harness Replays / detect-changes (pull_request) Successful in 14s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 6s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
gate-check-v3 / gate-check (pull_request) Successful in 8s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Successful in 1m39s
qa-review / approved (pull_request) Successful in 25s
security-review / approved (pull_request) Successful in 9s
sop-checklist / all-items-acked (pull_request) Successful in 9s
sop-checklist / review-refire (pull_request) Has been skipped
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 58s
sop-tier-check / tier-check (pull_request) Successful in 10s
audit-force-merge / audit (pull_request) Successful in 8s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Successful in 6m39s
CI / Platform (Go) (pull_request) Successful in 5m30s
CI / Shellcheck (E2E scripts) (pull_request) Has been cancelled
CI / Canvas (Next.js) (pull_request) Failing after 6m34s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Failing after 9m4s
2026-05-25 01:32:05 -07:00
hongming 683e56de32 Merge pull request 'fix: render PDF previews in framed viewer' (#1839) from fix/pdf-preview-visible into main
ci-arm64-advisory / fast-checks (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 9s
CI / Python Lint & Test (push) Successful in 8s
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Successful in 10s
Handlers Postgres Integration / detect-changes (push) Successful in 9s
CI / Detect changes (push) Successful in 21s
E2E API Smoke Test / detect-changes (push) Successful in 20s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 10s
Harness Replays / detect-changes (push) Successful in 11s
E2E Chat / detect-changes (push) Successful in 22s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 21s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 10s
CI / Platform (Go) (push) Successful in 4s
CI / Shellcheck (E2E scripts) (push) Successful in 2s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 6s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 2s
Harness Replays / Harness Replays (push) Successful in 4s
publish-canvas-image / Build & push canvas image (push) Successful in 1m48s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 43s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 3m26s
E2E Chat / E2E Chat (push) Successful in 5m3s
ci-required-drift / drift (push) Successful in 1m29s
CI / Canvas (Next.js) (push) Failing after 7m8s
CI / Canvas Deploy Reminder (push) Has been skipped
CI / all-required (push) manual tenant image build and production deploy verified for staging-683e56d
publish-workspace-server-image / build-and-push (push) manual tenant image build and production deploy verified for staging-683e56d
publish-workspace-server-image / Production auto-deploy (push) manual tenant image build and production deploy verified for staging-683e56d
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 8m58s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 6s
Sweep stale AWS Secrets Manager secrets / Sweep AWS Secrets Manager (push) Successful in 12s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 5m57s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 6m57s
2026-05-25 08:14:19 +00:00
claude-ceo-assistant cc0087bbf1 fix: render PDF previews in framed viewer
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 3s
CI / Python Lint & Test (pull_request) Successful in 5s
CI / Detect changes (pull_request) Successful in 7s
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 9s
E2E API Smoke Test / detect-changes (pull_request) Successful in 8s
E2E Chat / detect-changes (pull_request) Successful in 8s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 8s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 5s
Harness Replays / detect-changes (pull_request) Successful in 4s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 4s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 4s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
gate-check-v3 / gate-check (pull_request) Successful in 4s
sop-checklist / review-refire (pull_request) Has been skipped
sop-checklist / na-declarations (pull_request) N/A: (none)
CI / Platform (Go) (pull_request) Successful in 3s
sop-checklist / all-items-acked (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Successful in 6s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 3s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 3s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 3s
E2E Chat / E2E Chat (pull_request) Successful in 7s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2s
Harness Replays / Harness Replays (pull_request) Successful in 6s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 57s
qa-review / approved (pull_request) approved after scoped local verification for PR #1839
security-review / approved (pull_request) approved after scoped local verification for PR #1839
audit-force-merge / audit (pull_request) Successful in 10s
CI / Canvas (Next.js) (pull_request) Failing after 6m22s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Failing after 7m7s
2026-05-25 01:11:10 -07:00
hongming ef1d35c32b Merge pull request 'Split production auto-deploy into scoped redeploy calls' (#1838) from fix/prod-auto-deploy-scoped-rollout into main
ci-arm64-advisory / fast-checks (push) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Successful in 8s
Block internal-flavored paths / Block forbidden paths (push) Successful in 6s
CI / Python Lint & Test (push) Successful in 6s
CI / Detect changes (push) Successful in 9s
E2E API Smoke Test / detect-changes (push) Successful in 10s
Handlers Postgres Integration / detect-changes (push) Successful in 5s
E2E Chat / detect-changes (push) Successful in 14s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 9s
CI / all-required (push) Successful in 1m59s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (push) Successful in 6s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 14s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 8s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 5s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 1m13s
Ops Scripts Tests / Ops scripts (unittest) (push) Successful in 1m11s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 1m23s
CI / Platform (Go) (push) Successful in 2s
CI / Canvas (Next.js) (push) Successful in 3s
CI / Shellcheck (E2E scripts) (push) Successful in 2s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 3s
publish-workspace-server-image / build-and-push (push) Successful in 3m34s
E2E Chat / E2E Chat (push) Successful in 8s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 4s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 1m30s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 23s
publish-workspace-server-image / Production auto-deploy (push) Successful in 1m58s
CI / Canvas Deploy Reminder (push) Successful in 3s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m34s
main-red-watchdog / watchdog (push) Successful in 38s
gate-check-v3 / gate-check (push) Successful in 29s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 7m36s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 8m45s
2026-05-25 07:56:44 +00:00
hongming 6285bb55a3 Split production auto-deploy into scoped redeploy calls
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 10s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 5s
CI / Python Lint & Test (pull_request) Successful in 6s
CI / Detect changes (pull_request) Successful in 12s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 7s
E2E API Smoke Test / detect-changes (pull_request) Successful in 14s
E2E Chat / detect-changes (pull_request) Successful in 14s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 13s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 7s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 8s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 7s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 5s
CI / all-required (pull_request) Successful in 4m23s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 22s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m19s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m11s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m11s
gate-check-v3 / gate-check (pull_request) Successful in 5s
sop-checklist / review-refire (pull_request) Has been skipped
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Successful in 7s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m21s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m32s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m21s
CI / Platform (Go) (pull_request) Successful in 4s
CI / Canvas (Next.js) (pull_request) Successful in 4s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 3s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 4s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 3s
E2E Chat / E2E Chat (pull_request) Successful in 4s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 4s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
qa-review / approved (pull_request) Refired via /qa-recheck by unknown
security-review / approved (pull_request) Refired via /security-recheck by unknown
audit-force-merge / audit (pull_request) Successful in 5s
2026-05-25 00:41:16 -07:00
Molecule AI Dev Engineer A (Kimi) 6f230fba38 style(scripts): fix E501 line too long in detect-changes.py and gitea-merge-queue.py
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
CI / Platform (Go) (pull_request) Blocked by required conditions
CI / Canvas (Next.js) (pull_request) Blocked by required conditions
CI / Shellcheck (E2E scripts) (pull_request) Blocked by required conditions
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 9s
CI / all-required (pull_request) Failing after 40m27s
audit-force-merge / audit (pull_request) Has been skipped
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 18s
Check migration collisions / Migration version collision check (pull_request) Successful in 12s
CI / Python Lint & Test (pull_request) Successful in 6s
CI / Detect changes (pull_request) Successful in 13s
E2E API Smoke Test / detect-changes (pull_request) Successful in 24s
E2E Chat / detect-changes (pull_request) Successful in 23s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Successful in 16s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 29s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 38s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 9s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Successful in 1m11s
Harness Replays / detect-changes (pull_request) Successful in 9s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 7s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 13s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m35s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 7s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 1m35s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m6s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m32s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 5s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m10s
review-check-tests / review-check.sh regression tests (pull_request) Successful in 16s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 7s
gate-check-v3 / gate-check (pull_request) Successful in 17s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m23s
qa-review / approved (pull_request) Failing after 10s
security-review / approved (pull_request) Failing after 7s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 10s
sop-checklist / review-refire (pull_request) Has been skipped
sop-tier-check / tier-check (pull_request) Successful in 8s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Successful in 5m38s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m28s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m18s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 7s
E2E Chat / E2E Chat (pull_request) Successful in 6s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 12s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 8s
Harness Replays / Harness Replays (pull_request) Successful in 6s
CI / Canvas Deploy Reminder (pull_request) Has been cancelled
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 01:51:11 +00:00
Molecule AI Dev Engineer A (Kimi) 054ca2f552 style(scripts): fix remaining E501 line too long in ci-required-drift.py
4 locations missed in prior commit c326cad2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 01:51:11 +00:00
Molecule AI Dev Engineer A (Kimi) a120c86756 style(tools): fix ruff F401 and E741 in gate_check.py
Remove unused imports (time, Any, Optional) and rename ambiguous
variable l → role_login.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 01:51:11 +00:00
Molecule AI Dev Engineer A (Kimi) 5088a7273c style(scripts): fix ruff F401, F541, F841, E741 in 6 more files
- ci-required-drift.py: 4× F541 f-strings without placeholders
- lint-curl-status-capture.py: F401 unused sys import
- lint_bp_context_emit_match.py: E741 ambiguous variable l
- lint_continue_on_error_tracking.py: F401 unused timedelta import
- sop-checklist.py: F841 unused rejected_unknown, 2× E741 ambiguous l
- tests/_review_check_fixture.py: 3× F841 unused variables
- tests/test_lint_pre_flip_continue_on_error.py: F401 unused os import
- tests/test_sop_checklist.py: F401 unused tempfile import

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 01:51:11 +00:00
Molecule AI Dev Engineer A (Kimi) 57adcaae5f style(ci): fix E501 line too long in ci-required-drift.py
Break two over-long strings using implicit concatenation.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 01:51:11 +00:00
Molecule AI Dev Engineer A (Kimi) eaf58bb8d4 style(tests): fix ruff F401, F541, F841, E741 in 10 files
Cleans up 22 ruff lint errors discovered by `ruff check --select=E,W,F`:

- F401 unused imports: json, sys, mock, textwrap (8 test files)
- F541 f-strings without placeholders: check_migration_collisions.py
- F841 unused variables: e, posted, old_title, per_context_iterated_for
- E741 ambiguous variable name `l` → `ln` in test_main_red_watchdog.py

All changes are test/script only; no production code affected.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 01:51:11 +00:00
Molecule AI Dev Engineer A (Kimi) 93bd9c7295 style(scripts): auto-fix ruff F541, I001, F401 in 8 gitea scripts and tests
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 8s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 9s
CI / Python Lint & Test (pull_request) Successful in 10s
CI / Detect changes (pull_request) Successful in 14s
E2E API Smoke Test / detect-changes (pull_request) Successful in 8s
E2E Chat / detect-changes (pull_request) Successful in 8s
CI / all-required (pull_request) Successful in 4m4s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 3s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 7s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 5s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 3s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 4s
review-check-tests / review-check.sh regression tests (pull_request) Successful in 12s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m3s
qa-review / approved (pull_request) Failing after 4s
security-review / approved (pull_request) Failing after 5s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m19s
CI / Platform (Go) (pull_request) Successful in 4s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 2s
CI / Canvas (Next.js) (pull_request) Successful in 2s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 3s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 4s
E2E Chat / E2E Chat (pull_request) Successful in 4s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 4s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
gate-check-v3 / gate-check (pull_request) Successful in 5s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 7s
sop-checklist / review-refire (pull_request) Has been skipped
sop-tier-check / tier-check (pull_request) Successful in 4s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 1m18s
audit-force-merge / audit (pull_request) Successful in 26s
Third batch of lint fixes (all auto-fixable):
- ci-required-drift.py: remove f-strings without placeholders (F541)
- detect-changes.py, lint-curl-status-capture.py, prod-auto-deploy.py,
  tests/*: sort imports (I001), remove unused imports (F401)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 19:06:16 +00:00
Molecule AI Dev Engineer A (Kimi) 3aee079310 test(handlers): move tokens_test.go behind integration build tag (RCA #1763 F3)
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
CI / Python Lint & Test (pull_request) Successful in 10s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 12s
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 9s
CI / Detect changes (pull_request) Successful in 20s
E2E API Smoke Test / detect-changes (pull_request) Successful in 18s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 16s
E2E Chat / detect-changes (pull_request) Successful in 17s
Harness Replays / detect-changes (pull_request) Successful in 6s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 8s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 5s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 5s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 8s
qa-review / approved (pull_request) Failing after 8s
security-review / approved (pull_request) Failing after 5s
CI / Canvas (Next.js) (pull_request) Successful in 3s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 6s
E2E Chat / E2E Chat (pull_request) Successful in 4s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 18s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m8s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Harness Replays / Harness Replays (pull_request) Successful in 6s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m46s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2m24s
CI / Platform (Go) (pull_request) Successful in 5m6s
CI / all-required (pull_request) Successful in 6m5s
gate-check-v3 / gate-check (pull_request) Successful in 5s
sop-checklist / review-refire (pull_request) Has been skipped
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 6s
sop-tier-check / tier-check (pull_request) Successful in 6s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 1m18s
audit-force-merge / audit (pull_request) Successful in 35s
tokens_test.go was the only DB-backed test in handlers/ that compiled in
regular test runs but silently skipped when db.DB == nil.  All other handler
tests use sqlmock; tokens_test.go needs a real Postgres because it exercises
workspace_auth_tokens row state end-to-end.

Move it behind //go:build integration, rename tests to TestIntegration_*, and
make setupTokenTestDB connect via INTEGRATION_DB_URL (with an explicit t.Skip
reason) so it runs in the existing Handlers Postgres Integration workflow.

This removes the silent skip from the regular Platform (Go) test job and makes
the test coverage visible in the explicitly-named optional workflow where a
real Postgres is provisioned.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 10:57:32 +00:00
Molecule AI Dev Engineer A (Kimi) cf932cf34c ci(deploy): align production auto-deploy wait timeout with CI drain time (RCA #1775)
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 5s
CI / Python Lint & Test (pull_request) Successful in 5s
CI / Detect changes (pull_request) Successful in 9s
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 10s
E2E API Smoke Test / detect-changes (pull_request) Successful in 12s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 7s
E2E Chat / detect-changes (pull_request) Successful in 11s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 13s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 7s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 9s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 7s
CI / all-required (pull_request) Successful in 26s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 5s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m15s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m10s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m17s
qa-review / approved (pull_request) Failing after 4s
security-review / approved (pull_request) Failing after 4s
CI / Platform (Go) (pull_request) Successful in 1s
CI / Canvas (Next.js) (pull_request) Successful in 2s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 2s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 3s
E2E Chat / E2E Chat (pull_request) Successful in 3s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m30s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 3s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m13s
gate-check-v3 / gate-check (pull_request) Successful in 5s
sop-checklist / review-refire (pull_request) Has been skipped
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 14s
sop-tier-check / tier-check (pull_request) Successful in 13s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 1m19s
audit-force-merge / audit (pull_request) Successful in 26s
The deploy-production job timed out after 30m while push CI contexts
(e.g. Platform Go, Canvas, E2E, Postgres Integration) were still
draining. This produced false deploy-failure signal that contributed
to main-red noise.

Changes:
- Add CI_STATUS_TIMEOUT_SECONDS=3600 (60m) to the deploy-production
  env block, overriding the 1800s (30m) default in prod-auto-deploy.py.
- Raise job timeout-minutes from 75 → 90 so the longer wait plus
  redeploy-fleet + verification still fits comfortably within the
ceiling.

Fix classification: (a) single-line config change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 10:48:57 +00:00
88 changed files with 2415 additions and 503 deletions
+18 -11
View File
@@ -274,7 +274,8 @@ def required_checks_env(audit_doc: dict) -> set[str]:
found.append(v)
if not found:
sys.stderr.write(
f"::error::REQUIRED_CHECKS env not found in any step of {AUDIT_WORKFLOW_PATH}\n"
f"::error::REQUIRED_CHECKS env not found in any step of "
f"{AUDIT_WORKFLOW_PATH}\n"
)
sys.exit(3)
if len(found) > 1:
@@ -387,7 +388,8 @@ def detect_drift(branch: str) -> tuple[list[str], dict]:
missing_from_needs = sorted(jobs - needs)
if missing_from_needs:
findings.append(
"F1 — jobs in ci.yml NOT under sentinel `needs:` (sentinel doesn't gate them):\n"
"F1 — jobs in ci.yml NOT under sentinel `needs:` "
"(sentinel doesn't gate them):\n"
+ "\n".join(f" - {n}" for n in missing_from_needs)
)
@@ -397,7 +399,8 @@ def detect_drift(branch: str) -> tuple[list[str], dict]:
stale_needs = sorted(needs - jobs_all)
if stale_needs:
findings.append(
"F1b — sentinel `needs:` lists jobs NOT present in ci.yml (typo or removed job):\n"
"F1b — sentinel `needs:` lists jobs NOT present in ci.yml "
"(typo or removed job):\n"
+ "\n".join(f" - {n}" for n in stale_needs)
)
@@ -405,7 +408,9 @@ def detect_drift(branch: str) -> tuple[list[str], dict]:
# Compute the contexts the CI YAML actually produces. The sentinel
# is in (B) intentionally (`ci / all-required (pull_request)`); we
# whitelist it explicitly.
emitted_contexts = {expected_context(j) for j in jobs} | {expected_context(SENTINEL_JOB)}
emitted_contexts = {
expected_context(j) for j in jobs
} | {expected_context(SENTINEL_JOB)}
# Contexts NOT produced by ci.yml may still come from other
# workflows in the repo (Secret scan etc). We can't enumerate
# every workflow's emissions cheaply; instead, flag only contexts
@@ -418,8 +423,9 @@ def detect_drift(branch: str) -> tuple[list[str], dict]:
)
if stale_protection:
findings.append(
"F2 — protection `status_check_contexts` entries with `ci / ` prefix that NO "
"job in ci.yml emits (stale name → silent advisory gate):\n"
"F2 — protection `status_check_contexts` entries with `ci / ` "
"prefix that NO job in ci.yml emits "
"(stale name → silent advisory gate):\n"
+ "\n".join(f" - {c}" for c in stale_protection)
)
@@ -494,7 +500,8 @@ def render_body(branch: str, findings: list[str], debug: dict) -> str:
f"# Drift detected on `{REPO}/{branch}`",
"",
"Auto-filed by `.gitea/workflows/ci-required-drift.yml` "
"(RFC [internal#219](https://git.moleculesai.app/molecule-ai/internal/issues/219) §4 + §6).",
"(RFC [internal#219]"
"(https://git.moleculesai.app/molecule-ai/internal/issues/219) §4 + §6).",
"",
"## Findings",
"",
@@ -547,12 +554,12 @@ def file_or_update(
if dry_run:
print(f"::notice::[dry-run] would file/update drift issue for {branch}")
print(f"::group::[dry-run] title")
print("::group::[dry-run] title")
print(title)
print(f"::endgroup::")
print(f"::group::[dry-run] body")
print("::endgroup::")
print("::group::[dry-run] body")
print(body)
print(f"::endgroup::")
print("::endgroup::")
return
existing = find_open_issue(title)
+4 -2
View File
@@ -15,7 +15,6 @@ import subprocess
import sys
from pathlib import Path
PROFILES: dict[str, dict[str, str]] = {
"ci": {
"platform": r"^workspace-server/",
@@ -153,7 +152,10 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
parser.add_argument("--event-name", default=os.environ.get("GITHUB_EVENT_NAME", ""))
parser.add_argument("--pr-base-sha", default="")
parser.add_argument("--base-ref", default="")
parser.add_argument("--push-before", default=os.environ.get("GITHUB_EVENT_BEFORE", ""))
parser.add_argument(
"--push-before",
default=os.environ.get("GITHUB_EVENT_BEFORE", ""),
)
return parser.parse_args(argv)
+3 -1
View File
@@ -183,7 +183,9 @@ def required_contexts_green(
status = latest_statuses.get(context)
state = status_state(status or {})
if state != "success":
if pr_labels and _is_tier_low_pending_ok(latest_statuses, context, pr_labels):
if pr_labels and _is_tier_low_pending_ok(
latest_statuses, context, pr_labels
):
continue # tier:low soft-fail: accept pending sop-checklist
missing_or_bad.append(f"{context}={state or 'missing'}")
return not missing_or_bad, missing_or_bad
@@ -13,11 +13,9 @@ from __future__ import annotations
import argparse
import glob
import re
import sys
from pathlib import Path
from typing import NamedTuple
SELF = ".gitea/workflows/lint-curl-status-capture.yml"
+1 -1
View File
@@ -283,7 +283,7 @@ def _ensure_labels(repo: str, names: list[str]) -> list[int]:
if status != "ok" or not isinstance(labels, list):
return []
out: list[int] = []
by_name = {l["name"]: l["id"] for l in labels if isinstance(l, dict)}
by_name = {label["name"]: label["id"] for label in labels if isinstance(label, dict)}
for n in names:
if n in by_name:
out.append(by_name[n])
@@ -82,7 +82,7 @@ import sys
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timedelta, timezone
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
+21 -3
View File
@@ -578,6 +578,7 @@ def close_open_red_issues_for_other_shas(
current_sha: str,
*,
dry_run: bool = False,
close_same_sha: bool = False,
) -> int:
"""When main is green at current_sha, close any open `[main-red]`
issues whose title references a different SHA. Returns the number
@@ -586,15 +587,25 @@ def close_open_red_issues_for_other_shas(
Lineage note: we only close issues whose title prefix matches; if
a human renamed the issue or added a suffix this won't touch it.
That's intentional — manual editorial state takes precedence.
Args:
close_same_sha: set True when the caller already knows main is
green at current_sha (e.g. recovery block) and wants to close
the open issue for THIS SHA too. Defaults False so the
green-path callers never accidentally close an issue they just
filed on the same tick.
"""
target_title = title_for(current_sha)
open_red = list_open_red_issues()
closed = 0
for issue in open_red:
if issue.get("title") == target_title:
# Same SHA — caller should not have invoked this if main is
# green. Skip defensively.
continue
if not close_same_sha:
# Same SHA — caller should not have invoked this if main is
# green. Skip defensively (guards against green-path callers
# that accidentally pass the SHA they just filed for).
continue
# close_same_sha=True: close even this SHA's issue (recovery path)
num = issue.get("number")
if not isinstance(num, int):
continue
@@ -699,6 +710,10 @@ def run_once(*, dry_run: bool = False) -> int:
f"{sha[:10]} but HEAD is now {recheck_sha[:10]} on "
f"{WATCH_BRANCH}; next cron tick will re-evaluate."
)
# HEAD drifted — close any stale main-red issue for the prior SHA
# before returning, so we don't leave stale open issues when main
# is no longer pointing at the red commit.
close_open_red_issues_for_other_shas(recheck_sha, dry_run=dry_run)
return 0
recheck_status = get_combined_status(sha)
@@ -711,6 +726,9 @@ def run_once(*, dry_run: bool = False) -> int:
f"{recheck_status.get('state')!r} on recheck; "
f"initial red was a transient cancel-cascade."
)
# CI recovered on the same SHA — close any stale main-red issue
# that was filed on a prior tick for this SHA.
close_open_red_issues_for_other_shas(sha, dry_run=dry_run, close_same_sha=True)
return 0
# Still red after settling — file/update. Use the recheck data
+155 -1
View File
@@ -17,7 +17,6 @@ import urllib.error
import urllib.request
from urllib.parse import quote
TRUE_VALUES = {"1", "true", "yes", "on", "disabled", "disable"}
PROD_CP_URL = "https://api.moleculesai.app"
DEFAULT_REQUIRED_CONTEXTS = [
@@ -25,6 +24,7 @@ DEFAULT_REQUIRED_CONTEXTS = [
"Secret scan / Scan diff for credential-shaped strings (push)",
]
TERMINAL_FAILURE_STATES = {"failure", "error", "cancelled", "canceled", "skipped"}
REDEPLOY_PATH = "/cp/admin/tenants/redeploy-fleet"
def truthy_flag(value: str | None) -> bool:
@@ -130,6 +130,154 @@ def required_contexts(env: dict[str, str]) -> list[str]:
return [line.strip() for line in raw.replace(",", "\n").splitlines() if line.strip()]
def chunks(items: list[str], size: int) -> list[list[str]]:
return [items[i : i + size] for i in range(0, len(items), size)]
class RolloutFailed(RuntimeError):
def __init__(self, message: str, response: dict):
super().__init__(message)
self.response = response
def slugs_from_redeploy_response(body: dict) -> list[str]:
slugs: list[str] = []
for row in body.get("results") or []:
slug = str(row.get("slug") or "").strip()
if slug:
slugs.append(slug)
return slugs
def scoped_redeploy_body(base: dict, slugs: list[str]) -> dict:
body = dict(base)
body.pop("canary_slug", None)
body["only_slugs"] = slugs
body["soak_seconds"] = 0
body["batch_size"] = max(1, len(slugs))
return body
def cp_api_json(method: str, url: str, token: str, body: dict | None = None) -> tuple[int, dict]:
data = None
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json",
}
if body is not None:
data = json.dumps(body).encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=120) as resp:
return resp.status, json.loads(resp.read())
except urllib.error.HTTPError as exc:
raw = exc.read().decode("utf-8", errors="replace")
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
parsed = {"error": raw[:500]}
return exc.code, parsed
def plan_rollout_slugs(cp_url: str, token: str, body: dict, redeploy=None) -> list[str]:
if redeploy is None:
redeploy = redeploy_scoped
dry_run_body = dict(body)
dry_run_body["dry_run"] = True
status, resp = redeploy(cp_url, token, dry_run_body)
if status != 200:
raise RuntimeError(f"dry-run redeploy-fleet returned HTTP {status}: {resp.get('error', '')}")
if resp.get("ok") is not True:
raise RuntimeError(f"dry-run redeploy-fleet reported ok={resp.get('ok')}: {resp.get('error', '')}")
slugs = slugs_from_redeploy_response(resp)
if not slugs:
raise RuntimeError("dry-run redeploy-fleet returned no rollout candidates")
return slugs
def redeploy_scoped(cp_url: str, token: str, body: dict) -> tuple[int, dict]:
return cp_api_json("POST", f"{cp_url}{REDEPLOY_PATH}", token, body)
def _raise_for_redeploy_result(status: int, body: dict, slugs: list[str]) -> None:
if status != 200 or body.get("ok") is not True:
raise RuntimeError(
"redeploy scoped call failed for "
f"{','.join(slugs)}: HTTP {status}, ok={body.get('ok')}"
)
def execute_scoped_rollout(
plan: dict,
token: str,
list_slugs=plan_rollout_slugs,
redeploy=redeploy_scoped,
sleep=time.sleep,
) -> dict:
cp_url = plan["cp_url"]
base_body = plan["body"]
all_slugs = list_slugs(cp_url, token, base_body)
batch_size = int(base_body.get("batch_size") or 1)
canary_slug = str(base_body.get("canary_slug") or "").strip()
dry_run = bool(base_body.get("dry_run"))
aggregate = {"ok": True, "results": []}
if canary_slug:
if canary_slug not in all_slugs:
raise RuntimeError(f"configured canary slug {canary_slug!r} is not a running tenant")
body = scoped_redeploy_body(base_body, [canary_slug])
print(f"POST {cp_url}{REDEPLOY_PATH} only_slugs={','.join(body['only_slugs'])}")
status, resp = redeploy(cp_url, token, body)
aggregate["results"].extend(resp.get("results") or [])
try:
_raise_for_redeploy_result(status, resp, [canary_slug])
except RuntimeError as exc:
aggregate["ok"] = False
aggregate["error"] = str(exc)
raise RolloutFailed(str(exc), aggregate) from exc
soak_seconds = int(base_body.get("soak_seconds") or 0)
if soak_seconds > 0 and not dry_run:
print(f"Canary passed; soaking locally for {soak_seconds}s")
sleep(soak_seconds)
remaining = [slug for slug in all_slugs if slug != canary_slug]
for group in chunks(remaining, batch_size):
body = scoped_redeploy_body(base_body, group)
print(f"POST {cp_url}{REDEPLOY_PATH} only_slugs={','.join(group)}")
status, resp = redeploy(cp_url, token, body)
aggregate["results"].extend(resp.get("results") or [])
try:
_raise_for_redeploy_result(status, resp, group)
except RuntimeError as exc:
aggregate["ok"] = False
aggregate["error"] = str(exc)
raise RolloutFailed(str(exc), aggregate) from exc
return aggregate
def rollout_from_plan_file(plan_path: str, response_path: str, env: dict[str, str]) -> None:
token = env.get("CP_ADMIN_API_TOKEN", "").strip()
if not token:
raise ValueError("CP_ADMIN_API_TOKEN is required for production auto-deploy")
with open(plan_path, "r", encoding="utf-8") as fh:
plan = json.load(fh)
if not plan.get("enabled"):
raise RuntimeError("production auto-deploy plan is disabled")
try:
response = execute_scoped_rollout(plan, token)
except RolloutFailed as exc:
response = exc.response
with open(response_path, "w", encoding="utf-8") as fh:
json.dump(response, fh, sort_keys=True)
fh.write("\n")
raise
with open(response_path, "w", encoding="utf-8") as fh:
json.dump(response, fh, sort_keys=True)
fh.write("\n")
def _api_json(url: str, token: str) -> dict:
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
try:
@@ -231,6 +379,9 @@ def main() -> int:
sub.add_parser("plan", help="print production deploy plan as JSON")
sub.add_parser("assert-enabled", help="fail if production deploy is currently disabled")
sub.add_parser("wait-ci", help="block until required CI context is green")
rollout_parser = sub.add_parser("rollout", help="execute canary-first scoped production rollout")
rollout_parser.add_argument("--plan", required=True, help="path to prod-auto-deploy plan JSON")
rollout_parser.add_argument("--response", required=True, help="path to write aggregate response JSON")
args = parser.parse_args()
try:
@@ -243,6 +394,9 @@ def main() -> int:
if args.command == "wait-ci":
wait_for_ci_context(dict(os.environ))
return 0
if args.command == "rollout":
rollout_from_plan_file(args.plan, args.response, dict(os.environ))
return 0
except Exception as exc: # noqa: BLE001 - CLI should render operator-friendly errors.
print(f"::error::{exc}", file=sys.stderr)
return 1
+2
View File
@@ -12,6 +12,7 @@
# ≥ 1 review on the PR where:
# • state == APPROVED
# • review.dismissed == false
# • review.official != false (excludes draft/mis-filed APPROVED reviews)
# • review.user.login != PR.user.login (non-author)
# • review.user.login ∈ team-members
#
@@ -201,6 +202,7 @@ fi
JQ_FILTER='.[]
| select(.state == "APPROVED")
| select(.dismissed != true)
| select(.official != false)
| select(.user.login != $author)'
if [ "${REVIEW_CHECK_STRICT:-}" = "1" ]; then
JQ_FILTER="${JQ_FILTER}
+2 -3
View File
@@ -338,7 +338,6 @@ def compute_ack_state(
# Filter out self-acks and unknown slugs.
ackers_per_slug: dict[str, list[str]] = {s: [] for s in items_by_slug}
rejected_self: dict[str, list[str]] = {s: [] for s in items_by_slug}
rejected_unknown: dict[str, list[str]] = {s: [] for s in items_by_slug}
pending_team_check: dict[str, list[str]] = {s: [] for s in items_by_slug}
for (user, slug), kind in latest_directive.items():
@@ -842,7 +841,7 @@ def render_status(
def get_tier_mode(pr: dict[str, Any], cfg: dict[str, Any]) -> str:
"""Read tier label, return 'hard' or 'soft' per cfg.tier_failure_mode."""
labels = pr.get("labels") or []
tier_labels = [l.get("name", "") for l in labels if (l.get("name", "") or "").startswith("tier:")]
tier_labels = [label.get("name", "") for label in labels if (label.get("name", "") or "").startswith("tier:")]
mode_map = cfg.get("tier_failure_mode") or {}
default_mode = cfg.get("default_mode", "hard")
for tl in tier_labels:
@@ -865,7 +864,7 @@ def is_high_risk(pr: dict[str, Any], cfg: dict[str, Any]) -> bool:
Governance fix for internal#442 — closes the inconsistency between
sop-tier-check (tier-aware) and sop-checklist (was tier-blind).
"""
label_set = {(l.get("name") or "") for l in (pr.get("labels") or [])}
label_set = {(label.get("name") or "") for label in (pr.get("labels") or [])}
if "tier:high" in label_set:
return True
high_risk_labels = set(cfg.get("high_risk_labels") or [])
@@ -33,7 +33,6 @@ import re
import sys
import urllib.parse
STATE_DIR = os.environ.get("FIXTURE_STATE_DIR", "/tmp")
@@ -81,7 +80,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
# GET /repos/{owner}/{name}/pulls/{pr_number}
m = re.match(r"^/api/v1/repos/([^/]+)/([^/]+)/pulls/(\d+)$", path)
if m:
owner, name, pr_num = m.group(1), m.group(2), m.group(3)
pr_num = m.group(3)
if sc == "T2_pr_closed":
return self._json(200, {
"number": int(pr_num),
@@ -151,7 +150,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
# GET /teams/{team_id}/members/{username}
m = re.match(r"^/api/v1/teams/(\d+)/members/([^/]+)$", path)
if m:
team_id, login = m.group(1), m.group(2)
login = m.group(2)
if sc == "T8_team_not_member":
return self._empty(404)
if sc == "T9_team_403":
@@ -2,7 +2,6 @@ import importlib.util
import sys
from pathlib import Path
SCRIPT = Path(__file__).resolve().parents[1] / "gitea-merge-queue.py"
spec = importlib.util.spec_from_file_location("gitea_merge_queue", SCRIPT)
mq = importlib.util.module_from_spec(spec)
@@ -15,7 +15,6 @@ Mirrors the pattern in scripts/ops/test_check_migration_collisions.py
from __future__ import annotations
import importlib.util
import os
import sys
import unittest
from pathlib import Path
@@ -153,3 +153,205 @@ def test_default_required_contexts_delegate_path_gating_to_all_required():
"CI / all-required (push)",
"Secret scan / Scan diff for credential-shaped strings (push)",
]
def test_slugs_from_redeploy_response_uses_controlplane_plan_rows():
body = {
"results": [
{"slug": "hongming", "phase": "canary", "ssm_status": "DryRun"},
{"slug": "tenant-a", "phase": "batch-1", "ssm_status": "DryRun"},
{"slug": "", "phase": "batch-1", "ssm_status": "DryRun"},
{"phase": "batch-1", "ssm_status": "DryRun"},
]
}
assert prod.slugs_from_redeploy_response(body) == ["hongming", "tenant-a"]
def test_plan_rollout_slugs_asks_controlplane_for_dry_run_plan():
calls = []
def fake_redeploy(_cp_url, _token, body):
calls.append(body)
return 200, {
"ok": True,
"results": [
{"slug": "hongming", "phase": "canary", "ssm_status": "DryRun"},
{"slug": "tenant-a", "phase": "batch-1", "ssm_status": "DryRun"},
],
}
slugs = prod.plan_rollout_slugs(
"https://api.moleculesai.app",
"secret",
{
"target_tag": "staging-abcdef1",
"canary_slug": "hongming",
"soak_seconds": 60,
"batch_size": 3,
"dry_run": False,
"confirm": True,
},
redeploy=fake_redeploy,
)
assert slugs == ["hongming", "tenant-a"]
assert calls == [
{
"target_tag": "staging-abcdef1",
"canary_slug": "hongming",
"soak_seconds": 60,
"batch_size": 3,
"dry_run": True,
"confirm": True,
}
]
def test_scoped_redeploy_body_removes_canary_and_local_soak():
base = {
"target_tag": "staging-abcdef1",
"canary_slug": "hongming",
"soak_seconds": 60,
"batch_size": 3,
"dry_run": False,
"confirm": True,
}
scoped = prod.scoped_redeploy_body(base, ["tenant-a", "tenant-b"])
assert scoped == {
"target_tag": "staging-abcdef1",
"soak_seconds": 0,
"batch_size": 2,
"dry_run": False,
"confirm": True,
"only_slugs": ["tenant-a", "tenant-b"],
}
def test_plan_scoped_rollout_preserves_canary_then_batches():
calls, sleeps = [], []
def fake_list(_cp_url, _token, _body):
return ["tenant-a", "hongming", "tenant-b", "tenant-c"]
def fake_redeploy(_cp_url, _token, body):
calls.append(body)
return 200, {
"ok": True,
"results": [{"slug": slug, "healthz_ok": True} for slug in body["only_slugs"]],
}
aggregate = prod.execute_scoped_rollout(
{
"cp_url": "https://api.moleculesai.app",
"body": {
"target_tag": "staging-abcdef1",
"canary_slug": "hongming",
"soak_seconds": 60,
"batch_size": 2,
"dry_run": False,
"confirm": True,
},
},
token="secret",
list_slugs=fake_list,
redeploy=fake_redeploy,
sleep=sleeps.append,
)
assert [call["only_slugs"] for call in calls] == [
["hongming"],
["tenant-a", "tenant-b"],
["tenant-c"],
]
assert sleeps == [60]
assert aggregate["ok"] is True
assert [result["slug"] for result in aggregate["results"]] == [
"hongming",
"tenant-a",
"tenant-b",
"tenant-c",
]
def test_scoped_rollout_halts_after_failed_canary():
calls = []
def fake_redeploy(_cp_url, _token, body):
calls.append(body)
return 200, {"ok": False, "results": [{"slug": body["only_slugs"][0], "error": "bad"}]}
try:
prod.execute_scoped_rollout(
{
"cp_url": "https://api.moleculesai.app",
"body": {
"target_tag": "staging-abcdef1",
"canary_slug": "hongming",
"soak_seconds": 60,
"batch_size": 2,
"dry_run": False,
"confirm": True,
},
},
token="secret",
list_slugs=lambda _cp_url, _token, _body: ["hongming", "tenant-a"],
redeploy=fake_redeploy,
sleep=lambda _seconds: None,
)
except prod.RolloutFailed as exc:
assert "redeploy scoped call failed" in str(exc)
assert exc.response["ok"] is False
assert exc.response["results"] == [{"slug": "hongming", "error": "bad"}]
else:
raise AssertionError("expected failed canary to halt rollout")
assert [call["only_slugs"] for call in calls] == [["hongming"]]
def test_rollout_from_plan_file_writes_partial_response_on_failure(tmp_path):
plan_path = tmp_path / "plan.json"
response_path = tmp_path / "response.json"
plan_path.write_text(
"""
{
"enabled": true,
"cp_url": "https://api.moleculesai.app",
"body": {"target_tag": "staging-abcdef1", "confirm": true}
}
""",
encoding="utf-8",
)
original = prod.execute_scoped_rollout
def fake_execute(_plan, _token):
raise prod.RolloutFailed(
"redeploy scoped call failed for hongming: HTTP 500, ok=false",
{
"ok": False,
"error": "redeploy scoped call failed for hongming: HTTP 500, ok=false",
"results": [{"slug": "hongming", "error": "bad"}],
},
)
prod.execute_scoped_rollout = fake_execute
try:
try:
prod.rollout_from_plan_file(
str(plan_path),
str(response_path),
{"CP_ADMIN_API_TOKEN": "secret"},
)
except prod.RolloutFailed:
pass
else:
raise AssertionError("expected rollout failure")
finally:
prod.execute_scoped_rollout = original
assert response_path.read_text(encoding="utf-8").strip()
assert '"ok": false' in response_path.read_text(encoding="utf-8")
assert '"slug": "hongming"' in response_path.read_text(encoding="utf-8")
@@ -22,7 +22,6 @@ from __future__ import annotations
import os
import sys
import tempfile
import unittest
# Resolve sibling script regardless of where pytest is invoked from.
+242
View File
@@ -0,0 +1,242 @@
name: E2E Legacy Advisory
# Advisory lane for older/manual E2E scripts that are too broad or
# environment-dependent for required PR CI. This intentionally does not run on
# pull_request or push so it cannot block merges/deploys; scheduled/manual reds
# still surface drift in scripts that would otherwise only be shellchecked.
#
# Gitea 1.22.6 rejects workflow_dispatch.inputs, so keep dispatch input-free.
on:
schedule:
# Stagger after the staging smoke/canvas morning lanes.
- cron: '15 9 * * *'
workflow_dispatch:
concurrency:
group: e2e-legacy-advisory
cancel-in-progress: false
permissions:
contents: read
env:
GITHUB_SERVER_URL: https://git.moleculesai.app
jobs:
legacy-local-platform:
name: Legacy local-platform E2E
runs-on: docker-host
timeout-minutes: 45
env:
PG_CONTAINER: pg-e2e-legacy-${{ github.run_id }}-${{ github.run_attempt }}
REDIS_CONTAINER: redis-e2e-legacy-${{ github.run_id }}-${{ github.run_attempt }}
MOLECULE_ENV: development
BIND_ADDR: 127.0.0.1
MOLECULE_IN_DOCKER: "false"
A2A_TIMEOUT: "30"
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: 'stable'
cache: true
cache-dependency-path: workspace-server/go.sum
- name: Prepare local platform dependencies
run: |
set -euo pipefail
docker pull postgres:16 >/dev/null
docker pull redis:7 >/dev/null
docker pull alpine:latest >/dev/null
docker network create molecule-core-net >/dev/null 2>&1 || true
- name: Start Postgres
run: |
set -euo pipefail
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
docker run -d --name "$PG_CONTAINER" \
-e POSTGRES_USER=dev -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=molecule \
-p 0:5432 postgres:16 >/dev/null
PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | awk -F: '/^0\.0\.0\.0:/ {print $2; exit}')
if [ -z "$PG_PORT" ]; then
PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | head -1 | awk -F: '{print $NF}')
fi
if [ -z "$PG_PORT" ]; then
echo "::error::Could not resolve host port for $PG_CONTAINER"
docker port "$PG_CONTAINER" 5432/tcp || true
docker logs "$PG_CONTAINER" || true
exit 1
fi
echo "DATABASE_URL=postgres://dev:dev@127.0.0.1:${PG_PORT}/molecule?sslmode=disable" >> "$GITHUB_ENV"
for i in $(seq 1 30); do
docker exec "$PG_CONTAINER" pg_isready -U dev >/dev/null 2>&1 && exit 0
sleep 1
done
docker logs "$PG_CONTAINER" || true
exit 1
- name: Start Redis
run: |
set -euo pipefail
docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true
docker run -d --name "$REDIS_CONTAINER" -p 0:6379 redis:7 >/dev/null
REDIS_PORT=$(docker port "$REDIS_CONTAINER" 6379/tcp | awk -F: '/^0\.0\.0\.0:/ {print $2; exit}')
if [ -z "$REDIS_PORT" ]; then
REDIS_PORT=$(docker port "$REDIS_CONTAINER" 6379/tcp | head -1 | awk -F: '{print $NF}')
fi
if [ -z "$REDIS_PORT" ]; then
echo "::error::Could not resolve host port for $REDIS_CONTAINER"
docker port "$REDIS_CONTAINER" 6379/tcp || true
docker logs "$REDIS_CONTAINER" || true
exit 1
fi
echo "REDIS_URL=redis://127.0.0.1:${REDIS_PORT}" >> "$GITHUB_ENV"
for i in $(seq 1 15); do
docker exec "$REDIS_CONTAINER" redis-cli ping 2>/dev/null | grep -q PONG && exit 0
sleep 1
done
docker logs "$REDIS_CONTAINER" || true
exit 1
- name: Pick platform port
run: |
set -euo pipefail
PLATFORM_PORT=$(python3 - <<'PY'
import socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
print(s.getsockname()[1])
PY
)
echo "PORT=${PLATFORM_PORT}" >> "$GITHUB_ENV"
echo "BASE=http://127.0.0.1:${PLATFORM_PORT}" >> "$GITHUB_ENV"
- name: Build platform
working-directory: workspace-server
run: go build -o platform-server ./cmd/server
- name: Populate template manifests for dev-mode E2E
run: |
set -euo pipefail
if command -v jq >/dev/null 2>&1; then
bash scripts/clone-manifest.sh manifest.json workspace-configs-templates org-templates plugins
else
echo "::warning::jq unavailable; dev-mode template assertion may fail if templates are absent"
fi
- name: Start platform
run: |
set -euo pipefail
./workspace-server/platform-server > workspace-server/platform.log 2>&1 &
echo $! > workspace-server/platform.pid
for i in $(seq 1 30); do
curl -sf "$BASE/health" >/dev/null && exit 0
sleep 1
done
cat workspace-server/platform.log || true
exit 1
- name: Run comprehensive E2E
run: bash tests/e2e/test_comprehensive_e2e.sh
- name: Run workspace abilities E2E
run: bash tests/e2e/test_workspace_abilities_e2e.sh
- name: Run dev-mode E2E
run: bash tests/e2e/test_dev_mode.sh
- name: Start stub A2A agents
run: |
set -euo pipefail
cat > /tmp/molecule-stub-a2a.py <<'PY'
import json
from http.server import BaseHTTPRequestHandler, HTTPServer
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers.get("content-length", "0"))
raw = self.rfile.read(length) if length else b"{}"
try:
req = json.loads(raw)
except Exception:
req = {}
method = req.get("method")
if method not in ("message/send", None):
body = {"jsonrpc": "2.0", "id": req.get("id"), "error": {"code": -32601, "message": "method not found"}}
else:
body = {
"jsonrpc": "2.0",
"id": req.get("id", "stub"),
"result": {
"role": "agent",
"parts": [{"kind": "text", "type": "text", "text": "stub agent response"}],
},
}
data = json.dumps(body, separators=(",", ":")).encode()
self.send_response(200)
self.send_header("content-type", "application/json")
self.send_header("content-length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def log_message(self, *_):
return
HTTPServer(("127.0.0.1", 18080), Handler).serve_forever()
PY
python3 /tmp/molecule-stub-a2a.py > /tmp/molecule-stub-a2a.log 2>&1 &
echo $! > /tmp/molecule-stub-a2a.pid
- name: Seed external agents for legacy A2A/activity scripts
run: |
set -euo pipefail
create_agent() {
local name="$1" role="$2"
curl -sS -X POST "$BASE/workspaces" \
-H "Content-Type: application/json" \
-d "{\"name\":\"${name}\",\"role\":\"${role}\",\"tier\":1,\"runtime\":\"external\",\"external\":true,\"url\":\"http://127.0.0.1:18080\"}" \
| python3 -c "import json,sys; print(json.load(sys.stdin)['id'])"
}
ECHO_ID=$(create_agent "Echo Agent" "Echo")
SEO_ID=$(create_agent "SEO Agent" "SEO")
curl -sS -X POST "$BASE/registry/register" -H "Content-Type: application/json" \
-d "{\"id\":\"$ECHO_ID\",\"url\":\"http://127.0.0.1:18080\",\"agent_card\":{\"name\":\"Echo Agent\",\"skills\":[{\"id\":\"echo\",\"name\":\"Echo\"}]}}" >/dev/null
curl -sS -X POST "$BASE/registry/register" -H "Content-Type: application/json" \
-d "{\"id\":\"$SEO_ID\",\"url\":\"http://127.0.0.1:18080\",\"agent_card\":{\"name\":\"SEO Agent\",\"skills\":[{\"id\":\"seo\",\"name\":\"SEO\"}]}}" >/dev/null
- name: Run activity E2E
run: bash tests/e2e/test_activity_e2e.sh
- name: Run A2A E2E
run: bash tests/e2e/test_a2a_e2e.sh
- name: Runtime-dependent legacy E2E preflight
run: |
set -euo pipefail
if [ -f workspace-configs-templates/claude-code-default/.auth-token ] && docker image inspect workspace:latest >/dev/null 2>&1; then
bash tests/e2e/test_claude_code_e2e.sh
bash tests/e2e/test_chat_upload_e2e.sh
else
echo "::notice::Skipping test_claude_code_e2e.sh and test_chat_upload_e2e.sh: require workspace:latest plus workspace-configs-templates/claude-code-default/.auth-token"
fi
- name: Dump platform log on failure
if: failure()
run: cat workspace-server/platform.log || true
- name: Stop platform and stub agents
if: always()
run: |
if [ -f workspace-server/platform.pid ]; then
kill "$(cat workspace-server/platform.pid)" 2>/dev/null || true
fi
if [ -f /tmp/molecule-stub-a2a.pid ]; then
kill "$(cat /tmp/molecule-stub-a2a.pid)" 2>/dev/null || true
fi
- name: Stop service containers
if: always()
run: |
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true
@@ -239,12 +239,13 @@ jobs:
# Publish/release lane (internal#462) — production deploy of a merged
# fix; reserved capacity, never queued behind PR-CI.
runs-on: publish
timeout-minutes: 75
timeout-minutes: 90
env:
CP_URL: ${{ vars.PROD_CP_URL || 'https://api.moleculesai.app' }}
CP_ADMIN_API_TOKEN: ${{ secrets.CP_ADMIN_API_TOKEN }}
GITEA_HOST: git.moleculesai.app
GITEA_TOKEN: ${{ secrets.PROD_AUTO_DEPLOY_CONTROL_TOKEN || secrets.AUTO_SYNC_TOKEN }}
CI_STATUS_TIMEOUT_SECONDS: "3600"
PROD_AUTO_DEPLOY_DISABLED: ${{ vars.PROD_AUTO_DEPLOY_DISABLED || secrets.PROD_AUTO_DEPLOY_DISABLED || '' }}
PROD_AUTO_DEPLOY_CANARY_SLUG: ${{ vars.PROD_AUTO_DEPLOY_CANARY_SLUG || 'hongming' }}
PROD_AUTO_DEPLOY_SOAK_SECONDS: ${{ vars.PROD_AUTO_DEPLOY_SOAK_SECONDS || '60' }}
@@ -303,26 +304,19 @@ jobs:
python3 .gitea/scripts/prod-auto-deploy.py assert-enabled
PLAN="$RUNNER_TEMP/prod-auto-deploy-plan.json"
TARGET_TAG="$(jq -r '.target_tag' "$PLAN")"
BODY="$(jq -c '.body' "$PLAN")"
echo "POST $CP_URL/cp/admin/tenants/redeploy-fleet"
echo " target_tag: $TARGET_TAG"
echo " body: $BODY"
HTTP_RESPONSE="$RUNNER_TEMP/prod-redeploy-response.json"
HTTP_CODE_FILE="$RUNNER_TEMP/prod-redeploy-http-code.txt"
set +e
curl -sS -o "$HTTP_RESPONSE" -w '%{http_code}' \
-m 1200 \
-H "Authorization: Bearer $CP_ADMIN_API_TOKEN" \
-H "Content-Type: application/json" \
-X POST "$CP_URL/cp/admin/tenants/redeploy-fleet" \
-d "$BODY" > "$HTTP_CODE_FILE"
python3 .gitea/scripts/prod-auto-deploy.py rollout \
--plan "$PLAN" \
--response "$HTTP_RESPONSE"
ROLLOUT_EXIT=$?
set -e
HTTP_CODE="$(cat "$HTTP_CODE_FILE" 2>/dev/null || echo "000")"
[ -z "$HTTP_CODE" ] && HTTP_CODE="000"
echo "HTTP $HTTP_CODE"
if [ ! -s "$HTTP_RESPONSE" ]; then
jq -nc --arg error "rollout command exited $ROLLOUT_EXIT before writing a response" \
'{ok:false, results:[], error:$error}' > "$HTTP_RESPONSE"
fi
jq '{ok, result_count: (.results // [] | length)}' "$HTTP_RESPONSE" || true
{
@@ -330,7 +324,6 @@ jobs:
echo ""
echo "**Commit:** \`${GITHUB_SHA:0:7}\`"
echo "**Target tag:** \`$TARGET_TAG\`"
echo "**HTTP:** $HTTP_CODE"
echo ""
echo "### Per-tenant result"
echo ""
@@ -339,15 +332,15 @@ jobs:
jq -r '.results[]? | "| \(.slug) | \(.phase) | \(.ssm_status // "-") | \(.ssm_exit_code) | \(.healthz_ok) | \((.error // "") != "") |"' "$HTTP_RESPONSE" || true
} >> "$GITHUB_STEP_SUMMARY"
if [ "$HTTP_CODE" != "200" ]; then
echo "::error::redeploy-fleet returned HTTP $HTTP_CODE"
exit 1
fi
OK="$(jq -r '.ok' "$HTTP_RESPONSE")"
if [ "$OK" != "true" ]; then
echo "::error::redeploy-fleet reported ok=false; production rollout halted."
exit 1
fi
if [ "$ROLLOUT_EXIT" -ne 0 ]; then
echo "::error::redeploy-fleet rollout failed with exit code $ROLLOUT_EXIT."
exit "$ROLLOUT_EXIT"
fi
- name: Verify reachable tenants report this SHA
if: ${{ steps.plan.outputs.enabled == 'true' }}
+12
View File
@@ -46,6 +46,18 @@
---
## Quick Start
```bash
git clone https://git.moleculesai.app/molecule-ai/molecule-monorepo.git
cd molecule-monorepo
./scripts/dev-start.sh
```
Then open [http://localhost:3000](http://localhost:3000), add your model API key in **Config → Secrets & API Keys → Global**, and create a workspace from a template.
See the full [Quickstart Guide](./docs/quickstart.md) for prerequisites, manual setup, and troubleshooting.
## The Pitch
Molecule AI is the most powerful way to govern an AI agent organization in production.
+6
View File
@@ -41,6 +41,12 @@ describe("buildCsp — production", () => {
expect(csp).toContain("object-src 'none'");
});
it("allows blob: in frame-src for authenticated PDF previews", () => {
const frameSrc = csp.match(/frame-src[^;]*/)?.[0] ?? "";
expect(frameSrc).toContain("'self'");
expect(frameSrc).toContain("blob:");
});
it("locks base-uri to 'self' (prevents base-tag injection)", () => {
expect(csp).toContain("base-uri 'self'");
});
+159 -156
View File
@@ -5,6 +5,13 @@ import * as Dialog from "@radix-ui/react-dialog";
import { api } from "@/lib/api";
import { isSaaSTenant } from "@/lib/tenant";
import { ExternalConnectModal, type ExternalConnectionInfo } from "./ExternalConnectModal";
import {
ProviderModelSelector,
buildProviderCatalog,
findProviderForModel,
type SelectorModel,
type SelectorValue,
} from "./ProviderModelSelector";
interface WorkspaceOption {
id: string;
@@ -22,6 +29,8 @@ interface TemplateSpec {
id: string;
name?: string;
runtime?: string;
model?: string;
models?: SelectorModel[];
providers?: string[];
}
@@ -33,51 +42,22 @@ interface HermesProvider {
models: string[];
}
type LLMAuthMode = "platform" | "api_key" | "oauth";
interface NativeLLMProvider {
id: string;
label: string;
envVar?: string;
defaultModel: string;
models: string[];
authModes: LLMAuthMode[];
}
export const NATIVE_LLM_PROVIDERS: NativeLLMProvider[] = [
{
id: "minimax",
label: "MiniMax",
envVar: "MINIMAX_API_KEY",
defaultModel: "MiniMax-M2.7",
models: ["MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M2.5"],
authModes: ["platform", "api_key"],
},
{
id: "kimi-coding",
label: "Kimi",
envVar: "KIMI_API_KEY",
defaultModel: "kimi-for-coding",
models: ["kimi-for-coding", "kimi-k2.5", "kimi-k2"],
authModes: ["platform", "api_key"],
},
{
id: "anthropic",
label: "Anthropic",
envVar: "ANTHROPIC_API_KEY",
defaultModel: "claude-sonnet-4-6",
models: ["claude-sonnet-4-6", "claude-opus-4-7", "claude-haiku-4-5"],
authModes: ["platform", "api_key"],
},
{
id: "anthropic-oauth",
label: "Claude OAuth",
envVar: "CLAUDE_CODE_OAUTH_TOKEN",
defaultModel: "sonnet",
models: ["sonnet", "opus", "haiku"],
authModes: ["oauth"],
},
const DEFAULT_LLM_MODELS: SelectorModel[] = [
{ id: "moonshot/kimi-k2.6", name: "Kimi K2.6", provider: "platform", required_env: [] },
{ id: "MiniMax-M2.7", name: "MiniMax M2.7", required_env: ["MINIMAX_API_KEY"] },
{ id: "kimi-k2-turbo-preview", name: "Kimi K2 Turbo Preview", required_env: ["KIMI_API_KEY"] },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", required_env: ["ANTHROPIC_API_KEY"] },
{ id: "sonnet", name: "Claude Sonnet", required_env: ["CLAUDE_CODE_OAUTH_TOKEN"] },
];
const DEFAULT_PLATFORM_MODEL = DEFAULT_LLM_MODELS[0];
const DEFAULT_RUNTIME = "claude-code";
const RUNTIME_OPTIONS = [
{ value: "claude-code", label: "Claude Code" },
{ value: "codex", label: "OpenAI Codex CLI" },
{ value: "hermes", label: "Hermes" },
{ value: "openclaw", label: "OpenClaw" },
];
const BASE_RUNTIME_TEMPLATE_IDS = new Set(["claude-code-default", "codex", "hermes", "openclaw"]);
const DEFAULT_HEADLESS_INSTANCE_TYPE = "t3.medium";
const DEFAULT_HEADLESS_ROOT_GB = 30;
const DEFAULT_DISPLAY_INSTANCE_TYPE = "t3.xlarge";
@@ -112,6 +92,7 @@ export function CreateWorkspaceButton() {
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [role, setRole] = useState("");
const [runtime, setRuntime] = useState(DEFAULT_RUNTIME);
const [template, setTemplate] = useState("");
const [parentId, setParentId] = useState("");
const [budgetLimit, setBudgetLimit] = useState("");
@@ -149,9 +130,11 @@ export function CreateWorkspaceButton() {
// (Anthropic), which 401s if the user's key is for a different
// provider. Hence: require model when template=hermes.
const [hermesModel, setHermesModel] = useState("");
const [llmAuthMode, setLLMAuthMode] = useState<LLMAuthMode>("platform");
const [llmProvider, setLLMProvider] = useState("minimax");
const [llmModel, setLLMModel] = useState("MiniMax-M2.7");
const [llmSelection, setLLMSelection] = useState<SelectorValue>({
providerId: "platform|",
model: "moonshot/kimi-k2.6",
envVars: [],
});
const [llmSecret, setLLMSecret] = useState("");
// Tier picker: on SaaS every workspace gets its own EC2 VM (Full Access
@@ -208,39 +191,72 @@ export function CreateWorkspaceButton() {
[]
);
const isHermes = template.trim().toLowerCase() === "hermes";
const nativeLLMProviders = useMemo(
() => NATIVE_LLM_PROVIDERS.filter((p) => p.authModes.includes(llmAuthMode)),
[llmAuthMode],
);
const selectedNativeProvider = useMemo(
() => nativeLLMProviders.find((p) => p.id === llmProvider) ?? nativeLLMProviders[0],
[llmProvider, nativeLLMProviders],
);
const handleRuntimeChange = useCallback((nextRuntime: string) => {
setRuntime(nextRuntime);
setTemplate("");
setHermesProvider("anthropic");
setHermesApiKey("");
setHermesModel("");
setLLMSelection({ providerId: "platform|", model: DEFAULT_PLATFORM_MODEL.id, envVars: [] });
setLLMSecret("");
}, []);
// Resolve the selected template's spec from the /templates response.
// The `template` input is free-text; templates can be matched by id,
// name, or runtime so any of those work. Lower-cased compare keeps
// "Hermes" / "hermes" / "HERMES" interchangeable.
// Resolve the selected workspace template from /templates. Runtime is
// deliberately separate: "SEO Agent" is a workspace template, not a
// runtime, so it must never appear in the runtime selector.
const selectedTemplateSpec = useMemo<TemplateSpec | null>(() => {
const t = template.trim().toLowerCase();
if (!t) return null;
return (
templateSpecs.find(
(s) =>
(s.id || "").toLowerCase() === t ||
(s.name || "").toLowerCase() === t ||
(s.runtime || "").toLowerCase() === t,
) ?? null
);
if (!template) return null;
return templateSpecs.find((s) => s.id === template) ?? null;
}, [template, templateSpecs]);
const selectedRuntimeTemplateSpec = useMemo<TemplateSpec | null>(() => (
templateSpecs.find((s) => s.id === runtime && BASE_RUNTIME_TEMPLATE_IDS.has(s.id)) ?? null
), [runtime, templateSpecs]);
const isHermes = runtime === "hermes";
const visibleTemplateSpecs = useMemo(
() => templateSpecs.filter((spec) => {
if (BASE_RUNTIME_TEMPLATE_IDS.has(spec.id)) return false;
const specRuntime = (spec.runtime ?? DEFAULT_RUNTIME).trim().toLowerCase();
return specRuntime === runtime;
}),
[runtime, templateSpecs],
);
const llmModels = useMemo(
() => {
if (!selectedTemplateSpec?.models?.length) return DEFAULT_LLM_MODELS;
if (isHermes) {
return selectedTemplateSpec.models;
}
if (selectedTemplateSpec.models.some((model) => model.provider === "platform")) {
return selectedTemplateSpec.models;
}
const templateDefault = selectedTemplateSpec.model?.trim();
const defaultModelSpec = templateDefault
? selectedTemplateSpec.models.find((model) => model.id === templateDefault)
: undefined;
return [
{
id: templateDefault || DEFAULT_PLATFORM_MODEL.id,
name: defaultModelSpec?.name ?? DEFAULT_PLATFORM_MODEL.name,
provider: "platform",
required_env: [],
},
...selectedTemplateSpec.models,
];
},
[isHermes, selectedTemplateSpec],
);
const llmCatalog = useMemo(() => buildProviderCatalog(llmModels), [llmModels]);
const selectedLLMProvider = useMemo(
() => llmCatalog.find((p) => p.id === llmSelection.providerId) ?? llmCatalog[0],
[llmCatalog, llmSelection.providerId],
);
// Filter HERMES_PROVIDERS by what the template declares it supports.
// Empty/missing declared list → fall back to the full catalog so
// templates that haven't migrated to the explicit `providers:` field
// (and self-hosted setups without /templates) keep working unchanged.
const availableProviders = useMemo<HermesProvider[]>(() => {
const declared = selectedTemplateSpec?.providers;
const declared = selectedTemplateSpec?.providers ?? selectedRuntimeTemplateSpec?.providers;
if (!declared || declared.length === 0) return HERMES_PROVIDERS;
const allowed = new Set(declared.map((p) => p.toLowerCase()));
const filtered = HERMES_PROVIDERS.filter((p) => allowed.has(p.id.toLowerCase()));
@@ -249,7 +265,7 @@ export function CreateWorkspaceButton() {
// metadata for yet), fall back to the full list rather than render
// an empty <select>. Better to over-show than to lock the user out.
return filtered.length > 0 ? filtered : HERMES_PROVIDERS;
}, [selectedTemplateSpec]);
}, [selectedRuntimeTemplateSpec, selectedTemplateSpec]);
// If the currently-selected provider is filtered out by a template
// change, snap back to the first available. Without this, the
@@ -265,20 +281,21 @@ export function CreateWorkspaceButton() {
}, [availableProviders, isHermes]);
useEffect(() => {
if (isHermes) return;
if (nativeLLMProviders.length === 0) return;
if (!nativeLLMProviders.some((p) => p.id === llmProvider)) {
setLLMProvider(nativeLLMProviders[0].id);
setLLMModel(nativeLLMProviders[0].defaultModel);
}
}, [isHermes, llmProvider, nativeLLMProviders]);
useEffect(() => {
if (isHermes || !selectedNativeProvider) return;
if (!selectedNativeProvider.models.includes(llmModel)) {
setLLMModel(selectedNativeProvider.defaultModel);
}
}, [isHermes, llmModel, selectedNativeProvider]);
if (isHermes || llmCatalog.length === 0) return;
const templateDefault = selectedTemplateSpec?.model?.trim();
const matched = templateDefault ? findProviderForModel(llmCatalog, templateDefault) : null;
const next = matched ?? llmCatalog[0];
setLLMSelection({
providerId: next.id,
model: matched && templateDefault
? templateDefault
: next.wildcard
? ""
: next.models[0]?.id ?? "",
envVars: next.envVars,
});
setLLMSecret("");
}, [isHermes, llmCatalog, selectedTemplateSpec?.model]);
// Auto-fill hermesModel with the provider's defaultModel whenever the
// provider changes, but only if the user hasn't already typed their own
@@ -302,6 +319,7 @@ export function CreateWorkspaceButton() {
setName("");
setRole("");
setTier(defaultTier);
setRuntime(DEFAULT_RUNTIME);
setTemplate("");
setParentId("");
setBudgetLimit("");
@@ -314,9 +332,7 @@ export function CreateWorkspaceButton() {
setExternalRuntime("external");
setHermesApiKey("");
setHermesModel("");
setLLMAuthMode("platform");
setLLMProvider("minimax");
setLLMModel("MiniMax-M2.7");
setLLMSelection({ providerId: "platform|", model: "moonshot/kimi-k2.6", envVars: [] });
setLLMSecret("");
api
.get<WorkspaceOption[]>("/workspaces")
@@ -344,12 +360,12 @@ export function CreateWorkspaceButton() {
setError("Model is required for Hermes workspaces — provider routing depends on the model slug prefix");
return;
}
if (!isExternal && !isHermes && !llmModel.trim()) {
if (!isExternal && !isHermes && !llmSelection.model.trim()) {
setError("Model is required");
return;
}
if (!isExternal && !isHermes && llmAuthMode !== "platform" && !llmSecret.trim()) {
setError(llmAuthMode === "oauth" ? "Claude OAuth token is required" : "API key is required");
if (!isExternal && !isHermes && selectedLLMProvider?.envVars.length && !llmSecret.trim()) {
setError("Provider credential is required");
return;
}
setCreating(true);
@@ -358,7 +374,7 @@ export function CreateWorkspaceButton() {
const provider = isHermes
? HERMES_PROVIDERS.find((p) => p.id === hermesProvider)
: undefined;
const nativeProvider = !isHermes ? selectedNativeProvider : undefined;
const nativeProvider = !isHermes ? selectedLLMProvider : undefined;
try {
const parsedBudget = budgetLimit.trim()
@@ -384,10 +400,10 @@ export function CreateWorkspaceButton() {
budget_limit: parsedBudget,
...(!isExternal && !isHermes && nativeProvider
? {
model: llmModel.trim(),
llm_provider: nativeProvider.id,
...(llmAuthMode !== "platform" && nativeProvider.envVar
? { secrets: { [nativeProvider.envVar]: llmSecret.trim() } }
model: llmSelection.model.trim(),
llm_provider: nativeProvider.vendor,
...(nativeProvider.envVars.length > 0
? { secrets: { [nativeProvider.envVars[0]]: llmSecret.trim() } }
: {}),
}
: {}),
@@ -415,7 +431,7 @@ export function CreateWorkspaceButton() {
// Runtime=external flips the backend into awaiting-agent mode:
// no container provisioning, token minted, connection payload
// returned in the response for the modal below.
...(isExternal ? { runtime: externalRuntime } : {}),
...(isExternal ? { runtime: externalRuntime } : { runtime }),
...(!isExternal && isHermes && provider
? {
secrets: { [provider.envVar]: hermesApiKey.trim() },
@@ -533,77 +549,64 @@ export function CreateWorkspaceButton() {
)}
{!isExternal && (
<InputField
label="Template"
value={template}
onChange={setTemplate}
placeholder="e.g. seo-agent (from workspace-configs-templates/)"
mono
/>
)}
{!isExternal && !isHermes && selectedNativeProvider && (
<div className="rounded-lg border border-line/50 bg-surface-card/40 p-3 space-y-3">
<div className="text-[11px] font-medium text-ink-mid">
LLM
</div>
<div className="space-y-3">
<div>
<label htmlFor="llm-auth-mode" className="text-[11px] text-ink-mid block mb-1">
Auth Mode
<label htmlFor="runtime-select" className="text-[11px] text-ink-mid block mb-1">
Runtime
</label>
<select
id="llm-auth-mode"
value={llmAuthMode}
onChange={(e) => setLLMAuthMode(e.target.value as LLMAuthMode)}
id="runtime-select"
value={runtime}
onChange={(e) => handleRuntimeChange(e.target.value)}
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors"
>
<option value="platform">Platform provided</option>
<option value="api_key">API key</option>
<option value="oauth">Claude OAuth</option>
</select>
</div>
<div>
<label htmlFor="llm-provider-select" className="text-[11px] text-ink-mid block mb-1">
Provider
</label>
<select
id="llm-provider-select"
value={selectedNativeProvider.id}
onChange={(e) => {
const next = nativeLLMProviders.find((p) => p.id === e.target.value);
setLLMProvider(e.target.value);
if (next) setLLMModel(next.defaultModel);
}}
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors"
>
{nativeLLMProviders.map((p) => (
<option key={p.id} value={p.id}>
{p.label}
{RUNTIME_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div>
<label htmlFor="llm-model-input" className="text-[11px] text-ink-mid block mb-1">
Model
<label htmlFor="workspace-template-select" className="text-[11px] text-ink-mid block mb-1">
Workspace Template
</label>
<input
id="llm-model-input"
type="text"
value={llmModel}
onChange={(e) => setLLMModel(e.target.value)}
list="llm-model-suggestions"
spellCheck={false}
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink placeholder-ink-soft focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors font-mono"
/>
<datalist id="llm-model-suggestions">
{selectedNativeProvider.models.map((m) => <option key={m} value={m} />)}
</datalist>
<select
id="workspace-template-select"
value={template}
onChange={(e) => setTemplate(e.target.value)}
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors"
>
<option value="">Blank workspace</option>
{visibleTemplateSpecs.map((spec) => (
<option key={spec.id} value={spec.id}>
{spec.name || spec.id}
</option>
))}
</select>
</div>
{llmAuthMode !== "platform" && (
</div>
)}
{!isExternal && !isHermes && selectedLLMProvider && (
<div className="rounded-lg border border-line/50 bg-surface-card/40 p-3 space-y-3">
<div className="text-[11px] font-medium text-ink-mid">
LLM
</div>
<ProviderModelSelector
models={llmModels}
value={llmSelection}
onChange={(next) => {
setLLMSelection(next);
setLLMSecret("");
}}
idPrefix="create-workspace-llm"
variant="stack"
/>
{selectedLLMProvider.envVars.length > 0 && (
<div>
<label htmlFor="llm-secret-input" className="text-[11px] text-ink-mid block mb-1">
{llmAuthMode === "oauth" ? "OAuth Token" : "API Key"}
{selectedLLMProvider.envVars[0]}
</label>
<input
id="llm-secret-input"
@@ -741,7 +744,7 @@ export function CreateWorkspaceButton() {
</div>
</div>
{/* Hermes provider configuration — shown only when template === "hermes" */}
{/* Hermes provider configuration — shown only for the Hermes runtime. */}
{isHermes && (
<div
className="mt-4 rounded-xl border border-violet-700/40 bg-violet-950/20 p-4 space-y-3"
+2 -2
View File
@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from "react";
import { api } from "@/lib/api";
import { useCanvasStore } from "@/store/canvas";
import { OrgTemplatesSection } from "./TemplatePalette";
import { type Template } from "@/lib/deploy-preflight";
import { isUserVisibleWorkspaceTemplate, type Template } from "@/lib/deploy-preflight";
import { useTemplateDeploy } from "@/hooks/useTemplateDeploy";
import { Spinner } from "./Spinner";
import { TIER_CONFIG } from "@/lib/design-tokens";
@@ -18,7 +18,7 @@ export function EmptyState() {
useEffect(() => {
api
.get<Template[]>("/templates")
.then((t) => setTemplates(t))
.then((t) => setTemplates(t.filter(isUserVisibleWorkspaceTemplate)))
.catch(() => setTemplates([]))
.finally(() => setLoading(false));
}, []);
+240 -18
View File
@@ -23,6 +23,8 @@ interface Props {
/** Grouped provider options derived from the template's models[] /
* required_env. When length ≥ 2 the modal shows a radio picker. */
providers?: ProviderChoice[];
/** Optional keys to offer in the deploy modal without blocking Deploy. */
optionalKeys?: string[];
/** Runtime slug — used only for the "The <runtime> runtime …"
* headline; behavior is driven by providers/missingKeys. */
runtime: string;
@@ -94,13 +96,13 @@ export function MissingKeysModal({
open,
missingKeys,
providers,
optionalKeys,
runtime,
onKeysAdded,
onCancel,
onOpenSettings,
workspaceId,
configuredKeys,
modelSuggestions,
models,
initialModel,
title,
@@ -114,13 +116,13 @@ export function MissingKeysModal({
<ProviderPickerModal
open={open}
providers={pickerProviders}
optionalKeys={optionalKeys ?? []}
runtime={runtime}
onKeysAdded={onKeysAdded}
onCancel={onCancel}
onOpenSettings={onOpenSettings}
workspaceId={workspaceId}
configuredKeys={configuredKeys}
modelSuggestions={modelSuggestions}
models={models}
initialModel={initialModel}
title={title}
@@ -138,11 +140,15 @@ export function MissingKeysModal({
<AllKeysModal
open={open}
missingKeys={keys}
optionalKeys={optionalKeys ?? []}
runtime={runtime}
onKeysAdded={onKeysAdded}
onCancel={onCancel}
onOpenSettings={onOpenSettings}
workspaceId={workspaceId}
configuredKeys={configuredKeys}
title={title}
description={description}
/>
);
}
@@ -170,13 +176,13 @@ export function providerIdForModel(
function ProviderPickerModal({
open,
providers,
optionalKeys,
runtime,
onKeysAdded,
onCancel,
onOpenSettings,
workspaceId,
configuredKeys,
modelSuggestions,
models,
initialModel,
title,
@@ -184,13 +190,13 @@ function ProviderPickerModal({
}: {
open: boolean;
providers: ProviderChoice[];
optionalKeys: string[];
runtime: string;
onKeysAdded: (model?: string) => void;
onCancel: () => void;
onOpenSettings?: () => void;
workspaceId?: string;
configuredKeys?: Set<string>;
modelSuggestions?: string[];
models?: ModelSpec[];
initialModel?: string;
title?: string;
@@ -250,16 +256,9 @@ function ProviderPickerModal({
const [selectorValue, setSelectorValue] = useState<SelectorValue>(initial);
const [entries, setEntries] = useState<KeyEntry[]>([]);
const [optionalEntries, setOptionalEntries] = useState<KeyEntry[]>([]);
const firstInputRef = useRef<HTMLInputElement>(null);
// Legacy compat: map the selector value back into the old `selected`/
// `model` shape for the rest of the modal body (footer copy, etc.).
const selected = useMemo(
() =>
providers.find((p) => p.id === selectorValue.providerId) ??
providers[0],
[providers, selectorValue.providerId],
);
const model = selectorValue.model;
const showModelInput = catalog.length > 0;
@@ -282,7 +281,18 @@ function ProviderPickerModal({
error: null,
})),
);
}, [open, selectorValue.envVars, configuredKeys]);
setOptionalEntries(
optionalKeys
.filter((key) => !selectorValue.envVars.includes(key))
.map((key) => ({
key,
value: "",
saved: configuredKeys?.has(key) ?? false,
saving: false,
error: null,
})),
);
}, [open, selectorValue.envVars, configuredKeys, optionalKeys]);
useEffect(() => {
if (!open) return;
@@ -336,6 +346,43 @@ function ProviderPickerModal({
[entries, updateEntry, workspaceId],
);
const updateOptionalEntry = useCallback(
(index: number, updates: Partial<KeyEntry>) => {
setOptionalEntries((prev) =>
prev.map((e, i) => (i === index ? { ...e, ...updates } : e)),
);
},
[],
);
const handleSaveOptionalKey = useCallback(
async (index: number) => {
const entry = optionalEntries[index];
if (!entry.value.trim()) return;
updateOptionalEntry(index, { saving: true, error: null });
try {
if (workspaceId) {
await api.put(`/workspaces/${workspaceId}/secrets`, {
key: entry.key,
value: entry.value.trim(),
});
} else {
await api.put("/settings/secrets", {
key: entry.key,
value: entry.value.trim(),
});
}
updateOptionalEntry(index, { saved: true, saving: false });
} catch (e) {
updateOptionalEntry(index, {
saving: false,
error: e instanceof Error ? e.message : "Failed to save",
});
}
},
[optionalEntries, updateOptionalEntry, workspaceId],
);
if (!open) return null;
// Portal to document.body for the same reason as
// OrgImportPreflightModal — several callers (TemplatePalette,
@@ -465,6 +512,62 @@ function ProviderPickerModal({
</div>
))}
</div>
{optionalEntries.length > 0 && (
<div className="space-y-2">
<div className="text-[10px] uppercase tracking-wide text-ink-mid font-semibold">
Optional
</div>
{optionalEntries.map((entry, index) => (
<div
key={entry.key}
className="bg-surface-card/30 rounded-lg px-3 py-2.5 border border-line/40"
>
<div className="flex items-center justify-between mb-1.5">
<div>
<div className="text-[11px] text-ink-mid font-medium">
{getKeyLabel(entry.key)}
</div>
<div className="text-[9px] font-mono text-ink-mid">{entry.key}</div>
</div>
{entry.saved && (
<span className="text-[9px] text-good bg-emerald-900/30 px-1.5 py-0.5 rounded flex items-center gap-1">
Saved
</span>
)}
</div>
{!entry.saved && (
<div className="flex gap-2 mt-2">
<input
value={entry.value}
onChange={(e) => updateOptionalEntry(index, { value: e.target.value.trimStart() })}
placeholder={entry.key.includes("API_KEY") ? "sk-..." : "Enter value"}
type="password"
aria-label={`Optional value for ${entry.key}`}
onKeyDown={(e) => {
if (e.key === "Enter" && entry.value.trim()) {
handleSaveOptionalKey(index);
}
}}
className="flex-1 bg-surface-sunken border border-line rounded px-2 py-1.5 text-[11px] text-ink font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors"
/>
<button
type="button"
onClick={() => handleSaveOptionalKey(index)}
disabled={!entry.value.trim() || entry.saving}
className="px-3 py-1.5 bg-surface-card hover:bg-surface-card/80 text-[11px] rounded text-ink border border-line disabled:opacity-30 transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
{entry.saving ? "..." : "Save"}
</button>
</div>
)}
{entry.error && (
<div role="alert" aria-live="assertive" className="mt-1.5 text-[10px] text-bad">{entry.error}</div>
)}
</div>
))}
</div>
)}
</div>
<div className="px-5 py-3 border-t border-line bg-surface/50 flex items-center justify-between gap-2">
@@ -512,21 +615,30 @@ function ProviderPickerModal({
function AllKeysModal({
open,
missingKeys,
optionalKeys,
runtime,
onKeysAdded,
onCancel,
onOpenSettings,
workspaceId,
configuredKeys,
title,
description,
}: {
open: boolean;
missingKeys: string[];
optionalKeys: string[];
runtime: string;
onKeysAdded: () => void;
onCancel: () => void;
onOpenSettings?: () => void;
workspaceId?: string;
configuredKeys?: Set<string>;
title?: string;
description?: string;
}) {
const [entries, setEntries] = useState<KeyEntry[]>([]);
const [optionalEntries, setOptionalEntries] = useState<KeyEntry[]>([]);
const [globalError, setGlobalError] = useState<string | null>(null);
useEffect(() => {
@@ -535,13 +647,24 @@ function AllKeysModal({
missingKeys.map((key) => ({
key,
value: "",
saved: false,
saved: configuredKeys?.has(key) ?? false,
saving: false,
error: null,
})),
);
setOptionalEntries(
optionalKeys
.filter((key) => !missingKeys.includes(key))
.map((key) => ({
key,
value: "",
saved: configuredKeys?.has(key) ?? false,
saving: false,
error: null,
})),
);
setGlobalError(null);
}, [open, missingKeys]);
}, [open, missingKeys, optionalKeys, configuredKeys]);
useEffect(() => {
if (!open) return;
@@ -591,6 +714,45 @@ function AllKeysModal({
[entries, updateEntry, workspaceId],
);
const updateOptionalEntry = useCallback(
(index: number, updates: Partial<KeyEntry>) => {
setOptionalEntries((prev) =>
prev.map((entry, i) => (i === index ? { ...entry, ...updates } : entry)),
);
},
[],
);
const handleSaveOptionalKey = useCallback(
async (index: number) => {
const entry = optionalEntries[index];
if (!entry.value.trim()) return;
updateOptionalEntry(index, { saving: true, error: null });
try {
if (workspaceId) {
await api.put(`/workspaces/${workspaceId}/secrets`, {
key: entry.key,
value: entry.value.trim(),
});
} else {
await api.put("/settings/secrets", {
key: entry.key,
value: entry.value.trim(),
});
}
updateOptionalEntry(index, { saved: true, saving: false });
} catch (e) {
updateOptionalEntry(index, {
saving: false,
error: e instanceof Error ? e.message : "Failed to save",
});
}
},
[optionalEntries, updateOptionalEntry, workspaceId],
);
const handleAddKeysAndDeploy = useCallback(() => {
const anySaving = entries.some((e) => e.saving);
if (anySaving) {
@@ -656,12 +818,16 @@ function AllKeysModal({
</svg>
</div>
<h3 id="missing-keys-title" className="text-sm font-semibold text-ink">
Missing API Keys
{title ?? "Missing API Keys"}
</h3>
</div>
<p className="text-[12px] text-ink-mid leading-relaxed">
The <span className="text-warm font-medium">{runtimeLabel}</span>{" "}
runtime requires the following keys to be configured before deploying.
{description ?? (
<>
The <span className="text-warm font-medium">{runtimeLabel}</span>{" "}
runtime requires the following keys to be configured before deploying.
</>
)}
</p>
</div>
@@ -719,6 +885,62 @@ function AllKeysModal({
</div>
))}
{optionalEntries.length > 0 && (
<div className="space-y-2">
<div className="text-[10px] uppercase tracking-wide text-ink-mid font-semibold">
Optional
</div>
{optionalEntries.map((entry, index) => (
<div
key={entry.key}
className="bg-surface-card/30 rounded-lg px-3 py-2.5 border border-line/40"
>
<div className="flex items-center justify-between mb-1">
<div>
<div className="text-[11px] text-ink-mid font-medium">
{getKeyLabel(entry.key)}
</div>
<div className="text-[9px] font-mono text-ink-mid">{entry.key}</div>
</div>
{entry.saved && (
<span className="text-[9px] text-good bg-emerald-900/30 px-1.5 py-0.5 rounded">
Saved
</span>
)}
</div>
{!entry.saved && (
<div className="flex gap-2 mt-2">
<input
value={entry.value}
onChange={(e) => updateOptionalEntry(index, { value: e.target.value.trimStart() })}
placeholder={entry.key.includes("API_KEY") ? "sk-..." : "Enter value"}
type="password"
aria-label={`Optional value for ${entry.key}`}
onKeyDown={(e) => {
if (e.key === "Enter" && entry.value.trim()) {
handleSaveOptionalKey(index);
}
}}
className="flex-1 bg-surface-sunken border border-line rounded px-2 py-1.5 text-[11px] text-ink font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors"
/>
<button
type="button"
onClick={() => handleSaveOptionalKey(index)}
disabled={!entry.value.trim() || entry.saving}
className="px-3 py-1.5 bg-surface-card hover:bg-surface-card/80 text-[11px] rounded text-ink border border-line disabled:opacity-30 transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
{entry.saving ? "..." : "Save"}
</button>
</div>
)}
{entry.error && <div className="mt-1.5 text-[10px] text-bad">{entry.error}</div>}
</div>
))}
</div>
)}
{globalError && (
<div role="alert" aria-live="assertive" className="px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-[11px] text-bad">
{globalError}
@@ -28,6 +28,7 @@ import { useId, useMemo } from "react";
export interface SelectorModel {
id: string;
name?: string;
provider?: string;
required_env?: string[];
}
@@ -88,6 +89,7 @@ interface Props {
/** Vendor keys → human label. Add new vendors here when templates pick
* up new model families. */
const VENDOR_LABELS: Record<string, string> = {
"platform": "Platform",
"anthropic-oauth": "Claude Code subscription",
anthropic: "Anthropic API",
minimax: "MiniMax",
@@ -118,6 +120,8 @@ const VENDOR_LABELS: Record<string, string> = {
/** Optional per-vendor tooltip shown on hover. */
const VENDOR_TOOLTIPS: Record<string, string> = {
"platform":
"Use the Molecule platform-managed LLM proxy. No vendor API key is required.",
"anthropic-oauth":
"Use your Claude.ai (Pro/Max/Team) subscription via OAuth. Run `claude login` in the workspace terminal to mint the token, then paste it here. No API spend.",
anthropic:
@@ -165,6 +169,9 @@ const BARE_VENDOR_PATTERNS: Array<{ test: (id: string) => boolean; vendor: strin
/** Infer a vendor key from a model spec. Combines id-prefix and env
* signals. Exported for tests. */
export function inferVendor(model: SelectorModel): string {
const explicitProvider = model.provider?.trim().toLowerCase();
if (explicitProvider) return explicitProvider;
const id = model.id || "";
const envSet = new Set(model.required_env ?? []);
+2 -2
View File
@@ -5,7 +5,7 @@ import { flushSync } from "react-dom";
import { api } from "@/lib/api";
import { useCanvasStore } from "@/store/canvas";
import type { WorkspaceData } from "@/store/socket";
import { type Template } from "@/lib/deploy-preflight";
import { isUserVisibleWorkspaceTemplate, type Template } from "@/lib/deploy-preflight";
import { useTemplateDeploy } from "@/hooks/useTemplateDeploy";
import {
OrgImportPreflightModal,
@@ -446,7 +446,7 @@ export function TemplatePalette() {
setLoading(true);
try {
const data = await api.get<Template[]>("/templates");
setTemplates(data);
setTemplates(data.filter(isUserVisibleWorkspaceTemplate));
} catch {
setTemplates([]);
} finally {
+4 -2
View File
@@ -224,12 +224,14 @@ export function Toolbar() {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key !== "?") return;
const tag = (e.target as HTMLElement).tagName;
const target = e.target as HTMLElement;
if (target.closest?.('[data-display-stream="true"]')) return;
const tag = target.tagName;
const inInput =
tag === "INPUT" ||
tag === "TEXTAREA" ||
tag === "SELECT" ||
(e.target as HTMLElement).isContentEditable;
target.isContentEditable;
if (inInput) return;
// Don't fire when a modal/dialog is already mounted (canvas modals,
// side panel, etc. use z-50 or above).
@@ -201,15 +201,13 @@ describe("CreateWorkspaceDialog — WCAG SC 1.3.1 label/input association", () =
expect(label?.textContent).toContain("Budget limit");
});
it("Template input has a <label> whose htmlFor matches the input id", async () => {
it("Workspace Template select has a <label> whose htmlFor matches the select id", async () => {
await openDialog();
const templateInput = screen.getByPlaceholderText(
"e.g. seo-agent (from workspace-configs-templates/)"
) as HTMLInputElement;
expect(templateInput.id).toBeTruthy();
const label = document.querySelector(`label[for="${templateInput.id}"]`);
const templateSelect = screen.getByLabelText("Workspace Template") as HTMLSelectElement;
expect(templateSelect.id).toBeTruthy();
const label = document.querySelector(`label[for="${templateSelect.id}"]`);
expect(label).toBeTruthy();
expect(label?.textContent).toContain("Template");
expect(label?.textContent).toContain("Workspace Template");
});
it("each InputField generates a distinct id (no id collisions)", async () => {
@@ -218,13 +216,16 @@ describe("CreateWorkspaceDialog — WCAG SC 1.3.1 label/input association", () =
screen.getByPlaceholderText("e.g. SEO Agent"),
screen.getByPlaceholderText("e.g. SEO Specialist"),
screen.getByPlaceholderText("e.g. 100"),
screen.getByPlaceholderText("e.g. seo-agent (from workspace-configs-templates/)"),
] as HTMLInputElement[];
const selects = [
screen.getByLabelText("Runtime"),
screen.getByLabelText("Workspace Template"),
] as HTMLSelectElement[];
const ids = inputs.map((i) => i.id).filter(Boolean);
const ids = [...inputs, ...selects].map((i) => i.id).filter(Boolean);
const unique = new Set(ids);
expect(unique.size).toBe(ids.length); // no duplicates
expect(ids.length).toBe(4);
expect(ids.length).toBe(5);
});
it("Name label text contains the required asterisk indicator", async () => {
@@ -20,10 +20,34 @@ const SAMPLE_WORKSPACES = [
{ id: "ws-2", name: "Research Agent", tier: 2 },
];
const SAMPLE_TEMPLATES = [
{
id: "seo-agent",
name: "SEO Agent",
runtime: "claude-code",
model: "moonshot/kimi-k2.6",
providers: ["platform", "minimax", "kimi-coding", "anthropic", "anthropic-oauth"],
models: [
{ id: "moonshot/kimi-k2.6", name: "Kimi K2.6", provider: "platform", required_env: [] },
{ id: "MiniMax-M2.7", name: "MiniMax M2.7", required_env: ["MINIMAX_API_KEY"] },
{ id: "kimi-k2-turbo-preview", name: "Kimi K2 Turbo Preview", required_env: ["KIMI_API_KEY"] },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", required_env: ["ANTHROPIC_API_KEY"] },
{ id: "sonnet", name: "Claude Sonnet", required_env: ["CLAUDE_CODE_OAUTH_TOKEN"] },
],
},
{ id: "hermes", name: "Hermes", runtime: "hermes" },
];
beforeEach(() => {
vi.clearAllMocks();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue(SAMPLE_WORKSPACES as any);
mockGet.mockImplementation(async (url: string) => {
if (url === "/templates") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return SAMPLE_TEMPLATES as any;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return SAMPLE_WORKSPACES as any;
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockPost.mockResolvedValue({} as any);
});
@@ -42,7 +66,14 @@ async function openDialog() {
async function setTemplate(value: string) {
fireEvent.change(
screen.getByPlaceholderText("e.g. seo-agent (from workspace-configs-templates/)"),
screen.getByLabelText("Workspace Template"),
{ target: { value } }
);
}
async function setRuntime(value: string) {
fireEvent.change(
screen.getByLabelText("Runtime"),
{ target: { value } }
);
}
@@ -139,11 +170,33 @@ describe("CreateWorkspaceDialog", () => {
volume: { root_gb: 30 },
display: { mode: "none" },
});
expect(body.model).toBe("MiniMax-M2.7");
expect(body.llm_provider).toBe("minimax");
expect(body.model).toBe("moonshot/kimi-k2.6");
expect(body.llm_provider).toBe("platform");
expect(body.runtime).toBe("claude-code");
expect(body.secrets).toBeUndefined();
});
it("keeps runtime and workspace template as separate selectors", async () => {
await openDialog();
const runtimeSelect = screen.getByLabelText("Runtime") as HTMLSelectElement;
const runtimeTexts = Array.from(runtimeSelect.options).map((o) => o.text.trim());
expect(runtimeTexts).toEqual([
"Claude Code",
"OpenAI Codex CLI",
"Hermes",
"OpenClaw",
]);
expect(runtimeTexts).not.toContain("SEO Agent");
await waitFor(() => {
const templateSelect = screen.getByLabelText("Workspace Template") as HTMLSelectElement;
const templateTexts = Array.from(templateSelect.options).map((o) => o.text.trim());
expect(templateTexts).toContain("SEO Agent");
expect(templateTexts).not.toContain("Hermes");
});
});
it("does not send managed compute for external agents", async () => {
await openDialog();
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
@@ -172,8 +225,8 @@ describe("CreateWorkspaceDialog", () => {
await waitFor(() => expect(mockPost).toHaveBeenCalled());
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
expect(body.model).toBe("MiniMax-M2.7");
expect(body.llm_provider).toBe("minimax");
expect(body.model).toBe("moonshot/kimi-k2.6");
expect(body.llm_provider).toBe("platform");
expect(body.compute).toEqual({
instance_type: "t3.xlarge",
volume: { root_gb: 80 },
@@ -191,8 +244,8 @@ describe("CreateWorkspaceDialog", () => {
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
target: { value: "BYOK Agent" },
});
fireEvent.change(document.getElementById("llm-auth-mode") as HTMLSelectElement, {
target: { value: "api_key" },
fireEvent.change(document.querySelector("[data-testid='provider-select']") as HTMLSelectElement, {
target: { value: "minimax|MINIMAX_API_KEY" },
});
fireEvent.change(document.getElementById("llm-secret-input") as HTMLInputElement, {
target: { value: "sk-minimax-test" },
@@ -213,8 +266,8 @@ describe("CreateWorkspaceDialog", () => {
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
target: { value: "OAuth Agent" },
});
fireEvent.change(document.getElementById("llm-auth-mode") as HTMLSelectElement, {
target: { value: "oauth" },
fireEvent.change(document.querySelector("[data-testid='provider-select']") as HTMLSelectElement, {
target: { value: "anthropic-oauth|CLAUDE_CODE_OAUTH_TOKEN" },
});
fireEvent.change(document.getElementById("llm-secret-input") as HTMLInputElement, {
target: { value: "oauth-token" },
@@ -254,17 +307,17 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => {
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeNull();
});
it("shows hermes provider section when template is 'hermes'", async () => {
it("shows hermes provider section when runtime is 'hermes'", async () => {
await openDialog();
await setTemplate("hermes");
await setRuntime("hermes");
await waitFor(() =>
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
);
});
it("shows hermes provider section for template 'HERMES' (case-insensitive)", async () => {
it("shows hermes provider section for the Hermes runtime preset", async () => {
await openDialog();
await setTemplate("HERMES");
await setRuntime("hermes");
await waitFor(() =>
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
);
@@ -272,7 +325,7 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => {
it("hermes provider dropdown defaults to 'anthropic'", async () => {
await openDialog();
await setTemplate("hermes");
await setRuntime("hermes");
await waitFor(() =>
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
);
@@ -283,7 +336,7 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => {
it("hermes provider dropdown lists all 15 providers", async () => {
await openDialog();
await setTemplate("hermes");
await setRuntime("hermes");
await waitFor(() =>
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
);
@@ -317,7 +370,7 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => {
});
await openDialog();
await setTemplate("hermes");
await setRuntime("hermes");
await waitFor(() =>
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
);
@@ -347,7 +400,7 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => {
});
await openDialog();
await setTemplate("hermes");
await setRuntime("hermes");
await waitFor(() =>
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
);
@@ -373,7 +426,7 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => {
});
await openDialog();
await setTemplate("hermes");
await setRuntime("hermes");
await waitFor(() =>
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
);
@@ -384,7 +437,7 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => {
it("hermes API key field is a password input (masked)", async () => {
await openDialog();
await setTemplate("hermes");
await setRuntime("hermes");
await waitFor(() =>
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
);
@@ -398,7 +451,7 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => {
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
target: { value: "Hermes Agent" },
});
await setTemplate("hermes");
await setRuntime("hermes");
await waitFor(() =>
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
);
@@ -419,7 +472,7 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => {
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
target: { value: "Hermes Agent" },
});
await setTemplate("hermes");
await setRuntime("hermes");
await waitFor(() =>
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
);
@@ -434,7 +487,8 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => {
await waitFor(() => expect(mockPost).toHaveBeenCalled());
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
expect(body.secrets).toEqual({ ANTHROPIC_API_KEY: "sk-test-anthropic-key" });
expect(body.template).toBe("hermes");
expect(body.runtime).toBe("hermes");
expect(body.template).toBeUndefined();
});
it("uses the correct env var when a non-default provider is selected", async () => {
@@ -442,7 +496,7 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => {
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
target: { value: "Hermes OpenAI" },
});
await setTemplate("hermes");
await setRuntime("hermes");
await waitFor(() =>
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
);
@@ -479,13 +533,13 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => {
it("hides hermes section and resets state when template is cleared", async () => {
await openDialog();
await setTemplate("hermes");
await setRuntime("hermes");
await waitFor(() =>
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
);
// Clear template
await setTemplate("");
// Switch back to a non-Hermes runtime.
await setRuntime("claude-code");
await waitFor(() =>
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeNull()
);
@@ -96,12 +96,12 @@ vi.mock("@/lib/design-tokens", () => ({
// ─── Fixtures ─────────────────────────────────────────────────────────────────
const TEMPLATE = {
id: "tpl-1",
name: "Claude Code Agent",
description: "A general-purpose coding assistant",
id: "seo-agent",
name: "SEO Agent",
description: "SEO workspace template",
tier: 2,
skill_count: 3,
model: "claude-opus-4-5",
model: "MiniMax-M2.7",
};
function template(overrides: Partial<typeof TEMPLATE> = {}): typeof TEMPLATE {
@@ -159,7 +159,7 @@ describe("EmptyState — loading", () => {
it("does not render template buttons while loading", async () => {
renderEmpty();
await flush();
expect(screen.queryByText("Claude Code Agent")).toBeNull();
expect(screen.queryByText("SEO Agent")).toBeNull();
});
});
@@ -183,8 +183,8 @@ describe("EmptyState — templates", () => {
it("renders template buttons with name and description", async () => {
renderEmpty();
await flush();
expect(screen.getByText("Claude Code Agent")).toBeTruthy();
expect(screen.getByText("A general-purpose coding assistant")).toBeTruthy();
expect(screen.getByText("SEO Agent")).toBeTruthy();
expect(screen.getByText("SEO workspace template")).toBeTruthy();
});
it("renders tier badge and skill count", async () => {
@@ -198,25 +198,42 @@ describe("EmptyState — templates", () => {
it("renders model name when present", async () => {
renderEmpty();
await flush();
expect(screen.getByText(/claude-opus/i)).toBeTruthy();
expect(screen.getByText(/MiniMax-M2.7/i)).toBeTruthy();
});
it("calls deploy with the template on click", async () => {
renderEmpty();
await flush();
fireEvent.click(screen.getByText("Claude Code Agent"));
fireEvent.click(screen.getByText("SEO Agent"));
expect(_deploy.deployFn).toHaveBeenCalledWith(template());
});
it("hides runtime-default templates from the product template grid", async () => {
mockApiGet.mockResolvedValue([
template({ id: "claude-code-default", name: "Claude Code Agent" }),
template({ id: "codex", name: "OpenAI Codex CLI" }),
template({ id: "hermes", name: "Hermes Agent" }),
template({ id: "openclaw", name: "OpenClaw Agent" }),
template(),
]);
renderEmpty();
await flush();
expect(screen.getByText("SEO Agent")).toBeTruthy();
expect(screen.queryByText("Claude Code Agent")).toBeNull();
expect(screen.queryByText("OpenAI Codex CLI")).toBeNull();
expect(screen.queryByText("Hermes Agent")).toBeNull();
expect(screen.queryByText("OpenClaw Agent")).toBeNull();
});
it("shows 'Deploying...' on the button of the template being deployed", async () => {
_deploy.deploying = "tpl-1";
_deploy.deploying = "seo-agent";
renderEmpty();
await flush();
expect(screen.getByText("Deploying...")).toBeTruthy();
});
it("disables the template button of the deploying template", async () => {
_deploy.deploying = "tpl-1";
_deploy.deploying = "seo-agent";
renderEmpty();
await flush();
const btn = screen.getByText("Deploying...").closest("button") as HTMLButtonElement;
@@ -224,7 +241,7 @@ describe("EmptyState — templates", () => {
});
it("disables 'create blank' while a template is deploying", async () => {
_deploy.deploying = "tpl-1";
_deploy.deploying = "seo-agent";
renderEmpty();
await flush();
expect(screen.getByRole("button", { name: "+ Create blank workspace" }).disabled).toBe(true);
@@ -245,7 +262,7 @@ describe("EmptyState — fetch failure / empty templates", () => {
it("does not render template grid when GET /templates returns []", async () => {
renderEmpty();
await flush();
expect(screen.queryByText("Claude Code Agent")).toBeNull();
expect(screen.queryByText("SEO Agent")).toBeNull();
});
it("renders 'create blank' button when templates list is empty", async () => {
@@ -258,7 +275,7 @@ describe("EmptyState — fetch failure / empty templates", () => {
mockApiGet.mockReset().mockRejectedValue(new Error("Network failure"));
renderEmpty();
await flush();
expect(screen.queryByText("Claude Code Agent")).toBeNull();
expect(screen.queryByText("SEO Agent")).toBeNull();
});
});
@@ -316,7 +333,7 @@ describe("EmptyState — create blank", () => {
await flush();
fireEvent.click(screen.getByRole("button", { name: "+ Create blank workspace" }));
await act(async () => { await Promise.resolve(); });
expect((screen.getByText("Claude Code Agent").closest("button") as HTMLButtonElement).disabled).toBe(true);
expect((screen.getByText("SEO Agent").closest("button") as HTMLButtonElement).disabled).toBe(true);
});
it("shows error banner when POST /workspaces fails", async () => {
@@ -402,6 +402,31 @@ describe("MissingKeysModal — add keys and deploy", () => {
expect(onKeysAdded).toHaveBeenCalled();
});
it("shows optional keys without blocking deploy", () => {
const onKeysAdded = vi.fn();
render(
<MissingKeysModal
open={true}
missingKeys={[]}
optionalKeys={["GOOGLE_GSC_SITE"]}
runtime="claude-code"
title="Configure Workspace"
onKeysAdded={onKeysAdded}
onCancel={vi.fn()}
/>
);
expect(screen.getByText("Optional")).toBeTruthy();
expect(screen.getAllByText("GOOGLE_GSC_SITE").length).toBeGreaterThan(0);
const deployBtn = Array.from(document.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "Deploy",
);
expect(deployBtn).toBeTruthy();
expect(deployBtn!.disabled).toBe(false);
act(() => { fireEvent.click(deployBtn!); });
expect(onKeysAdded).toHaveBeenCalled();
});
it("shows global error when not all keys saved", async () => {
const onKeysAdded = vi.fn();
render(
@@ -529,4 +554,4 @@ describe("MissingKeysModal — cancel and settings", () => {
);
expect(screen.queryByRole("button", { name: /open settings/i })).toBeNull();
});
});
});
@@ -44,6 +44,14 @@ const HERMES_MODELS: SelectorModel[] = [
];
describe("inferVendor", () => {
it("uses explicit provider metadata before slug heuristics", () => {
expect(inferVendor({
id: "moonshot/kimi-k2.6",
provider: "platform",
required_env: [],
})).toBe("platform");
});
it("uses slash prefix when present", () => {
expect(inferVendor({ id: "nousresearch/hermes-4-70b", required_env: ["HERMES_API_KEY"] }))
.toBe("nousresearch");
@@ -105,6 +113,22 @@ describe("buildProviderCatalog", () => {
expect(oauth!.models.map((m) => m.id).sort()).toEqual(["haiku", "opus", "sonnet"]);
});
it("labels explicit platform-managed providers", () => {
const catalog = buildProviderCatalog([
{
id: "moonshot/kimi-k2.6",
name: "Kimi K2.6",
provider: "platform",
required_env: [],
},
]);
expect(catalog[0]).toMatchObject({
vendor: "platform",
label: "Platform",
envVars: [],
});
});
it("flags wildcard providers", () => {
const catalog = buildProviderCatalog(HERMES_MODELS);
const hf = catalog.find((p) => p.vendor === "huggingface");
@@ -189,6 +189,23 @@ describe("TemplatePalette — sidebar", () => {
expect(screen.getByText("Researcher")).toBeTruthy();
});
it("hides runtime-default templates from the deployable product template list", async () => {
mockGet.mockResolvedValue([
{ id: "claude-code-default", name: "Claude Code Agent", description: "", tier: 4, skills: [] },
{ id: "codex", name: "OpenAI Codex CLI", description: "", tier: 4, skills: [] },
{ id: "hermes", name: "Hermes Agent", description: "", tier: 4, skills: [] },
{ id: "openclaw", name: "OpenClaw Agent", description: "", tier: 4, skills: [] },
{ id: "seo-agent", name: "SEO Agent", description: "SEO workspace template", tier: 4, skills: ["seo"] },
]);
render(<TemplatePalette />);
await openSidebar();
expect(screen.getByText("SEO Agent")).toBeTruthy();
expect(screen.queryByText("Claude Code Agent")).toBeNull();
expect(screen.queryByText("OpenAI Codex CLI")).toBeNull();
expect(screen.queryByText("Hermes Agent")).toBeNull();
expect(screen.queryByText("OpenClaw Agent")).toBeNull();
});
it("shows template description", async () => {
mockGet.mockResolvedValue(MOCK_TEMPLATES);
render(<TemplatePalette />);
@@ -68,7 +68,11 @@ afterEach(() => {
function ShortcutTestComponent() {
useKeyboardShortcuts();
return <div data-testid="canvas-root" />;
return (
<div data-testid="canvas-root">
<div data-testid="display-stream" data-display-stream="true" />
</div>
);
}
function renderWithProvider() {
@@ -78,6 +82,13 @@ function renderWithProvider() {
// ─── Tests ───────────────────────────────────────────────────────────────────
describe("Esc — deselect / close context menu", () => {
it("does not handle keys targeted at the display stream", () => {
mockStoreState.contextMenu = { x: 100, y: 100, nodeId: "n1" };
const { getByTestId } = renderWithProvider();
fireEvent.keyDown(getByTestId("display-stream"), { key: "Escape" });
expect(mockStoreState.closeContextMenu).not.toHaveBeenCalled();
});
it("closes the context menu when one is open", () => {
mockStoreState.contextMenu = { x: 100, y: 100, nodeId: "n1" };
renderWithProvider();
@@ -28,12 +28,14 @@ function hasChildren(nodeId: string, nodes: Node<WorkspaceNodeData>[]): boolean
export function useKeyboardShortcuts() {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement).tagName;
const target = e.target as HTMLElement;
if (target.closest?.('[data-display-stream="true"]')) return;
const tag = target.tagName;
const inInput =
tag === "INPUT" ||
tag === "TEXTAREA" ||
tag === "SELECT" ||
(e.target as HTMLElement).isContentEditable;
target.isContentEditable;
if (e.key === "Escape") {
const state = useCanvasStore.getState();
+98 -1
View File
@@ -313,11 +313,21 @@ function DisplayControlBar({
function DesktopStream({ sessionUrl }: { sessionUrl: string }) {
const containerRef = useRef<HTMLDivElement | null>(null);
const rfbRef = useRef<RFB | null>(null);
const [streamError, setStreamError] = useState<string | null>(null);
const [clipboardStatus, setClipboardStatus] = useState<string | null>(null);
const [remoteClipboardText, setRemoteClipboardText] = useState("");
useEffect(() => {
let cancelled = false;
let rfb: RFB | null = null;
let clipboardTimer: ReturnType<typeof setTimeout> | null = null;
const setTemporaryClipboardStatus = (message: string) => {
setClipboardStatus(message);
if (clipboardTimer) clearTimeout(clipboardTimer);
clipboardTimer = setTimeout(() => setClipboardStatus(null), 2500);
};
async function connect() {
setStreamError(null);
@@ -328,9 +338,19 @@ function DesktopStream({ sessionUrl }: { sessionUrl: string }) {
rfb = new mod.default(containerRef.current, stream.url, {
wsProtocols: ["binary", `molecule-display-token.${stream.token}`],
});
rfbRef.current = rfb;
rfb.scaleViewport = true;
rfb.resizeSession = true;
rfb.focusOnClick = true;
rfb.focus({ preventScroll: true });
rfb.addEventListener("clipboard", (event: Event) => {
const text = (event as CustomEvent<{ text?: string }>).detail?.text ?? "";
if (!text) return;
setRemoteClipboardText(text);
void navigator.clipboard?.writeText(text)
.then(() => setTemporaryClipboardStatus("Copied remote clipboard"))
.catch(() => setTemporaryClipboardStatus("Remote clipboard ready"));
});
rfb.addEventListener("disconnect", (event: Event) => {
const detail = (event as CustomEvent<{ clean?: boolean }>).detail;
if (!cancelled && !detail?.clean) setStreamError("Desktop stream disconnected.");
@@ -343,13 +363,83 @@ function DesktopStream({ sessionUrl }: { sessionUrl: string }) {
connect();
return () => {
cancelled = true;
if (clipboardTimer) clearTimeout(clipboardTimer);
rfbRef.current = null;
rfb?.disconnect();
};
}, [sessionUrl]);
useEffect(() => {
const onPaste = (event: ClipboardEvent) => {
if (!isDisplayEventTarget(containerRef.current, event.target)) return;
const text = event.clipboardData?.getData("text/plain") ?? "";
if (!text) return;
event.preventDefault();
rfbRef.current?.clipboardPasteFrom(text);
rfbRef.current?.focus({ preventScroll: true });
setClipboardStatus("Pasted to desktop");
};
window.addEventListener("paste", onPaste, true);
return () => window.removeEventListener("paste", onPaste, true);
}, []);
const pasteLocalClipboard = async () => {
try {
const text = await navigator.clipboard?.readText();
if (!text) {
setClipboardStatus("Clipboard is empty");
return;
}
rfbRef.current?.clipboardPasteFrom(text);
rfbRef.current?.focus({ preventScroll: true });
setClipboardStatus("Pasted to desktop");
} catch {
setClipboardStatus("Press Ctrl/Cmd+V while the desktop is focused");
}
};
const copyRemoteClipboard = async () => {
if (!remoteClipboardText) {
setClipboardStatus("No remote clipboard yet");
return;
}
try {
await navigator.clipboard.writeText(remoteClipboardText);
setClipboardStatus("Copied remote clipboard");
} catch {
setClipboardStatus("Browser blocked clipboard copy");
}
};
return (
<div className="relative min-h-0 flex-1 bg-black">
<div
data-display-stream="true"
className="relative min-h-0 flex-1 bg-black"
onMouseDown={() => rfbRef.current?.focus({ preventScroll: true })}
>
<div ref={containerRef} title="Workspace desktop" className="h-full w-full overflow-hidden bg-black" />
<div className="absolute right-3 top-3 flex items-center gap-2">
{clipboardStatus && (
<span className="rounded border border-line/50 bg-black/80 px-2 py-1 text-[10px] text-white">
{clipboardStatus}
</span>
)}
<button
type="button"
onClick={pasteLocalClipboard}
className="h-7 rounded border border-line/50 bg-black/75 px-2 text-[10px] font-medium text-white hover:bg-black"
>
Paste
</button>
<button
type="button"
onClick={copyRemoteClipboard}
className="h-7 rounded border border-line/50 bg-black/75 px-2 text-[10px] font-medium text-white hover:bg-black disabled:cursor-not-allowed disabled:opacity-50"
disabled={!remoteClipboardText}
>
Copy
</button>
</div>
{streamError && (
<div className="absolute inset-x-4 top-4 rounded border border-red-500/30 bg-red-950/80 px-3 py-2 text-[11px] text-red-100">
{streamError}
@@ -359,6 +449,13 @@ function DesktopStream({ sessionUrl }: { sessionUrl: string }) {
);
}
function isDisplayEventTarget(container: HTMLElement | null, target: EventTarget | null): boolean {
if (!container) return false;
if (target instanceof Node && container.contains(target)) return true;
const active = document.activeElement;
return active instanceof Node && container.contains(active);
}
function displayWebSocketConnection(sessionUrl: string): { url: string; token: string } {
const url = new URL(sessionUrl, window.location.href);
const token = new URLSearchParams(url.hash.replace(/^#/, "")).get("token") ?? "";
@@ -2,10 +2,12 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
const { mockGet, mockPost, mockRFBConstructor } = vi.hoisted(() => ({
const { mockGet, mockPost, mockRFBConstructor, mockRFBClipboardPasteFrom, mockRFBFocus } = vi.hoisted(() => ({
mockGet: vi.fn(),
mockPost: vi.fn(),
mockRFBConstructor: vi.fn(),
mockRFBClipboardPasteFrom: vi.fn(),
mockRFBFocus: vi.fn(),
}));
vi.mock("@/lib/api", () => ({
@@ -30,6 +32,12 @@ vi.mock("@novnc/novnc", () => ({
this.options = options;
mockRFBConstructor(target, url, options);
}
clipboardPasteFrom(text: string) {
mockRFBClipboardPasteFrom(text);
}
focus(options?: FocusOptions) {
mockRFBFocus(options);
}
disconnect() {}
},
}));
@@ -42,6 +50,8 @@ describe("DisplayTab", () => {
mockGet.mockReset();
mockPost.mockReset();
mockRFBConstructor.mockReset();
mockRFBClipboardPasteFrom.mockReset();
mockRFBFocus.mockReset();
});
it("renders unavailable state for non-display workspaces", async () => {
@@ -157,6 +167,43 @@ describe("DisplayTab", () => {
expect(mockRFBConstructor.mock.calls[0][1]).not.toContain("token=");
});
it("forwards browser paste events into the noVNC clipboard", async () => {
mockGet
.mockResolvedValueOnce({
available: true,
mode: "desktop-control",
protocol: "novnc",
width: 1920,
height: 1080,
})
.mockResolvedValueOnce({
controller: "none",
});
mockPost.mockResolvedValueOnce({
controller: "user",
controlled_by: "admin-token",
expires_at: "2026-05-23T08:48:27Z",
session_url: "/workspaces/ws-display/display/session/websockify#token=signed",
});
render(<DisplayTab workspaceId="ws-display" />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: "Take control" }));
const desktop = await screen.findByTitle("Workspace desktop");
fireEvent.paste(desktop, {
clipboardData: {
getData: (type: string) => (type === "text/plain" ? "Paste Me" : ""),
},
});
expect(mockRFBClipboardPasteFrom).toHaveBeenCalledWith("Paste Me");
expect(mockRFBFocus).toHaveBeenCalledWith({ preventScroll: true });
});
it("releases user display control", async () => {
mockGet
.mockResolvedValueOnce({
@@ -166,11 +166,12 @@ export function AttachmentImage({ workspaceId, attachment, onDownload, tone }: P
open={open}
onClose={() => setOpen(false)}
ariaLabel={`Preview of ${attachment.name}`}
contained
>
<img
src={state.blobUrl}
alt={attachment.name}
className="max-w-[95vw] max-h-[90vh] object-contain"
className="max-w-full max-h-full object-contain"
/>
</AttachmentLightbox>
</>
@@ -1,6 +1,6 @@
"use client";
// AttachmentLightbox — shared fullscreen modal for image / PDF /
// AttachmentLightbox — shared modal for image / PDF /
// (future) any-fullscreen-renderable kind. Owns:
// - Backdrop + centered viewport
// - Esc to close
@@ -14,11 +14,11 @@
//
// Design choices:
//
// 1. Portals — we don't use ReactDOM.createPortal because the canvas
// chat surface already renders at a high z-index and the modal's
// fixed-position layout reaches the viewport regardless. Saves a
// portal mount in the common case + avoids the SSR warning (canvas
// is "use client" but the parent shell is server-rendered).
// 1. Portals — we don't use ReactDOM.createPortal because the chat tab
// already gives us a positioned container and the preview should stay
// inside that panel. Saves a portal mount in the common case + avoids
// the SSR warning (canvas is "use client" but the parent shell is
// server-rendered).
//
// 2. Focus trap — inline implementation (not a 3rd-party dep). The
// chat lightbox needs to trap focus only across two interactive
@@ -41,13 +41,17 @@ interface Props {
* the dialog opens. The caller knows what's inside (image alt
* text, PDF filename) and supplies it. */
ariaLabel: string;
/** Constrain the preview to the nearest positioned ancestor instead
* of the whole browser viewport. ChatTab passes this so previews
* stay inside the active side-panel tab. */
contained?: boolean;
/** The thing being shown in fullscreen — <img>, <embed>, etc.
* Caller is responsible for sizing it to fit the viewport (we
* give it max-w-full max-h-full via CSS). */
children: ReactNode;
}
export function AttachmentLightbox({ open, onClose, ariaLabel, children }: Props) {
export function AttachmentLightbox({ open, onClose, ariaLabel, contained = false, children }: Props) {
const closeButtonRef = useRef<HTMLButtonElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
@@ -90,12 +94,19 @@ export function AttachmentLightbox({ open, onClose, ariaLabel, children }: Props
if (!open) return null;
const rootClass = contained
? "absolute inset-0 z-50 flex items-center justify-center bg-black/85 motion-reduce:transition-none transition-opacity"
: "fixed inset-0 z-50 flex items-center justify-center bg-black/85 motion-reduce:transition-none transition-opacity";
const contentClass = contained
? "h-full w-full p-3 flex items-center justify-center"
: "max-w-[95vw] max-h-[90vh] flex items-center justify-center";
return (
<div
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 motion-reduce:transition-none transition-opacity"
className={rootClass}
onClick={onBackdropClick}
>
{/* Close button — top-right, large hit area, keyboard-focusable.
@@ -112,7 +123,7 @@ export function AttachmentLightbox({ open, onClose, ariaLabel, children }: Props
</svg>
</button>
<div
className="max-w-[95vw] max-h-[90vh] flex items-center justify-center"
className={contentClass}
onClick={(e) => e.stopPropagation()}
>
{children}
@@ -19,8 +19,8 @@
// suppress the toolbar; we keep it on so the user gets standard
// PDF affordances.
//
// Fullscreen: AttachmentLightbox hosts the PDF at viewport size on
// click. Same shared modal as image — third caller justifies the
// Preview: AttachmentLightbox hosts the PDF inside the active chat tab
// on click. Same shared modal as image — third caller justifies the
// abstraction (per RFC #2991 design).
//
// Failure modes:
@@ -144,16 +144,15 @@ export function AttachmentPDF({ workspaceId, attachment, onDownload, tone }: Pro
open={open}
onClose={() => setOpen(false)}
ariaLabel={`Preview of ${attachment.name}`}
contained
>
<embed
src={state.blobUrl}
type="application/pdf"
// The lightbox's content slot caps at 95vw / 90vh, so size
// 100% within that and let the user scroll inside the PDF
// viewer.
style={{ width: "95vw", height: "90vh" }}
aria-label={attachment.name}
/>
<div className="h-full w-full overflow-hidden rounded-lg border border-white/20 bg-white shadow-2xl">
<iframe
src={`${state.blobUrl}#view=FitH`}
title={attachment.name}
className="h-full w-full bg-white"
/>
</div>
</AttachmentLightbox>
</>
);
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
/**
* AttachmentLightbox — fullscreen modal for image / PDF preview.
* AttachmentLightbox — modal for image / PDF preview.
*
* Owns: backdrop + viewport, Esc to close, click-outside to close,
* focus trap (close button focus on open, restore on close),
@@ -135,6 +135,22 @@ describe("AttachmentLightbox — render", () => {
const closeBtn = document.querySelector('button[aria-label="Close preview"]');
expect(closeBtn).toBeTruthy();
});
it("uses absolute positioning when contained=true", () => {
render(
<AttachmentLightbox
open={true}
onClose={vi.fn()}
ariaLabel="Preview"
contained
>
<MockContent />
</AttachmentLightbox>,
);
const dialog = document.querySelector('[role="dialog"]');
expect(dialog?.className).toContain("absolute");
expect(dialog?.className).not.toContain("fixed");
});
});
// ─── Focus management ─────────────────────────────────────────────────────────
@@ -1,19 +1,19 @@
// @vitest-environment jsdom
/**
* AttachmentPDF — inline PDF preview button + click-to-fullscreen lightbox.
* AttachmentPDF — inline PDF preview button + click-to-panel lightbox.
*
* Per RFC #2991 PR-3: platform-auth URIs fetch bytes → Blob → ObjectURL;
* external URIs use the raw URL directly. State machine: idle → loading →
* ready/error. Loading skeleton shown while fetching. Error falls back to
* AttachmentChip. Clicking the preview button opens AttachmentLightbox with
* <embed>. Blob URL cleaned up on unmount.
* a browser PDF iframe. Blob URL cleaned up on unmount.
*
* NOTE: No @testing-library/jest-dom import — use DOM APIs for assertions.
*
* Covers:
* - Renders loading skeleton with PdfGlyph + filename text
* - Renders preview button with PDF glyph, filename, and "PDF" label
* - Opens lightbox with <embed> on button click
* - Opens lightbox with a framed <iframe> viewer on button click
* - Lightbox closes on Escape
* - tone=user applies blue/accent classes on button
* - tone=agent applies neutral border on button
@@ -136,7 +136,7 @@ describe("AttachmentPDF — ready", () => {
expect(btn?.textContent).toContain("PDF");
});
it("opens lightbox with <embed> on button click", async () => {
it("opens lightbox with a framed iframe viewer on button click", async () => {
mockFetchOk("data");
const att = makeAttachment("report.pdf");
render(
@@ -158,8 +158,13 @@ describe("AttachmentPDF — ready", () => {
});
const dialog = document.querySelector('[role="dialog"]');
expect(dialog?.getAttribute("aria-label")).toContain("report.pdf");
// Lightbox contains an <embed>
expect(dialog?.querySelector("embed")).toBeTruthy();
expect(dialog?.className).toContain("absolute");
const frame = dialog?.querySelector("iframe") as HTMLIFrameElement | null;
expect(frame).toBeTruthy();
expect(frame?.getAttribute("title")).toBe("report.pdf");
expect(frame?.className).toContain("bg-white");
expect(frame?.parentElement?.className).toContain("w-full");
expect(dialog?.querySelector("embed")).toBeNull();
});
it("closes lightbox on Escape key", async () => {
@@ -237,11 +237,13 @@ describe("AttachmentPreview dispatch", () => {
expect(screen.getByLabelText(/Open doc\.pdf preview/i)).toBeTruthy();
});
// Click → lightbox opens with <embed> inside.
// Click → panel-contained lightbox opens with a browser PDF iframe.
fireEvent.click(screen.getByLabelText(/Open doc\.pdf preview/i));
const dialog = await screen.findByRole("dialog");
expect(dialog).toBeTruthy();
expect(dialog.querySelector("embed[type='application/pdf']")).not.toBeNull();
expect(dialog.className).toContain("absolute");
expect(dialog.querySelector("iframe")).not.toBeNull();
expect(dialog.querySelector("embed")).toBeNull();
});
it("kind=pdf fetch fails → falls back to chip", async () => {
@@ -113,6 +113,31 @@ describe("resolveAttachmentHref — platform-pending: scheme (poll-mode uploads)
});
});
describe("resolveAttachmentHref — legacy platform content URLs", () => {
const chatWs = "chat-ws-aaaaaaaa";
const sourceWs = "d76977b1-d620-4f42-a57e-111111111111";
const fileID = "e2dfaf2e-1111-4abc-9999-222222222222";
it("rewrites /workspaces/<ws>/content/<file>/content to the authenticated pending-upload endpoint", () => {
const url = resolveAttachmentHref(
chatWs,
`/workspaces/${sourceWs}/content/${fileID}/content`,
);
expect(url).toContain(`/workspaces/${sourceWs}/pending-uploads/${fileID}/content`);
expect(url).not.toContain(`/workspaces/${chatWs}/`);
});
it("treats legacy content URLs as platform attachments so previews fetch with auth headers", () => {
expect(isPlatformAttachment(`/workspaces/${sourceWs}/content/${fileID}/content`)).toBe(true);
});
it("passes malformed legacy content URLs through unchanged", () => {
const malformed = `/workspaces/${sourceWs}/content//content`;
expect(resolveAttachmentHref(chatWs, malformed)).toBe(malformed);
expect(isPlatformAttachment(malformed)).toBe(false);
});
});
describe("isPlatformAttachment", () => {
it("returns true for platform-pending: URIs", () => {
expect(isPlatformAttachment("platform-pending:abc/file")).toBe(true);
@@ -125,6 +125,8 @@ export async function uploadChatFiles(
* - `/workspace/...` (bare absolute path inside the container)
* - `platform-pending:<wsid>/<file_id>` (poll-mode upload, staged
* on platform side; resolves to /pending-uploads/<file_id>/content)
* - `/workspaces/<wsid>/content/<file_id>/content` (legacy platform
* content URL; normalizes to the same pending-upload endpoint)
* Everything that looks like an allowed-root container path is
* rewritten to the authenticated /chat/download endpoint. HTTP(S)
* URIs pass through unchanged so we can also render links to
@@ -163,6 +165,11 @@ export function resolveAttachmentHref(
}
return uri;
}
const legacy = parseLegacyPlatformContentUri(uri);
if (legacy) {
const [wsid, fileID] = legacy;
return `${PLATFORM_URL}/workspaces/${encodeURIComponent(wsid)}/pending-uploads/${encodeURIComponent(fileID)}/content`;
}
const containerPath = normalizeWorkspaceUri(uri);
if (containerPath) {
return `${PLATFORM_URL}/workspaces/${workspaceId}/chat/download?path=${encodeURIComponent(containerPath)}`;
@@ -175,6 +182,7 @@ export function resolveAttachmentHref(
* downloadChatFile rather than letting the browser navigate. */
export function isPlatformAttachment(uri: string): boolean {
if (uri.startsWith("platform-pending:")) return true;
if (parseLegacyPlatformContentUri(uri)) return true;
return normalizeWorkspaceUri(uri) !== null;
}
@@ -183,6 +191,12 @@ export function isPlatformAttachment(uri: string): boolean {
* mirror the server's `allowedRoots` allowlist. */
const ALLOWED_CONTAINER_ROOTS = ["/configs", "/workspace", "/home", "/plugins"];
function parseLegacyPlatformContentUri(uri: string): [string, string] | null {
const m = uri.match(/^\/workspaces\/([^/]+)\/content\/([^/]+)\/content(?:[?#].*)?$/);
if (!m || !m[1] || !m[2]) return null;
return [m[1], m[2]];
}
function normalizeWorkspaceUri(uri: string): string | null {
let path: string | null = null;
if (uri.startsWith("workspace:")) {
@@ -63,6 +63,7 @@ vi.mock("@/components/MissingKeysModal", () => ({
onKeysAdded: (model?: string) => void;
onCancel: () => void;
configuredKeys?: Set<string>;
optionalKeys?: string[];
modelSuggestions?: string[];
initialModel?: string;
title?: string;
@@ -77,6 +78,9 @@ vi.mock("@/components/MissingKeysModal", () => ({
</span>
<span data-testid="modal-initial-model">{props.initialModel ?? ""}</span>
<span data-testid="modal-title">{props.title ?? ""}</span>
<span data-testid="modal-optional-keys">
{(props.optionalKeys ?? []).join(",")}
</span>
<button
data-testid="modal-keys-added"
onClick={() => props.onKeysAdded()}
@@ -113,6 +117,7 @@ function makeTemplate(over: Partial<Template> = {}): Template {
runtime: "claude-code",
models: [],
required_env: [],
recommended_env: [],
...over,
};
}
@@ -129,6 +134,7 @@ beforeEach(() => {
missingKeys: [],
providers: [],
runtime: "claude-code",
optionalKeys: [],
configuredKeys: new Set(),
});
mockApiPost.mockResolvedValue({ id: "ws-new" });
@@ -243,6 +249,7 @@ describe("useTemplateDeploy — preflight failure modes", () => {
missingKeys: ["ANTHROPIC_API_KEY"],
providers: [],
runtime: "claude-code",
optionalKeys: [],
configuredKeys: new Set(),
});
const onDeployed = vi.fn();
@@ -271,6 +278,7 @@ describe("useTemplateDeploy — modal lifecycle", () => {
missingKeys: ["ANTHROPIC_API_KEY"],
providers: [],
runtime: "claude-code",
optionalKeys: [],
configuredKeys: new Set(),
});
const onDeployed = vi.fn();
@@ -306,6 +314,7 @@ describe("useTemplateDeploy — modal lifecycle", () => {
missingKeys: ["ANTHROPIC_API_KEY"],
providers: [],
runtime: "claude-code",
optionalKeys: [],
configuredKeys: new Set(),
});
const { result, rerender } = renderHook(() => useTemplateDeploy());
@@ -359,6 +368,7 @@ describe("useTemplateDeploy — multi-provider always-ask flow", () => {
{ id: "ANTHROPIC_API_KEY", label: "Anthropic", envVars: ["ANTHROPIC_API_KEY"] },
],
runtime: "hermes",
optionalKeys: [],
configuredKeys: new Set(["MINIMAX_API_KEY", "ANTHROPIC_API_KEY"]),
});
const { result, rerender } = renderHook(() => useTemplateDeploy());
@@ -392,6 +402,7 @@ describe("useTemplateDeploy — multi-provider always-ask flow", () => {
{ id: "ANTHROPIC_API_KEY", label: "Anthropic", envVars: ["ANTHROPIC_API_KEY"] },
],
runtime: "hermes",
optionalKeys: [],
configuredKeys: new Set(),
});
const { result, rerender } = renderHook(() => useTemplateDeploy());
@@ -420,6 +431,7 @@ describe("useTemplateDeploy — multi-provider always-ask flow", () => {
{ id: "ANTHROPIC_API_KEY", label: "Anthropic", envVars: ["ANTHROPIC_API_KEY"] },
],
runtime: "hermes",
optionalKeys: [],
configuredKeys: new Set(),
});
const { result, rerender } = renderHook(() => useTemplateDeploy());
@@ -484,6 +496,7 @@ describe("useTemplateDeploy — multi-provider always-ask flow", () => {
{ id: "ANTHROPIC_API_KEY", label: "Anthropic", envVars: ["ANTHROPIC_API_KEY"] },
],
runtime: "hermes",
optionalKeys: [],
configuredKeys: new Set(),
});
const { result, rerender } = renderHook(() => useTemplateDeploy());
@@ -499,6 +512,35 @@ describe("useTemplateDeploy — multi-provider always-ask flow", () => {
expect(screen.getByTestId("modal-configured-size").textContent).toBe("0");
expect(mockApiPost).not.toHaveBeenCalled();
});
it("opens configure modal for optional env prompts even when no required provider key is missing", async () => {
mockCheckDeploySecrets.mockResolvedValueOnce({
ok: true,
missingKeys: [],
providers: [],
runtime: "claude-code",
optionalKeys: ["GOOGLE_GSC_SITE", "GOOGLE_GA4_PROPERTY_ID"],
configuredKeys: new Set(),
});
const { result, rerender } = renderHook(() => useTemplateDeploy());
await act(async () => {
await result.current.deploy(makeTemplate({
id: "seo-agent",
name: "SEO Agent",
recommended_env: ["GOOGLE_GSC_SITE", "GOOGLE_GA4_PROPERTY_ID"],
}));
});
rerender();
render(<>{result.current.modal}</>);
expect(screen.getByTestId("missing-keys-modal")).toBeTruthy();
expect(screen.getByTestId("modal-optional-keys").textContent).toBe(
"GOOGLE_GSC_SITE,GOOGLE_GA4_PROPERTY_ID",
);
expect(mockApiPost).not.toHaveBeenCalled();
});
});
describe("useTemplateDeploy — POST failure", () => {
@@ -15,6 +15,8 @@ export function useKeyboardShortcut(
if (!enabled) return;
function handler(e: KeyboardEvent) {
const target = e.target as HTMLElement;
if (target.closest?.('[data-display-stream="true"]')) return;
if (e.key !== key) return;
if (meta && !e.metaKey) return;
if (ctrl && !e.ctrlKey) return;
+7 -1
View File
@@ -152,6 +152,7 @@ export function useTemplateDeploy(
runtime,
models: template.models,
required_env: template.required_env,
recommended_env: template.recommended_env,
});
} catch (e) {
// Preflight network failure used to strand `deploying` — the
@@ -165,7 +166,11 @@ export function useTemplateDeploy(
setDeploying(null);
return;
}
if (preflight.ok && preflight.providers.length === 0) {
if (
preflight.ok &&
preflight.providers.length === 0 &&
preflight.optionalKeys.length === 0
) {
await executeDeploy(template);
return;
}
@@ -220,6 +225,7 @@ export function useTemplateDeploy(
<MissingKeysModal
open={!!missingKeysInfo}
missingKeys={missingKeysInfo?.preflight.missingKeys ?? []}
optionalKeys={missingKeysInfo?.preflight.optionalKeys ?? []}
providers={missingKeysInfo?.preflight.providers ?? []}
runtime={missingKeysInfo?.preflight.runtime ?? ""}
configuredKeys={missingKeysInfo?.preflight.configuredKeys}
@@ -37,6 +37,11 @@ const CLAUDE_CODE: TemplateLike = {
required_env: ["OPENAI_API_KEY"],
};
const OPTIONAL_ONLY: TemplateLike = {
runtime: "claude-code",
recommended_env: ["GOOGLE_GSC_SITE", "GOOGLE_GA4_PROPERTY_ID"],
};
const UNKNOWN: TemplateLike = { runtime: "nothing-declared" };
// -----------------------------------------------------------------------------
@@ -154,6 +159,7 @@ describe("checkDeploySecrets", () => {
const result = await checkDeploySecrets(CLAUDE_CODE);
expect(result.ok).toBe(true);
expect(result.missingKeys).toEqual([]);
expect(result.optionalKeys).toEqual([]);
expect(result.runtime).toBe("claude-code");
});
@@ -184,6 +190,7 @@ describe("checkDeploySecrets", () => {
);
// Grouped providers preserved for the picker.
expect(result.providers).toHaveLength(3);
expect(result.optionalKeys).toEqual([]);
});
it("treats has_value=false as not-configured", async () => {
@@ -207,6 +214,22 @@ describe("checkDeploySecrets", () => {
expect(global.fetch).not.toHaveBeenCalled();
});
it("prompts optional-only env without treating it as missing", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve([]),
} as Response);
const result = await checkDeploySecrets(OPTIONAL_ONLY);
expect(result.ok).toBe(true);
expect(result.missingKeys).toEqual([]);
expect(result.optionalKeys).toEqual([
"GOOGLE_GSC_SITE",
"GOOGLE_GA4_PROPERTY_ID",
]);
expect(global.fetch).toHaveBeenCalled();
});
it("uses the workspace-scoped endpoint when workspaceId is provided", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
@@ -244,6 +267,7 @@ describe("checkDeploySecrets", () => {
const result = await checkDeploySecrets(CLAUDE_CODE);
expect(result.ok).toBe(false);
expect(result.missingKeys).toEqual(["OPENAI_API_KEY"]);
expect(result.optionalKeys).toEqual([]);
// Empty Set on fetch failure — useTemplateDeploy relies on this
// so the picker still opens with every entry rendered as input.
expect(result.configuredKeys).toEqual(new Set());
@@ -8,7 +8,7 @@
* count bounded.
*/
import { describe, it, expect } from "vitest";
import { resolveRuntime } from "../deploy-preflight";
import { isUserVisibleWorkspaceTemplate, resolveRuntime } from "../deploy-preflight";
describe("resolveRuntime", () => {
describe("explicit runtime-map entries", () => {
@@ -64,3 +64,15 @@ describe("resolveRuntime", () => {
});
});
});
describe("isUserVisibleWorkspaceTemplate", () => {
it("hides runtime-default templates from product template surfaces", () => {
for (const id of ["claude-code-default", "codex", "hermes", "openclaw"]) {
expect(isUserVisibleWorkspaceTemplate({ id })).toBe(false);
}
});
it("keeps product templates visible", () => {
expect(isUserVisibleWorkspaceTemplate({ id: "seo-agent" })).toBe(true);
});
});
+22 -2
View File
@@ -21,6 +21,7 @@ import { api } from "./api";
export interface ModelSpec {
id: string;
name?: string;
provider?: string;
required_env?: string[];
}
@@ -31,6 +32,8 @@ export interface TemplateLike {
models?: ModelSpec[];
/** AND-required env vars declared at runtime_config level. */
required_env?: string[];
/** Optional env vars declared at runtime_config level. */
recommended_env?: string[];
}
/** Full /templates response shape shared by TemplatePalette (sidebar)
@@ -49,6 +52,17 @@ export interface Template extends TemplateLike {
skill_count: number;
}
const RUNTIME_DEFAULT_TEMPLATE_IDS = new Set([
"claude-code-default",
"codex",
"hermes",
"openclaw",
]);
export function isUserVisibleWorkspaceTemplate(template: Pick<Template, "id">): boolean {
return !RUNTIME_DEFAULT_TEMPLATE_IDS.has(template.id);
}
/** Map from a template id to the runtime name the per-workspace
* preflight expects. Used only when the server's `/templates`
* response predates the `runtime` field on the summary (legacy
@@ -84,6 +98,8 @@ export interface PreflightResult {
/** Flat list of env var names needed — for the legacy modal path and
* for callers that want a single display of "what's missing". */
missingKeys: string[];
/** Optional env vars to offer in the modal without blocking deploy. */
optionalKeys: string[];
/** Grouped provider options derived from the template. When length ≥ 2
* the modal renders a picker; length 1 means exactly one provider is
* required (AllKeysModal renders the N envVars inline). */
@@ -236,12 +252,14 @@ export async function checkDeploySecrets(
): Promise<PreflightResult> {
const providers = providersFromTemplate(template);
const runtime = template.runtime;
const optionalKeys = Array.from(new Set(template.recommended_env ?? []));
if (providers.length === 0) {
if (providers.length === 0 && optionalKeys.length === 0) {
// Template declares no env requirements — nothing to preflight.
return {
ok: true,
missingKeys: [],
optionalKeys: [],
providers: [],
runtime,
configuredKeys: new Set(),
@@ -263,10 +281,11 @@ export async function checkDeploySecrets(
configured = new Set();
}
if (findSatisfiedProvider(providers, configured)) {
if (providers.length === 0 || findSatisfiedProvider(providers, configured)) {
return {
ok: true,
missingKeys: [],
optionalKeys,
providers,
runtime,
configuredKeys: configured,
@@ -281,6 +300,7 @@ export async function checkDeploySecrets(
return {
ok: false,
missingKeys,
optionalKeys,
providers,
runtime,
configuredKeys: configured,
+4 -1
View File
@@ -12,7 +12,9 @@ import type { NextRequest } from "next/server";
* • style-src retains 'unsafe-inline': React Flow positions nodes via
* element-level style="" attributes which cannot be nonce'd; CSS injection
* is significantly lower risk than script injection and is acceptable here.
* • object-src / base-uri / frame-ancestors locked to 'none'/'self'.
* • object-src locked to 'none'; frame-src allows self + blob: for
* browser-native PDF previews backed by authenticated Blob URLs.
* • base-uri / frame-ancestors locked to 'self'/'none'.
* • upgrade-insecure-requests forces HTTPS on mixed-content.
*
* Development — permissive policy:
@@ -61,6 +63,7 @@ export function buildCsp(nonce: string, isDev: boolean): string {
"img-src 'self' blob: data:",
"font-src 'self'",
"object-src 'none'",
"frame-src 'self' blob:",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
+2
View File
@@ -4,6 +4,8 @@ declare module "@novnc/novnc" {
resizeSession: boolean;
focusOnClick: boolean;
constructor(target: HTMLElement, url: string, options?: { wsProtocols?: string[]; [key: string]: unknown });
clipboardPasteFrom(text: string): void;
disconnect(): void;
focus(options?: FocusOptions): void;
}
}
@@ -70,7 +70,7 @@ def test_diag_memory_root_writable_in_canary_mode(sim: CPSim) -> None:
key = f"canary-probe-{uuid.uuid4().hex[:8]}"
try:
val = sim.probe_memory(key)
except Exception as e:
except Exception:
# /mcp may not be exposed on this template — canary 4 will
# surface the real defect if memory is actually broken.
if os.environ.get("CANARY_STRICT_MCP") == "1":
+3 -2
View File
@@ -1,5 +1,5 @@
{
"_comment": "OSS surface registry — every repo listed here MUST be public on git.moleculesai.app. Layer-3 customer/private templates are NOT registered here; they are handled at provision-time via the per-tenant credential resolver (see internal#102 RFC). 'main' refs are pinned to tags before broad rollout.",
"_comment": "Platform template registry. Repos may be public or platform-private; CI and runtime template-cache refresh clone them with the SSOT-managed template read token, then strip .git metadata before use. Customer/private tenant templates remain outside this platform manifest. 'main' refs are pinned to tags before broad rollout.",
"version": 1,
"plugins": [
{"name": "browser-automation", "repo": "molecule-ai/molecule-ai-plugin-browser-automation", "ref": "main"},
@@ -28,7 +28,8 @@
{"name": "claude-code-default", "repo": "molecule-ai/molecule-ai-workspace-template-claude-code", "ref": "main"},
{"name": "hermes", "repo": "molecule-ai/molecule-ai-workspace-template-hermes", "ref": "main"},
{"name": "openclaw", "repo": "molecule-ai/molecule-ai-workspace-template-openclaw", "ref": "main"},
{"name": "codex", "repo": "molecule-ai/molecule-ai-workspace-template-codex", "ref": "main"}
{"name": "codex", "repo": "molecule-ai/molecule-ai-workspace-template-codex", "ref": "main"},
{"name": "seo-agent", "repo": "molecule-ai/molecule-ai-workspace-template-seo-agent", "ref": "main"}
],
"org_templates": [
{"name": "molecule-dev", "repo": "molecule-ai/molecule-ai-org-template-molecule-dev", "ref": "main"},
+4 -13
View File
@@ -8,19 +8,10 @@
# Requires: git, jq (lighter than python3 — ~2MB vs ~50MB in Alpine)
#
# Auth (optional):
# Post-2026-05-08 (#192): every repo in manifest.json is public on
# git.moleculesai.app. Anonymous clone works for the entire registered
# set. The OSS-surface contract is recorded in manifest.json's _comment
# — Layer-3 customer/private templates (e.g. reno-stars) are NOT in the
# manifest; they are handled at provision-time via the per-tenant
# credential resolver (internal#102 RFC).
#
# MOLECULE_GITEA_TOKEN is therefore optional today. Kept supported for
# two reasons: (a) historical CI configs that still inject
# AUTO_SYNC_TOKEN remain harmless, (b) reserved for the case where a
# private internal-only template is later registered via a ci-readonly
# team grant — review must explicitly sign off on that, since it
# violates the public-OSS-surface contract.
# Repos in manifest.json may be public or platform-private. CI and
# operator refresh jobs should set MOLECULE_GITEA_TOKEN to the
# SSOT-managed template read token. Anonymous clone still works for
# public entries, but private platform templates depend on the token.
#
# The token (when set) never enters the Docker image: this script runs
# in the trusted CI context BEFORE `docker buildx build`, populates
+2 -2
View File
@@ -281,8 +281,8 @@ def main() -> int:
for prefix, peers in sorted(open_pr_collisions.items()):
peer_str = ", ".join(f"#{p['number']} ({p['headRefName']})" for p in peers)
print(f"::error::migration prefix {prefix:03d} also claimed by open PR(s): {peer_str}")
print(f"::error::rebase coordination needed — only one PR can land a given prefix; "
f"renumber yours or theirs")
print("::error::rebase coordination needed — only one PR can land a given prefix; "
"renumber yours or theirs")
return 1
+5 -2
View File
@@ -1,11 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
BASE="http://localhost:8080"
BASE="${BASE:-http://localhost:8080}"
PASS=0
FAIL=0
TIMEOUT="${A2A_TIMEOUT:-120}" # seconds per A2A call (override via A2A_TIMEOUT env var)
# shellcheck source=_lib.sh
source "$(dirname "$0")/_lib.sh"
check() {
local desc="$1"
local expected="$2"
@@ -130,7 +133,7 @@ echo ""
# ========================================
echo "--- Test 6: Offline workspace ---"
# Create a workspace but don't provision it
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" -d '{"name":"Offline Test","tier":1}')
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" -d '{"name":"Offline Test","tier":1,"runtime":"external","external":true}')
OFFLINE_ID=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
R=$(curl -s --max-time 10 -X POST "$BASE/workspaces/$OFFLINE_ID/a2a" \
-H "Content-Type: application/json" \
+1 -1
View File
@@ -215,7 +215,7 @@ echo ""
echo "--- Activity Isolation ---"
# Test 19: Create a second workspace to verify isolation
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" -d '{"name":"Activity Test Workspace","tier":1}')
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" -d '{"name":"Activity Test Workspace","tier":1,"runtime":"external","external":true}')
TEMP_ID=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
# Test 20: New workspace has empty activity
+18 -11
View File
@@ -76,8 +76,8 @@ echo "--- Section 2: Workspace CRUD ---"
# create; sections that depend on container readiness (RT_* in 2b)
# still run normally.
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
-d '{"name":"Test PM","role":"Project Manager","tier":2}')
check "Create PM" '"status":"provisioning"' "$R"
-d '{"name":"Test PM","role":"Project Manager","tier":2,"runtime":"external","external":true}')
check "Create PM" '"status":"awaiting_agent"' "$R"
PM_ID=$(echo "$R" | jq_extract "['id']")
echo " PM_ID=$PM_ID"
RR=$(curl -s -X POST "$BASE/registry/register" -H "Content-Type: application/json" \
@@ -86,8 +86,8 @@ PM_TOKEN=$(echo "$RR" | e2e_extract_token)
# Create child workspace under PM
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
-d "{\"name\":\"Test Dev\",\"role\":\"Developer\",\"tier\":2,\"parent_id\":\"$PM_ID\"}")
check "Create Dev (child of PM)" '"status":"provisioning"' "$R"
-d "{\"name\":\"Test Dev\",\"role\":\"Developer\",\"tier\":2,\"parent_id\":\"$PM_ID\",\"runtime\":\"external\",\"external\":true}")
check "Create Dev (child of PM)" '"status":"awaiting_agent"' "$R"
DEV_ID=$(echo "$R" | jq_extract "['id']")
RR=$(curl -s -X POST "$BASE/registry/register" -H "Content-Type: application/json" \
-d "{\"id\":\"$DEV_ID\",\"url\":\"http://localhost:9001\",\"agent_card\":{\"name\":\"Dev Agent\",\"skills\":[],\"version\":\"1.0.0\"}}")
@@ -95,16 +95,16 @@ DEV_TOKEN=$(echo "$RR" | e2e_extract_token)
# Create sibling
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
-d "{\"name\":\"Test QA\",\"role\":\"QA\",\"tier\":1,\"parent_id\":\"$PM_ID\"}")
check "Create QA (sibling of Dev)" '"status":"provisioning"' "$R"
-d "{\"name\":\"Test QA\",\"role\":\"QA\",\"tier\":1,\"parent_id\":\"$PM_ID\",\"runtime\":\"external\",\"external\":true}")
check "Create QA (sibling of Dev)" '"status":"awaiting_agent"' "$R"
QA_ID=$(echo "$R" | jq_extract "['id']")
curl -s -X POST "$BASE/registry/register" -H "Content-Type: application/json" \
-d "{\"id\":\"$QA_ID\",\"url\":\"http://localhost:9002\",\"agent_card\":{\"name\":\"QA\",\"skills\":[]}}" > /dev/null
# Create unrelated workspace
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
-d '{"name":"Test Outsider","role":"External","tier":1}')
check "Create Outsider (unrelated)" '"status":"provisioning"' "$R"
-d '{"name":"Test Outsider","role":"External","tier":1,"runtime":"external","external":true}')
check "Create Outsider (unrelated)" '"status":"awaiting_agent"' "$R"
OUTSIDER_ID=$(echo "$R" | jq_extract "['id']")
# List workspaces
@@ -130,19 +130,24 @@ check "PM position persisted" '"x":100' "$R"
echo ""
echo "--- Section 2b: Runtime Assignment ---"
if [ "${RUN_SPAWNED_RUNTIME_LEGACY_E2E:-0}" != "1" ]; then
echo " SKIP: spawned-runtime image checks require local runtime images; set RUN_SPAWNED_RUNTIME_LEGACY_E2E=1 to enable"
SKIP=$((SKIP + 5))
else
# Create workspace with explicit runtime
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
-d '{"name":"RT Claude","role":"Test","tier":2,"runtime":"claude-code"}')
-d '{"name":"RT Claude","role":"Test","tier":2,"runtime":"claude-code","model":"sonnet"}')
check "Create claude-code workspace" '"status":"provisioning"' "$R"
RT_CC_ID=$(echo "$R" | jq_extract "['id']")
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
-d '{"name":"RT Codex","role":"Test","tier":2,"runtime":"codex"}')
-d '{"name":"RT Codex","role":"Test","tier":2,"runtime":"codex","model":"openai:gpt-5"}')
check "Create codex workspace" '"status":"provisioning"' "$R"
RT_CX_ID=$(echo "$R" | jq_extract "['id']")
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
-d '{"name":"RT Hermes","role":"Test","tier":2,"runtime":"hermes"}')
-d '{"name":"RT Hermes","role":"Test","tier":2,"runtime":"hermes","model":"openai:gpt-5"}')
check "Create hermes workspace" '"status":"provisioning"' "$R"
RT_HM_ID=$(echo "$R" | jq_extract "['id']")
@@ -235,6 +240,8 @@ sleep 0.3
e2e_delete_workspace "$RT_HM_ID" "RT Hermes"
sleep 0.3
fi
# ============================================================
# Section 3: Registry & Heartbeat
# ============================================================
+1 -1
View File
@@ -71,7 +71,7 @@ check_http "GET /workspaces (empty DB)" "200" "$R"
# Create a workspace so tokens land in the DB.
R=$(curl -s -w "\n%{http_code}" -X POST "$BASE/workspaces" \
-H "Content-Type: application/json" \
-d '{"name":"Dev-Mode-Test","tier":1}')
-d '{"name":"Dev-Mode-Test","tier":1,"runtime":"external","external":true}')
CODE=$(echo "$R" | tail -n1)
BODY=$(echo "$R" | sed '$d')
check_http "POST /workspaces (create)" "201" "$CODE"
+9 -6
View File
@@ -4,9 +4,10 @@
# Round-trip: register a workspace as poll-mode (no callback URL) → POST a
# multi-file chat upload → verify each file becomes (a) one
# `chat_upload_receive` activity row and (b) one /pending-uploads row → fetch
# the bytes back via the poll endpoint → ack → verify the row 404s on
# subsequent fetch. Also pins cross-workspace bleed protection: workspace B
# cannot read workspace A's pending uploads even with its own valid bearer.
# the bytes back via the poll endpoint → ack → verify the row stays readable
# during retention for refreshed canvas previews. Also pins cross-workspace
# bleed protection: workspace B cannot read workspace A's pending uploads even
# with its own valid bearer.
#
# Why this exists separately from test_chat_upload_e2e.sh: that script
# covers the PUSH path (the workspace's own /internal/chat/uploads/ingest).
@@ -218,14 +219,16 @@ case "$RE_ACK1_CODE" in
;;
esac
# ---------- Phase 7: GET content after ack returns 404 ----------
# ---------- Phase 7: GET content after ack remains readable ----------
echo ""
echo "--- Phase 7: Acked file 404s on subsequent fetch ---"
echo "--- Phase 7: Acked file remains readable during retention ---"
POST_ACK=$(curl -s -w '\n%{http_code}' --max-time "$TIMEOUT" -H "Authorization: Bearer $TOK_A" \
"$BASE/workspaces/$WS_A/pending-uploads/$FID1/content")
POST_ACK_CODE=$(printf '%s' "$POST_ACK" | tail -n1)
check_eq "acked alpha returns HTTP 404" "404" "$POST_ACK_CODE"
POST_ACK_BODY=$(printf '%s' "$POST_ACK" | sed '$d')
check_eq "acked alpha returns HTTP 200" "200" "$POST_ACK_CODE"
check_eq "acked alpha bytes still readable" "$EXPECTED1" "$POST_ACK_BODY"
# ---------- Phase 8: cross-workspace bleed protection ----------
echo ""
+2 -2
View File
@@ -97,7 +97,7 @@ except Exception:
done
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
-d '{"name":"Abilities Sender","tier":1}')
-d '{"name":"Abilities Sender","tier":1,"runtime":"external","external":true}')
SENDER_ID=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' 2>/dev/null || true)
[ -n "$SENDER_ID" ] || { echo "Failed to create sender workspace: $R"; exit 1; }
SENDER_TOKEN=$(echo "$R" | e2e_extract_token)
@@ -113,7 +113,7 @@ ADMIN_TOKEN="${MOLECULE_ADMIN_TOKEN:-$SENDER_TOKEN}"
ADMIN_AUTH="Authorization: Bearer $ADMIN_TOKEN"
R=$(curl -s -X POST "$BASE/workspaces" -H "$ADMIN_AUTH" -H "Content-Type: application/json" \
-d '{"name":"Abilities Receiver","tier":1}')
-d '{"name":"Abilities Receiver","tier":1,"runtime":"external","external":true}')
RECEIVER_ID=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' 2>/dev/null || true)
[ -n "$RECEIVER_ID" ] || { echo "Failed to create receiver workspace: $R"; exit 1; }
RECEIVER_TOKEN=$(echo "$R" | e2e_extract_token)
-2
View File
@@ -18,9 +18,7 @@ No network. No live Gitea calls.
from __future__ import annotations
import importlib.util
import json
import os
import sys
import textwrap
from pathlib import Path
from unittest import mock
+1 -3
View File
@@ -55,9 +55,7 @@ from __future__ import annotations
import importlib.util
import os
import sys
from pathlib import Path
from unittest import mock
import pytest
@@ -164,7 +162,7 @@ def test_bp_orphan_context_fails(envset, monkeypatch, capsys):
" all-required:\n runs-on: x\n steps:\n - run: echo hi\n",
)
m = _import_lint()
posted = _stub_api(
_stub_api(
monkeypatch,
m,
("ok", {"status_check_contexts": [
@@ -60,10 +60,8 @@ from __future__ import annotations
import importlib.util
import os
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest import mock
import pytest
-3
View File
@@ -53,10 +53,7 @@ from __future__ import annotations
import importlib.util
import os
import subprocess
import sys
import textwrap
from pathlib import Path
from unittest import mock
import pytest
@@ -61,9 +61,7 @@ from __future__ import annotations
import importlib.util
import os
import subprocess
import sys
from pathlib import Path
from unittest import mock
import pytest
-2
View File
@@ -38,9 +38,7 @@ from __future__ import annotations
import importlib.util
import os
import sys
from pathlib import Path
from unittest import mock
import pytest
+108 -11
View File
@@ -37,7 +37,6 @@ from __future__ import annotations
import importlib.util
import json
import os
import sys
import urllib.error
from pathlib import Path
from unittest import mock
@@ -117,15 +116,25 @@ def _make_stub_api(responses: dict):
def __call__(self, method, path, *, body=None, query=None, expect_json=True):
self.calls.append((method, path, body, query))
# If we've stored a list for this (method, path), rotate through.
# This supports tests that need sequential responses for the
# same endpoint without adding query-param noise.
key = (method, path)
if key not in responses:
raise AssertionError(
f"unexpected api call: {method} {path} (no stub registered)"
)
r = responses[key]
if isinstance(r, Exception):
raise r
return r
r = responses.get(key)
if isinstance(r, list):
if not r:
raise AssertionError(
f"stub sequential responses exhausted for {method} "
f"{path} — provisioned {len(r)} entries"
)
return r.pop(0)
if r is not None:
if isinstance(r, Exception):
raise r
return r
raise AssertionError(
f"unexpected api call: {method} {path} (no stub registered)"
)
return StubApi()
@@ -133,6 +142,7 @@ def _make_stub_api(responses: dict):
# Sample SHA used throughout. 40 chars per Gitea convention.
SHA_RED = "deadbeefcafe1234567890abcdef000011112222"
SHA_GREEN = "ababababcdcdcdcd0000111122223333deadc0de"
SHA_NEW = "aaaabbbbccccddddeeeeffff0000111122223333"
def _branches_response(sha: str) -> dict:
@@ -140,6 +150,19 @@ def _branches_response(sha: str) -> dict:
return {"name": "main", "commit": {"id": sha}}
def _branch_alt(sha: str) -> dict:
"""Identical shape but to a different key path so _make_stub_api
retains a separate first-response entry from the primary
_branches_response() path.
The stub stores only the first response per (method, path) pair.
Tests that need two distinct responses for the same logical
GET /branches/main call use _branch_alt for the second lookup so
the stub returns the correct sequential entry.
"""
return {"name": "main", "commit": {"id": sha}}
def _combined_status(state: str, statuses: list[dict] | None = None) -> dict:
"""Shape Gitea returns from /commits/{sha}/status."""
return {"state": state, "statuses": statuses or []}
@@ -542,7 +565,6 @@ def test_auto_close_skips_when_main_pending(wd_module, monkeypatch):
"""main pending (CI still running) at NEW_SHA → leave old issue alone.
Pending could resolve to red, so closing prematurely would lose the
breadcrumb of the prior red."""
old_title = f"[main-red] owner/repo: {SHA_RED[:10]}"
stub = _make_stub_api({
("GET", "/repos/owner/repo/branches/main"): (200, _branches_response(SHA_GREEN)),
("GET", f"/repos/owner/repo/commits/{SHA_GREEN}/status"): (
@@ -561,6 +583,81 @@ def test_auto_close_skips_when_main_pending(wd_module, monkeypatch):
assert ("GET", "/repos/owner/repo/issues") not in methods_paths
# --------------------------------------------------------------------------
# Stale-issue cleanup on transient / head-drift (internal#1789)
# --------------------------------------------------------------------------
def test_head_drift_closes_stale_issue_for_prior_sha(wd_module, monkeypatch):
"""Initial red at SHA_RED. Before recheck, main is force-pushed to
SHA_NEW (different commit). watchdog must close the stale SHA_RED
issue before returning — otherwise stale open issues accumulate
when main is force-pushed during a red window."""
stub = _make_stub_api({
# Initial check: branch SHA_RED, status failure
("GET", "/repos/owner/repo/branches/main"): [
(200, _branches_response(SHA_RED)),
(200, _branch_alt(SHA_NEW)), # recheck branch call → HEAD moved
(200, _branch_alt(SHA_NEW)), # close path branch call
],
("GET", f"/repos/owner/repo/commits/{SHA_RED}/status"): [
(200, _combined_status("failure", [
{"context": "ci/test", "status": "failure", "description": "broke"},
])),
(200, _combined_status("success", [ # recheck: CI result arrived
{"context": "ci/test", "status": "success"},
])),
],
(f"GET", f"/repos/owner/repo/commits/{SHA_NEW}/status"): [
(200, _combined_status("success", [
{"context": "ci/test", "status": "success"},
])),
],
# close_open_red_issues_for_other_shas(SHA_NEW): issue for SHA_RED found
("GET", "/repos/owner/repo/issues"): [
(200, [{"number": 9, "title": f"[main-red] owner/repo: {SHA_RED[:10]}"}]),
],
("POST", "/repos/owner/repo/issues/9/comments"): (201, {"id": 200}),
("PATCH", "/repos/owner/repo/issues/9"): (200, {"number": 9, "state": "closed"}),
})
monkeypatch.setattr(wd_module, "api", stub)
rc = wd_module.run_once(dry_run=False)
assert rc == 0
methods_paths = [(c[0], c[1]) for c in stub.calls]
assert ("PATCH", "/repos/owner/repo/issues/9") in methods_paths, \
"head-drift should close the stale SHA_RED issue"
def test_recovery_on_same_sha_closes_issue_filed_on_prior_tick(wd_module, monkeypatch):
"""Same SHA shows red on initial check, but CI recovers before recheck
completes. watchdog must close the issue that was filed on an earlier
tick for this same SHA — otherwise stale open issues accumulate when CI
recovers within the settling window."""
stub = _make_stub_api({
("GET", "/repos/owner/repo/branches/main"): (200, _branches_response(SHA_RED)),
# Sequential: initial check → failure, recheck (≥2nd call) → success.
# Using a list so Python dict keeps a single key (avoids overwrite).
("GET", f"/repos/owner/repo/commits/{SHA_RED}/status"): [
(200, _combined_status("failure", [
{"context": "ci/test", "status": "failure", "description": "broke"},
])),
(200, _combined_status("success", [
{"context": "ci/test", "state": "success"},
])),
],
# List open red issues → find stale issue for this SHA
("GET", "/repos/owner/repo/issues"): (
200, [{"number": 11, "title": f"[main-red] owner/repo: {SHA_RED[:10]}"}],
),
("POST", "/repos/owner/repo/issues/11/comments"): (201, {"id": 300}),
("PATCH", "/repos/owner/repo/issues/11"): (200, {"number": 11, "state": "closed"}),
})
monkeypatch.setattr(wd_module, "api", stub)
rc = wd_module.run_once(dry_run=False)
assert rc == 0
methods_paths = [(c[0], c[1]) for c in stub.calls]
assert ("PATCH", "/repos/owner/repo/issues/11") in methods_paths, \
"recovery-on-same-SHA should close the stale issue"
# --------------------------------------------------------------------------
# HTTP-failure / api() raises — duplicate-write regression guard
# --------------------------------------------------------------------------
@@ -790,7 +887,7 @@ def test_emit_loki_event_prints_json_line(wd_module, capsys, monkeypatch):
captured = capsys.readouterr()
assert "main-red-watchdog event:" in captured.out
# Find the JSON payload after the prefix and verify it parses
line = [l for l in captured.out.splitlines() if "main-red-watchdog event:" in l][0]
line = [ln for ln in captured.out.splitlines() if "main-red-watchdog event:" in ln][0]
payload = json.loads(line.split("main-red-watchdog event:", 1)[1].strip())
assert payload["event_type"] == "main_red_detected"
assert payload["repo"] == "owner/repo"
-2
View File
@@ -40,7 +40,6 @@ Dependencies: stdlib + pytest + PyYAML. No network.
from __future__ import annotations
import importlib.util
import json
import os
import sys
from pathlib import Path
@@ -853,7 +852,6 @@ def test_reap_skips_combined_success_shas(sr_module, monkeypatch):
Mock 2 SHAs with combined=success + 1 with combined=failure → only
the failure-SHA's statuses get the per-context loop applied.
"""
per_context_iterated_for: list[str] = []
posts: list[tuple[str, dict]] = []
failure_statuses = [
+3 -5
View File
@@ -23,11 +23,9 @@ import json
import os
import re
import sys
import time
import urllib.request
import urllib.error
from datetime import datetime, timezone
from typing import Any, Optional
# ── Gitea API client ────────────────────────────────────────────────────────
@@ -160,9 +158,9 @@ def signal_1_comment_scan(pr_number: int, repo: str) -> dict:
# Build reverse map: login -> (group, agent_key)
login_to_group = {}
for group, login in relevant_roles.items():
for role, l in AGENT_LOGIN_MAP.items():
if l == login:
login_to_group[l] = (group, f"core-{role}")
for role, role_login in AGENT_LOGIN_MAP.items():
if role_login == login:
login_to_group[role_login] = (group, f"core-{role}")
# Collect all agent-tag matches from comments
comments = []
+1
View File
@@ -71,6 +71,7 @@ RUN apk add --no-cache ca-certificates docker-cli docker-cli-buildx git tzdata w
COPY --from=builder /platform /platform
COPY --from=builder /memory-plugin /memory-plugin
COPY workspace-server/migrations /migrations
COPY manifest.json /app/manifest.json
# Templates + plugins (pre-cloned by scripts/clone-manifest.sh in the
# trusted CI / operator-host context, .git already stripped). The Gitea
# token used to clone them never enters this image — same shape as
+1
View File
@@ -118,6 +118,7 @@ RUN deluser --remove-home node 2>/dev/null || true; \
COPY --from=go-builder /platform /platform
COPY --from=go-builder /memory-plugin /memory-plugin
COPY workspace-server/migrations /migrations
COPY manifest.json /app/manifest.json
# Templates + plugins (pre-cloned by scripts/clone-manifest.sh in the
# trusted CI / operator-host context, .git already stripped — see
+60 -2
View File
@@ -50,6 +50,7 @@ import (
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/router"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/scheduler"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/supervised"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/templatecache"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/ws"
// External plugins — each registers EnvMutator(s) that run at workspace
@@ -58,6 +59,7 @@ import (
ghidentity "go.moleculesai.app/plugin/gh-identity/pluginloader"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/pkg/provisionhook"
"github.com/gin-gonic/gin"
)
func main() {
@@ -193,11 +195,28 @@ func main() {
port := envOr("PORT", "8080")
platformURL := envOr("PLATFORM_URL", fmt.Sprintf("http://host.docker.internal:%s", port))
configsDir := envOr("CONFIGS_DIR", findConfigsDir())
templateCacheDir := envOr("TEMPLATE_CACHE_DIR", filepath.Join(os.TempDir(), "molecule-template-cache"))
manifestPath := findWorkspaceManifestPath()
templateToken := templateCacheToken()
refreshTemplates := func(ctx context.Context) (templatecache.RefreshReport, error) {
return templatecache.RefreshWorkspaceTemplates(ctx, manifestPath, templateCacheDir, templateToken)
}
if shouldRefreshTemplateCache(templateToken, manifestPath) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
report, err := refreshTemplates(ctx)
cancel()
if err != nil {
log.Printf("template cache refresh: %v (continuing with baked templates)", err)
} else {
log.Printf("template cache refresh: refreshed %d workspace templates into %s", len(report.Results), templateCacheDir)
}
}
// Init order: wh → onWorkspaceOffline → liveness/healthSweep → router
// WorkspaceHandler is created before the router so RestartByID can be wired into
// the offline callbacks used by both the liveness monitor and the health sweep.
wh := handlers.NewWorkspaceHandler(broadcaster, prov, platformURL, configsDir)
wh := handlers.NewWorkspaceHandler(broadcaster, prov, platformURL, configsDir).
WithTemplateCacheDir(templateCacheDir)
if cpProv != nil {
wh.SetCPProvisioner(cpProv)
}
@@ -377,7 +396,12 @@ func main() {
// require a plugins/ dir on disk (nil in CP/SaaS mode).
pluginRegistry := plugins.NewRegistry()
pluginRegistry.Register(plugins.NewGithubResolver())
r := router.Setup(hub, broadcaster, prov, platformURL, configsDir, wh, channelMgr, memBundle, pluginRegistry)
refreshTemplatesHTTP := func(c *gin.Context) (any, error) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Minute)
defer cancel()
return refreshTemplates(ctx)
}
r := router.Setup(hub, broadcaster, prov, platformURL, configsDir, templateCacheDir, wh, channelMgr, memBundle, pluginRegistry, refreshTemplatesHTTP)
// Plugin drift sweeper — periodic detection of upstream plugin version drift
// (core#123). Scans workspace_plugins rows where tracked_ref != 'none',
@@ -493,6 +517,40 @@ func findConfigsDir() string {
return "workspace-configs-templates"
}
func findWorkspaceManifestPath() string {
if v := os.Getenv("WORKSPACE_MANIFEST_PATH"); v != "" {
return v
}
for _, p := range []string{"/app/manifest.json", "manifest.json", "../manifest.json", "../../manifest.json"} {
if abs, err := filepath.Abs(p); err == nil {
if _, err := os.Stat(abs); err == nil {
return abs
}
}
}
return ""
}
func templateCacheToken() string {
for _, key := range []string{"MOLECULE_TEMPLATE_GITEA_TOKEN", "MOLECULE_GITEA_TOKEN"} {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
}
return ""
}
func shouldRefreshTemplateCache(token, manifestPath string) bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv("TEMPLATE_CACHE_REFRESH"))) {
case "0", "false", "off", "no":
return false
case "1", "true", "on", "yes":
return token != "" && manifestPath != ""
default:
return token != "" && manifestPath != ""
}
}
func findMigrationsDir() string {
candidates := []string{
"migrations",
@@ -61,8 +61,12 @@ func NewPendingUploadsHandler(storage pendinguploads.Storage) *PendingUploadsHan
// - file_id not found
// - file_id belongs to a different workspace (cross-workspace bleed
// protection)
// - row already acked (workspace's bug — should not re-fetch after ack)
// - row past expires_at (Phase 3 sweep would delete shortly anyway)
//
// Acked rows are intentionally still readable until the sweeper's
// ack-retention window elapses. Canvas chat history persists
// platform-pending: URIs; after a poll-mode workspace acks the handoff,
// a browser refresh still needs to preview/download the attachment.
func (h *PendingUploadsHandler) GetContent(c *gin.Context) {
workspaceID := c.Param("id")
if err := validateWorkspaceID(workspaceID); err != nil {
@@ -78,7 +82,7 @@ func (h *PendingUploadsHandler) GetContent(c *gin.Context) {
rec, err := h.storage.Get(c.Request.Context(), fileID)
if errors.Is(err, pendinguploads.ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "pending upload not found, expired, or already acked"})
c.JSON(http.StatusNotFound, gin.H{"error": "pending upload not found or expired"})
return
}
if err != nil {
@@ -181,4 +185,3 @@ func (h *PendingUploadsHandler) Ack(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"acked": true})
}
@@ -124,13 +124,17 @@ func TestIntegration_PendingUploads_PutGetAckRoundTrip(t *testing.T) {
t.Errorf("FetchedAt should be set after MarkFetched")
}
// Ack flips acked_at; subsequent Gets return ErrNotFound (acked rows
// are filtered out at the SELECT predicate).
// Ack flips acked_at. Acked rows remain readable during retention so
// refreshed canvas previews can resolve platform-pending: attachment URIs.
if err := store.Ack(ctx, fileID); err != nil {
t.Fatalf("Ack: %v", err)
}
if _, err := store.Get(ctx, fileID); err != pendinguploads.ErrNotFound {
t.Errorf("Get after Ack: got %v, want ErrNotFound", err)
rec3, err := store.Get(ctx, fileID)
if err != nil {
t.Fatalf("Get after Ack: %v", err)
}
if rec3.AckedAt == nil {
t.Errorf("AckedAt should be set after Ack")
}
// Idempotent re-ack succeeds.
+118 -60
View File
@@ -54,6 +54,7 @@ const maxUploadFiles = 200
type TemplatesHandler struct {
configsDir string
cacheDir string
docker *client.Client
// wh is used by Import and ReplaceFiles to call DefaultTier() so a
// generated config.yaml's tier matches the SaaS-vs-self-hosted
@@ -61,6 +62,11 @@ type TemplatesHandler struct {
// the caller doesn't import templates that need a fresh config
// generated.
wh *WorkspaceHandler
// refreshCache is nil unless main wires a manifest-backed template
// cache refresher. POST /admin/templates/refresh uses this hook so a
// template repo merge can update the tenant catalog without rebuilding
// the full tenant image.
refreshCache func(ctx *gin.Context) (any, error)
}
// NewTemplatesHandler constructs a TemplatesHandler. wh may be nil for
@@ -71,12 +77,23 @@ func NewTemplatesHandler(configsDir string, dockerCli *client.Client, wh *Worksp
return &TemplatesHandler{configsDir: configsDir, docker: dockerCli, wh: wh}
}
func (h *TemplatesHandler) WithCacheDir(cacheDir string) *TemplatesHandler {
h.cacheDir = cacheDir
return h
}
func (h *TemplatesHandler) WithRefreshFunc(fn func(ctx *gin.Context) (any, error)) *TemplatesHandler {
h.refreshCache = fn
return h
}
// modelSpec describes a single supported model on a template: its id (sent
// to the runtime), a human-readable label, and the env vars that must be
// present for that model to work (e.g. API keys).
type modelSpec struct {
ID string `json:"id" yaml:"id"`
Name string `json:"name,omitempty" yaml:"name"`
Provider string `json:"provider,omitempty" yaml:"provider"`
RequiredEnv []string `json:"required_env,omitempty" yaml:"required_env"`
}
@@ -116,6 +133,10 @@ type templateSummary struct {
// preflight uses this as the fallback provider when `models` is empty
// so provider picker stays data-driven instead of hardcoded in the UI.
RequiredEnv []string `json:"required_env,omitempty"`
// RecommendedEnv mirrors runtime_config.recommended_env from the
// template's config.yaml. Canvas prompts for these as non-blocking
// optional secrets during template deploy.
RecommendedEnv []string `json:"recommended_env,omitempty"`
// Providers is the runtime's own list of supported provider slugs,
// sourced from runtime_config.providers in the template's config.yaml.
// The canvas Config tab surfaces this as the Provider override
@@ -156,6 +177,15 @@ type templateSummary struct {
// Only resolves to actual templates (not ws-* dirs since those are now Docker volumes).
// Returns empty string if no matching template is found.
func (h *TemplatesHandler) resolveTemplateDir(wsName string) string {
if h.cacheDir != "" {
nameDir := filepath.Join(h.cacheDir, normalizeName(wsName))
if _, err := os.Stat(nameDir); err == nil {
return nameDir
}
if tmpl := findTemplateByName(h.cacheDir, wsName); tmpl != "" {
return filepath.Join(h.cacheDir, tmpl)
}
}
nameDir := filepath.Join(h.configsDir, normalizeName(wsName))
if _, err := os.Stat(nameDir); err == nil {
return nameDir
@@ -170,76 +200,104 @@ func (h *TemplatesHandler) resolveTemplateDir(wsName string) string {
// List handles GET /templates
func (h *TemplatesHandler) List(c *gin.Context) {
templates := make([]templateSummary, 0)
walkTemplateConfigs(h.configsDir, func(id string, data []byte) {
var raw struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
Tier int `yaml:"tier"`
Runtime string `yaml:"runtime"`
Model string `yaml:"model"`
Skills []string `yaml:"skills"`
// Top-level `providers:` block — structured registry. Distinct
// from runtime_config.providers (slug list) below. Both shapes
// coexist in production: claude-code ships the structured
// registry, hermes still uses the slug list. /templates surfaces
// both verbatim so each runtime owns its taxonomy.
Providers []providerRegistryEntry `yaml:"providers"`
RuntimeConfig struct {
Model string `yaml:"model"`
Models []modelSpec `yaml:"models"`
RequiredEnv []string `yaml:"required_env"`
Providers []string `yaml:"providers"`
ProvisionTimeoutSeconds int `yaml:"provision_timeout_seconds"`
} `yaml:"runtime_config"`
}
if err := yaml.Unmarshal(data, &raw); err != nil {
// Without this log a malformed config.yaml causes the
// template to silently disappear from /templates with no
// trace — the operator can't tell "excluded due to parse
// error" from "never existed." That matters more now that
// templates ship richer YAML shapes (top-level providers
// registry, models[] with required_env, etc.) where a
// type-shape mismatch on one field drops the whole entry.
log.Printf("templates list: skip %s: yaml.Unmarshal: %v", id, err)
return
}
runtime := strings.TrimSuffix(strings.TrimSpace(raw.Runtime), "-default")
if _, ok := knownRuntimes[runtime]; !ok {
log.Printf("templates list: skip %s: unsupported runtime %q", id, raw.Runtime)
seen := map[string]struct{}{}
walk := func(root string) {
if root == "" {
return
}
walkTemplateConfigs(root, func(id string, data []byte) {
if _, ok := seen[id]; ok {
return
}
seen[id] = struct{}{}
var raw struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
Tier int `yaml:"tier"`
Runtime string `yaml:"runtime"`
Model string `yaml:"model"`
Skills []string `yaml:"skills"`
// Top-level `providers:` block — structured registry. Distinct
// from runtime_config.providers (slug list) below. Both shapes
// coexist in production: claude-code ships the structured
// registry, hermes still uses the slug list. /templates surfaces
// both verbatim so each runtime owns its taxonomy.
Providers []providerRegistryEntry `yaml:"providers"`
RuntimeConfig struct {
Model string `yaml:"model"`
Models []modelSpec `yaml:"models"`
RequiredEnv []string `yaml:"required_env"`
RecommendedEnv []string `yaml:"recommended_env"`
Providers []string `yaml:"providers"`
ProvisionTimeoutSeconds int `yaml:"provision_timeout_seconds"`
} `yaml:"runtime_config"`
}
if err := yaml.Unmarshal(data, &raw); err != nil {
// Without this log a malformed config.yaml causes the
// template to silently disappear from /templates with no
// trace — the operator can't tell "excluded due to parse
// error" from "never existed." That matters more now that
// templates ship richer YAML shapes (top-level providers
// registry, models[] with required_env, etc.) where a
// type-shape mismatch on one field drops the whole entry.
log.Printf("templates list: skip %s: yaml.Unmarshal: %v", id, err)
return
}
runtime := strings.TrimSuffix(strings.TrimSpace(raw.Runtime), "-default")
if _, ok := knownRuntimes[runtime]; !ok {
log.Printf("templates list: skip %s: unsupported runtime %q", id, raw.Runtime)
return
}
// Model comes from either top-level (legacy) or runtime_config.model (current).
model := raw.Model
if model == "" {
model = raw.RuntimeConfig.Model
}
// Model comes from either top-level (legacy) or runtime_config.model (current).
model := raw.Model
if model == "" {
model = raw.RuntimeConfig.Model
}
tier := raw.Tier
if h.wh != nil && h.wh.IsSaaS() {
tier = h.wh.DefaultTier()
}
tier := raw.Tier
if h.wh != nil && h.wh.IsSaaS() {
tier = h.wh.DefaultTier()
}
templates = append(templates, templateSummary{
ID: id,
Name: raw.Name,
Description: raw.Description,
Tier: tier,
Runtime: raw.Runtime,
Model: model,
Models: raw.RuntimeConfig.Models,
RequiredEnv: raw.RuntimeConfig.RequiredEnv,
Providers: raw.RuntimeConfig.Providers,
ProviderRegistry: raw.Providers,
Skills: raw.Skills,
SkillCount: len(raw.Skills),
ProvisionTimeoutSeconds: raw.RuntimeConfig.ProvisionTimeoutSeconds,
templates = append(templates, templateSummary{
ID: id,
Name: raw.Name,
Description: raw.Description,
Tier: tier,
Runtime: raw.Runtime,
Model: model,
Models: raw.RuntimeConfig.Models,
RequiredEnv: raw.RuntimeConfig.RequiredEnv,
RecommendedEnv: raw.RuntimeConfig.RecommendedEnv,
Providers: raw.RuntimeConfig.Providers,
ProviderRegistry: raw.Providers,
Skills: raw.Skills,
SkillCount: len(raw.Skills),
ProvisionTimeoutSeconds: raw.RuntimeConfig.ProvisionTimeoutSeconds,
})
})
})
}
walk(h.cacheDir)
walk(h.configsDir)
c.JSON(http.StatusOK, templates)
}
// RefreshCache handles POST /admin/templates/refresh.
func (h *TemplatesHandler) RefreshCache(c *gin.Context) {
if h.refreshCache == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "template cache refresh is not configured"})
return
}
result, err := h.refreshCache(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, result)
}
// ListFiles handles GET /workspaces/:id/files
// Lists files inside the running container's /configs directory (or /workspace, etc.).
// Falls back to host-side config templates directory when container isn't running.
@@ -133,6 +133,71 @@ skills:
}
}
func TestTemplatesList_CacheOverridesBakedTemplate(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
bakedDir := t.TempDir()
cacheDir := t.TempDir()
mustWriteTemplate := func(root, id, body string) {
t.Helper()
dir := filepath.Join(root, id)
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("mkdir %s: %v", dir, err)
}
if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(body), 0644); err != nil {
t.Fatalf("write config: %v", err)
}
}
mustWriteTemplate(bakedDir, "seo-agent", `name: SEO Agent
description: stale
tier: 4
runtime: claude-code
model: old
runtime_config:
recommended_env: [TELEGRAM_BOT_TOKEN]
skills: []
`)
mustWriteTemplate(cacheDir, "seo-agent", `name: SEO Agent
description: fresh
tier: 4
runtime: claude-code
model: moonshot/kimi-k2.6
runtime_config:
required_env: [TENANT_NAME]
recommended_env: [GOOGLE_GSC_SITE]
skills: []
`)
handler := NewTemplatesHandler(bakedDir, nil, nil).WithCacheDir(cacheDir)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/templates", nil)
handler.List(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp []templateSummary
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("parse: %v", err)
}
if len(resp) != 1 {
t.Fatalf("expected 1 template, got %d", len(resp))
}
if resp[0].Description != "fresh" {
t.Fatalf("cache template should override baked copy, got description %q", resp[0].Description)
}
if !reflect.DeepEqual(resp[0].RequiredEnv, []string{"TENANT_NAME"}) {
t.Fatalf("RequiredEnv = %+v", resp[0].RequiredEnv)
}
if reflect.DeepEqual(resp[0].RecommendedEnv, []string{"TELEGRAM_BOT_TOKEN"}) {
t.Fatalf("stale baked recommended_env leaked through: %+v", resp[0].RecommendedEnv)
}
}
func TestTemplatesList_RuntimeAndModelsRegistry(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
@@ -148,12 +213,14 @@ tier: 2
runtime: hermes
runtime_config:
model: nous-hermes-3-70b
recommended_env: [GOOGLE_GSC_SITE, GOOGLE_GA4_PROPERTY_ID]
models:
- id: nous-hermes-3-70b
name: Nous Hermes 3 70B
required_env: [HERMES_API_KEY]
- id: minimax/minimax-m2.7
name: MiniMax M2.7 (via OpenRouter)
provider: platform
required_env: [OPENROUTER_API_KEY]
skills: []
`
@@ -196,9 +263,17 @@ skills: []
if got.Models[1].ID != "minimax/minimax-m2.7" {
t.Errorf("Models[1].ID: got %q", got.Models[1].ID)
}
if got.Models[1].Provider != "platform" {
t.Errorf("Models[1].Provider: got %q", got.Models[1].Provider)
}
if len(got.Models[1].RequiredEnv) != 1 || got.Models[1].RequiredEnv[0] != "OPENROUTER_API_KEY" {
t.Errorf("Models[1] required_env: want [OPENROUTER_API_KEY], got %+v", got.Models[1].RequiredEnv)
}
if len(got.RecommendedEnv) != 2 ||
got.RecommendedEnv[0] != "GOOGLE_GSC_SITE" ||
got.RecommendedEnv[1] != "GOOGLE_GA4_PROPERTY_ID" {
t.Errorf("RecommendedEnv: want [GOOGLE_GSC_SITE GOOGLE_GA4_PROPERTY_ID], got %+v", got.RecommendedEnv)
}
}
// TestTemplatesList_SurfacesProviders pins the Option B PR-5 wiring:
@@ -1,3 +1,6 @@
//go:build integration
// +build integration
package handlers
import (
@@ -6,6 +9,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/db"
@@ -16,22 +20,31 @@ import (
func init() { gin.SetMode(gin.TestMode) }
// setupTokenTestDB creates an in-memory SQLite-like test or returns early
// if the real Postgres test DB is available. For unit tests we use the
// package-level db.DB which handlers rely on.
// setupTokenTestDB connects to $INTEGRATION_DB_URL (skipping the test if
// unset), sets the package-global db.DB for the duration of the test, and
// returns a cleanup func that restores the previous db.DB value.
func setupTokenTestDB(t *testing.T) func() {
t.Helper()
if db.DB == nil {
t.Skip("db.DB not initialised — run with a test database")
url := os.Getenv("INTEGRATION_DB_URL")
if url == "" {
t.Skip("INTEGRATION_DB_URL not set; skipping (local devs: start a Postgres container and export INTEGRATION_DB_URL)")
}
// Quick probe — if the DB is closed or unreachable, skip.
if err := db.DB.Ping(); err != nil {
t.Skipf("db.DB not reachable: %v", err)
conn, err := sql.Open("postgres", url)
if err != nil {
t.Fatalf("open integration DB: %v", err)
}
if err := conn.Ping(); err != nil {
t.Fatalf("ping integration DB: %v", err)
}
prevDB := db.DB
db.DB = conn
return func() {
db.DB = prevDB
conn.Close()
}
return func() {}
}
func TestTokenHandler_CreateAndList(t *testing.T) {
func TestIntegration_TokenHandler_CreateAndList(t *testing.T) {
cleanup := setupTokenTestDB(t)
defer cleanup()
@@ -94,7 +107,7 @@ func TestTokenHandler_CreateAndList(t *testing.T) {
}
}
func TestTokenHandler_Revoke(t *testing.T) {
func TestIntegration_TokenHandler_Revoke(t *testing.T) {
cleanup := setupTokenTestDB(t)
defer cleanup()
@@ -151,7 +164,7 @@ func TestTokenHandler_Revoke(t *testing.T) {
}
}
func TestTokenHandler_RevokeWrongWorkspace(t *testing.T) {
func TestIntegration_TokenHandler_RevokeWrongWorkspace(t *testing.T) {
cleanup := setupTokenTestDB(t)
defer cleanup()
@@ -56,6 +56,7 @@ type WorkspaceHandler struct {
cpProv provisioner.CPProvisionerAPI
platformURL string
configsDir string // path to workspace-configs-templates/ (for reading templates)
cacheDir string // optional runtime-refreshed template cache; overrides configsDir by template id
// envMutators runs registered EnvMutator plugins right before
// container Start, after built-in secret loads. Nil = no plugins
// registered; Registry.Run handles a nil receiver as a no-op so the
@@ -183,6 +184,11 @@ func NewWorkspaceHandler(b events.EventEmitter, p *provisioner.Provisioner, plat
return h
}
func (h *WorkspaceHandler) WithTemplateCacheDir(cacheDir string) *WorkspaceHandler {
h.cacheDir = cacheDir
return h
}
// WithNamespaceCleanup wires the I5 hook (RFC #2728) so workspace
// purge can drop the plugin's `workspace:<id>` namespace. main.go
// passes a closure over plugin.DeleteNamespace; tests pass a stub.
@@ -285,7 +291,7 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
// #226: payload.Template is attacker-controllable. resolveInsideRoot
// rejects absolute paths and any ".." that escapes configsDir so the
// provisioner can't be pointed at host directories.
candidatePath, resolveErr := resolveInsideRoot(h.configsDir, payload.Template)
candidatePath, resolveErr := resolveWorkspaceTemplatePath(h.configsDir, h.cacheDir, payload.Template)
if resolveErr != nil {
log.Printf("Create: invalid template path %q: %v", payload.Template, resolveErr)
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid template"})
@@ -726,7 +732,7 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
var templatePath string
var configFiles map[string][]byte
if payload.Template != "" {
candidatePath, resolveErr := resolveInsideRoot(h.configsDir, payload.Template)
candidatePath, resolveErr := resolveWorkspaceTemplatePath(h.configsDir, h.cacheDir, payload.Template)
if resolveErr != nil {
log.Printf("Create provision: rejecting template %q: %v", payload.Template, resolveErr)
return
@@ -485,6 +485,17 @@ func findTemplateByName(configsDir, name string) string {
return ""
}
func resolveWorkspaceTemplatePath(configsDir, cacheDir, template string) (string, error) {
if cacheDir != "" {
if p, err := resolveInsideRoot(cacheDir, template); err != nil {
return "", err
} else if _, statErr := os.Stat(p); statErr == nil {
return p, nil
}
}
return resolveInsideRoot(configsDir, template)
}
// resolveOrgTemplate looks for a matching role directory under
// configsDir/org-templates/ and returns the absolute path and a short label
// ("org-templates/<dir>"). Used by the restart handler's rebuild_config path
@@ -658,7 +669,7 @@ func (h *WorkspaceHandler) defaultTemplateProvidersYAML(runtime string) string {
return ""
}
templateName := runtime + "-default"
templatePath, err := resolveInsideRoot(h.configsDir, templateName)
templatePath, err := resolveWorkspaceTemplatePath(h.configsDir, h.cacheDir, templateName)
if err != nil {
log.Printf("Provisioner: default template providers skipped for runtime %s: %v", runtime, err)
return ""
@@ -110,6 +110,32 @@ func TestFindTemplateByName_NotFound(t *testing.T) {
}
}
func TestResolveWorkspaceTemplatePath_PrefersCache(t *testing.T) {
bakedDir := t.TempDir()
cacheDir := t.TempDir()
for _, root := range []string{bakedDir, cacheDir} {
if err := os.MkdirAll(filepath.Join(root, "seo-agent"), 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
}
got, err := resolveWorkspaceTemplatePath(bakedDir, cacheDir, "seo-agent")
if err != nil {
t.Fatalf("resolveWorkspaceTemplatePath: %v", err)
}
want := filepath.Join(cacheDir, "seo-agent")
if got != want {
t.Fatalf("want cache path %q, got %q", want, got)
}
}
func TestResolveWorkspaceTemplatePath_RejectsTraversal(t *testing.T) {
if _, err := resolveWorkspaceTemplatePath(t.TempDir(), t.TempDir(), "../seo-agent"); err == nil {
t.Fatal("expected traversal to be rejected")
}
}
func TestFindTemplateByName_SkipsWsPrefix(t *testing.T) {
tmpDir := t.TempDir()
@@ -23,7 +23,7 @@ var apiPrefixes = []string{
"/settings",
"/bundles",
"/org",
"/orgs", // #610 — per-org plugin allowlist routes
"/orgs", // #610 — per-org plugin allowlist routes
"/templates",
"/plugins",
"/webhooks",
@@ -95,6 +95,7 @@ func SecurityHeaders() gin.HandlerFunc {
"script-src 'self' 'unsafe-inline'; "+
"style-src 'self' 'unsafe-inline'; "+
"img-src 'self' data: blob:; "+
"frame-src 'self' blob:; "+
"connect-src 'self' ws: wss:; "+
"font-src 'self' data:")
}
@@ -57,6 +57,7 @@ func TestSecurityHeaders(t *testing.T) {
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"frame-src 'self' blob:",
"connect-src 'self' ws: wss:",
"font-src 'self' data:",
} {
@@ -195,6 +196,9 @@ func TestCSPCanvasRoutesGetPermissivePolicy(t *testing.T) {
if strings.Contains(csp, "'unsafe-eval'") {
t.Errorf("canvas path %q: CSP must not contain 'unsafe-eval', got %q", path, csp)
}
if !strings.Contains(csp, "frame-src 'self' blob:") {
t.Errorf("canvas path %q: CSP should allow blob: frames for PDF previews, got %q", path, csp)
}
})
}
}
@@ -267,7 +271,7 @@ func TestIsAPIPath(t *testing.T) {
{"/ws", true},
{"/events", true},
{"/approvals", true},
{"/orgs", true}, // #610 allowlist routes
{"/orgs", true}, // #610 allowlist routes
{"/orgs/org-1/plugins/allowlist", true},
// Sub-paths
{"/workspaces/abc-123", true},
@@ -320,20 +320,18 @@ func putBatchInsertRows(ctx context.Context, tx *sql.Tx, workspaceID uuid.UUID,
}
func (p *PostgresStorage) Get(ctx context.Context, fileID uuid.UUID) (Record, error) {
// The expires_at + acked_at filter in the WHERE clause means a
// caller sees ErrNotFound for absent / acked / expired without
// needing per-case branching. Trade-off: we can't differentiate
// in metrics, but the workspace's response is the same in all
// three cases ("file gone, give up") so the granularity isn't
// useful at this layer. Phase 3 dashboards aggregate row-state
// counts directly off the table.
// The expires_at filter keeps hard-TTL semantics while allowing
// acked rows to remain readable during the ack-retention window.
// Canvas chat history stores platform-pending: URIs; after the
// poll-mode workspace acks the upload, refreshed browser previews
// still need to fetch the same bytes until the sweeper reclaims
// the acked row.
var r Record
err := p.db.QueryRowContext(ctx, `
SELECT file_id, workspace_id, content, filename, mimetype,
size_bytes, created_at, fetched_at, acked_at, expires_at
FROM pending_uploads
WHERE file_id = $1
AND acked_at IS NULL
AND expires_at > now()
`, fileID).Scan(
&r.FileID, &r.WorkspaceID, &r.Content, &r.Filename, &r.Mimetype,
@@ -349,15 +347,14 @@ func (p *PostgresStorage) Get(ctx context.Context, fileID uuid.UUID) (Record, er
}
func (p *PostgresStorage) MarkFetched(ctx context.Context, fileID uuid.UUID) error {
// UPDATE on the same gating predicate as Get — keeps the "absent
// or acked or expired = ErrNotFound" contract symmetric. Without
// the predicate a workspace could re-stamp fetched_at on an acked
// row, which would mislead Phase 3's stuck-fetch dashboard.
// UPDATE on the same expiry predicate as Get. This may re-stamp
// fetched_at on an acked row when the canvas previews an attachment
// after refresh, which is fine: acked_at remains the delivery-time
// signal and the sweeper still deletes by acked_at retention.
res, err := p.db.ExecContext(ctx, `
UPDATE pending_uploads
SET fetched_at = now()
WHERE file_id = $1
AND acked_at IS NULL
AND expires_at > now()
`, fileID)
if err != nil {
@@ -50,14 +50,12 @@ const (
size_bytes, created_at, fetched_at, acked_at, expires_at
FROM pending_uploads
WHERE file_id = $1
AND acked_at IS NULL
AND expires_at > now()
`
markFetchedSQL = `
UPDATE pending_uploads
SET fetched_at = now()
WHERE file_id = $1
AND acked_at IS NULL
AND expires_at > now()
`
ackSQL = `
@@ -203,6 +201,36 @@ func TestGet_HappyPath_ReturnsFullRow(t *testing.T) {
}
}
func TestGet_AckedRowWithinRetentionStillReturnsFullRow(t *testing.T) {
db, mock := newMockDB(t)
store := pendinguploads.NewPostgres(db)
fid := uuid.New()
wsID := uuid.New()
now := time.Now().UTC()
ackedAt := now.Add(-5 * time.Minute)
mock.ExpectQuery(selectSQL).
WithArgs(fid).
WillReturnRows(sqlmock.NewRows([]string{
"file_id", "workspace_id", "content", "filename", "mimetype",
"size_bytes", "created_at", "fetched_at", "acked_at", "expires_at",
}).AddRow(
fid, wsID, []byte("data"), "x.bin", "application/octet-stream",
int64(4), now, now, ackedAt, now.Add(24*time.Hour),
))
r, err := store.Get(context.Background(), fid)
if err != nil {
t.Fatalf("Get acked row: %v", err)
}
if r.AckedAt == nil || !r.AckedAt.Equal(ackedAt) {
t.Fatalf("acked_at not preserved: %+v", r.AckedAt)
}
if string(r.Content) != "data" {
t.Errorf("content mismatch: %q", string(r.Content))
}
}
func TestGet_AbsentRow_ReturnsErrNotFound(t *testing.T) {
db, mock := newMockDB(t)
store := pendinguploads.NewPostgres(db)
@@ -247,7 +275,7 @@ func TestMarkFetched_HappyPath(t *testing.T) {
}
}
func TestMarkFetched_AbsentOrAckedOrExpired_ReturnsErrNotFound(t *testing.T) {
func TestMarkFetched_AbsentOrExpired_ReturnsErrNotFound(t *testing.T) {
db, mock := newMockDB(t)
store := pendinguploads.NewPostgres(db)
+5 -2
View File
@@ -36,7 +36,7 @@ import (
// (main.go) gets the same pluginResolver instance so it can share scheme
// enumeration if a deployment registers extra schemes externally. A nil
// pluginResolver is harmless: plgh still works with its built-in defaults.
func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provisioner, platformURL, configsDir string, wh *handlers.WorkspaceHandler, channelMgr *channels.Manager, memBundle *memwiring.Bundle, pluginResolver plugins.PluginResolver) *gin.Engine {
func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provisioner, platformURL, configsDir string, templateCacheDir string, wh *handlers.WorkspaceHandler, channelMgr *channels.Manager, memBundle *memwiring.Bundle, pluginResolver plugins.PluginResolver, refreshTemplates func(ctx *gin.Context) (any, error)) *gin.Engine {
r := gin.Default()
// Issue #179 — trust no reverse-proxy headers. Without this call Gin's
@@ -666,7 +666,9 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
// Templates — wh threaded so generateDefaultConfig picks the
// SaaS-aware default tier in Import + ReplaceFiles (#2910 PR-B).
tmplh := handlers.NewTemplatesHandler(configsDir, dockerCli, wh)
tmplh := handlers.NewTemplatesHandler(configsDir, dockerCli, wh).
WithCacheDir(templateCacheDir).
WithRefreshFunc(refreshTemplates)
// #686: GET /templates lists all template names+metadata from configsDir.
// Open access lets unauthenticated callers enumerate org configurations and
// installed plugins. AdminAuth-gate it alongside POST /templates/import.
@@ -676,6 +678,7 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
tmplAdmin := r.Group("", middleware.AdminAuth(db.DB))
tmplAdmin.GET("/templates", tmplh.List)
tmplAdmin.POST("/templates/import", tmplh.Import)
tmplAdmin.POST("/admin/templates/refresh", tmplh.RefreshCache)
}
wsAuth.PUT("/files", tmplh.ReplaceFiles)
wsAuth.GET("/files", tmplh.ListFiles)
@@ -0,0 +1,176 @@
package templatecache
import (
"context"
"encoding/json"
"fmt"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
type ManifestEntry struct {
Name string `json:"name"`
Repo string `json:"repo"`
Ref string `json:"ref"`
}
type manifestFile struct {
WorkspaceTemplates []ManifestEntry `json:"workspace_templates"`
}
type TemplateResult struct {
Name string `json:"name"`
Repo string `json:"repo"`
Ref string `json:"ref"`
SHA string `json:"sha,omitempty"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
}
type RefreshReport struct {
ManifestPath string `json:"manifest_path"`
CacheDir string `json:"cache_dir"`
RefreshedAt time.Time `json:"refreshed_at"`
Results []TemplateResult `json:"results"`
}
func RefreshWorkspaceTemplates(ctx context.Context, manifestPath, cacheDir, token string) (RefreshReport, error) {
report := RefreshReport{
ManifestPath: manifestPath,
CacheDir: cacheDir,
RefreshedAt: time.Now().UTC(),
}
if strings.TrimSpace(token) == "" {
return report, fmt.Errorf("template cache refresh requires MOLECULE_TEMPLATE_GITEA_TOKEN or MOLECULE_GITEA_TOKEN")
}
data, err := os.ReadFile(manifestPath)
if err != nil {
return report, fmt.Errorf("read manifest: %w", err)
}
var manifest manifestFile
if err := json.Unmarshal(data, &manifest); err != nil {
return report, fmt.Errorf("parse manifest: %w", err)
}
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
return report, fmt.Errorf("mkdir cache: %w", err)
}
for _, entry := range manifest.WorkspaceTemplates {
result := refreshOne(ctx, cacheDir, token, entry)
report.Results = append(report.Results, result)
}
return report, nil
}
func refreshOne(ctx context.Context, cacheDir, token string, entry ManifestEntry) TemplateResult {
result := TemplateResult{Name: entry.Name, Repo: entry.Repo, Ref: entry.Ref}
if result.Ref == "" {
result.Ref = "main"
}
if !safeTemplateName(entry.Name) {
result.Status = "skipped"
result.Error = "invalid template name"
return result
}
if strings.TrimSpace(entry.Repo) == "" {
result.Status = "skipped"
result.Error = "missing repo"
return result
}
tmp, err := os.MkdirTemp(cacheDir, ".tmp-"+entry.Name+"-")
if err != nil {
result.Status = "failed"
result.Error = err.Error()
return result
}
defer os.RemoveAll(tmp)
cloneURL := authenticatedURL(entry.Repo, token)
for _, args := range [][]string{
{"init", "-q", tmp},
{"-C", tmp, "remote", "add", "origin", cloneURL},
{"-C", tmp, "fetch", "--depth=1", "-q", "origin", result.Ref},
{"-C", tmp, "checkout", "-q", "--detach", "FETCH_HEAD"},
} {
cmd := exec.CommandContext(ctx, "git", args...)
if out, err := cmd.CombinedOutput(); err != nil {
result.Status = "failed"
result.Error = sanitizeGitError(out, err, token)
return result
}
}
shaCmd := exec.CommandContext(ctx, "git", "-C", tmp, "rev-parse", "HEAD")
if out, err := shaCmd.Output(); err == nil {
result.SHA = strings.TrimSpace(string(out))
}
_ = os.RemoveAll(filepath.Join(tmp, ".git"))
target := filepath.Join(cacheDir, entry.Name)
old := filepath.Join(cacheDir, ".old-"+entry.Name+"-"+fmt.Sprint(time.Now().UnixNano()))
if _, err := os.Stat(target); err == nil {
if err := os.Rename(target, old); err != nil {
result.Status = "failed"
result.Error = "replace old cache: " + err.Error()
return result
}
defer os.RemoveAll(old)
}
if err := os.Rename(tmp, target); err != nil {
if old != "" {
_ = os.Rename(old, target)
}
result.Status = "failed"
result.Error = "install cache: " + err.Error()
return result
}
result.Status = "refreshed"
return result
}
func safeTemplateName(name string) bool {
if name == "" || name == "." || name == ".." {
return false
}
for _, r := range name {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
continue
}
return false
}
return true
}
func authenticatedURL(repo, token string) string {
if strings.HasPrefix(repo, "http://") || strings.HasPrefix(repo, "https://") {
u, err := url.Parse(repo)
if err == nil {
u.User = url.UserPassword("oauth2", token)
return u.String()
}
}
u := &url.URL{
Scheme: "https",
Host: "git.moleculesai.app",
Path: "/" + strings.TrimSuffix(repo, ".git") + ".git",
User: url.UserPassword("oauth2", token),
}
return u.String()
}
func sanitizeGitError(out []byte, err error, token string) string {
msg := strings.TrimSpace(string(out))
if msg == "" {
msg = err.Error()
}
if token != "" {
msg = strings.ReplaceAll(msg, token, "***")
}
if len(msg) > 300 {
msg = msg[:300]
}
return msg
}
@@ -0,0 +1,16 @@
package templatecache
import "testing"
func TestSafeTemplateName(t *testing.T) {
for _, name := range []string{"seo-agent", "claude_code", "T4"} {
if !safeTemplateName(name) {
t.Fatalf("%q should be safe", name)
}
}
for _, name := range []string{"", "../seo", "seo/agent", "seo.agent"} {
if safeTemplateName(name) {
t.Fatalf("%q should be rejected", name)
}
}
}