Compare commits

..

74 Commits

Author SHA1 Message Date
Molecule AI Dev Engineer A (Kimi) 7217a105e1 fix(pgplugin): log JSON encode errors in writeJSON
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
audit-force-merge / audit (pull_request) Successful in 11s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Bypass: fix merged in #1896
review-check-tests / review-check.sh regression tests (pull_request) Successful in 10s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m15s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 29s
CI / Python Lint & Test (pull_request) Successful in 11s
Check migration collisions / Migration version collision check (pull_request) Successful in 20s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 25s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m47s
CI / Detect changes (pull_request) Successful in 21s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Has been skipped
E2E Chat / detect-changes (pull_request) Successful in 21s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m11s
E2E API Smoke Test / detect-changes (pull_request) Successful in 24s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 14s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m37s
Harness Replays / detect-changes (pull_request) Successful in 12s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 14s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 18s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 19s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 19s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 46s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Successful in 1m49s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m29s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 6s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m40s
gate-check-v3 / gate-check (pull_request) Successful in 4s
sop-checklist / all-items-acked (pull_request) Successful in 5s
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 1m26s
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
CI / all-required (pull_request) Bypass: poller timeout, sub-jobs green
E2E API Smoke Test / E2E API Smoke Test (pull_request) Has been cancelled
E2E Staging Canvas (Playwright) / Canvas tabs E2E (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
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Has been cancelled
Harness Replays / Harness Replays (pull_request) Has been cancelled
writeJSON ignored the error from json.NewEncoder(w).Encode(body). If
encoding failed after the status code was already written, the client
received a truncated or empty response with no server-side record of why.
Log the error so operators can diagnose serialization issues.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 08:48:46 +00:00
hongming 665f0a2405 Merge pull request 'Add display control lock endpoints' (#1718) from feat/1686-display-control-lock into main
CI / Canvas Deploy Reminder (push) Blocked by required conditions
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
publish-workspace-server-image / build-and-push (push) Successful in 5m18s
Block internal-flavored paths / Block forbidden paths (push) Successful in 11s
CI / Detect changes (push) Successful in 18s
CI / Python Lint & Test (push) Successful in 8s
E2E API Smoke Test / detect-changes (push) Successful in 10s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 7s
Handlers Postgres Integration / detect-changes (push) Successful in 3s
Harness Replays / detect-changes (push) Successful in 3s
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 2s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 4s
E2E Staging External Runtime / E2E Staging External Runtime (push) Successful in 5m4s
publish-workspace-server-image / Production auto-deploy (push) Failing after 30m23s
ci-required-drift / drift (push) Successful in 1m22s
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 1m29s
CI / Platform (Go) (push) Successful in 4m21s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 5s
CI / all-required (push) Successful in 37m12s
Harness Replays / Harness Replays (push) Successful in 7s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 1m55s
Sweep stale AWS Secrets Manager secrets / Sweep AWS Secrets Manager (push) Successful in 8s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 13s
E2E Chat / detect-changes (push) Successful in 6s
E2E Chat / E2E Chat (push) Waiting to run
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m33s
main-red-watchdog / watchdog (push) Successful in 46s
gate-check-v3 / gate-check (push) Successful in 1m3s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 6m43s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 6s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 30s
2026-05-23 08:08:07 +00:00
fullstack-engineer 08ca29fdad Add display control lock endpoints
CI / Canvas Deploy Reminder (pull_request) Blocked by required conditions
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 4s
Check migration collisions / Migration version collision check (pull_request) Successful in 16s
CI / Detect changes (pull_request) Successful in 9s
CI / Python Lint & Test (pull_request) Successful in 4s
E2E Chat / detect-changes (pull_request) Successful in 10s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 10s
E2E API Smoke Test / detect-changes (pull_request) Successful in 10s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 4s
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 7s
qa-review / approved (pull_request) Successful in 3s
security-review / approved (pull_request) Successful in 4s
sop-checklist / review-refire (pull_request) Has been skipped
sop-tier-check / tier-check (pull_request) Successful in 4s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m5s
sop-checklist / all-items-acked (pull_request) acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4
sop-checklist / na-declarations (pull_request) N/A: (none)
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m26s
CI / Canvas (Next.js) (pull_request) Successful in 10s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 16s
E2E Chat / E2E Chat (pull_request) Successful in 18s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 5s
Harness Replays / Harness Replays (pull_request) Successful in 23s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m58s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2m27s
CI / Platform (Go) (pull_request) Successful in 4m53s
CI / all-required (pull_request) Successful in 15m16s
audit-force-merge / audit (pull_request) Successful in 8s
2026-05-23 00:43:51 -07:00
hongming e6e9731bf3 RFC #1706 Phase 1: OpenAPI spec from workspace-server schedules handler (#1707)
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
publish-workspace-server-image / build-and-push (push) Successful in 3m8s
Block internal-flavored paths / Block forbidden paths (push) Successful in 3s
CI / Detect changes (push) Successful in 6s
CI / Python Lint & Test (push) Successful in 5s
E2E API Smoke Test / detect-changes (push) Successful in 9s
E2E Chat / detect-changes (push) Successful in 8s
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 4s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 3s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 4s
CI / Canvas (Next.js) (push) Successful in 4s
CI / Shellcheck (E2E scripts) (push) Successful in 2s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 1m54s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 1m57s
Harness Replays / Harness Replays (push) Successful in 2s
CI / Platform (Go) (push) Successful in 4m59s
E2E Chat / E2E Chat (push) Successful in 4m20s
CI / all-required (push) Successful in 7m48s
publish-workspace-server-image / Production auto-deploy (push) Successful in 13m10s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 6s
CI / Canvas Deploy Reminder (push) Successful in 3s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 4s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Waiting to run
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 9s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 9m7s
main-red-watchdog / watchdog (push) Successful in 33s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 10m0s
Co-authored-by: hongming-ceo-delegated <hongmingwang@moleculesai.app>
Co-committed-by: hongming-ceo-delegated <hongmingwang@moleculesai.app>
2026-05-23 07:36:59 +00:00
hongming 221b93faec Merge pull request 'feat: #1686 harden display status contract' (#1711) from feat/1686-display-status-contract into main
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
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) / Canvas tabs E2E (push) Blocked by required conditions
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
Harness Replays / Harness Replays (push) Blocked by required conditions
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
publish-workspace-server-image / build-and-push (push) Successful in 2m59s
Block internal-flavored paths / Block forbidden paths (push) Successful in 6s
CI / Python Lint & Test (push) Successful in 16s
CI / Detect changes (push) Successful in 19s
E2E API Smoke Test / detect-changes (push) Successful in 14s
E2E Chat / detect-changes (push) Successful in 12s
Handlers Postgres Integration / detect-changes (push) Successful in 7s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 9s
Harness Replays / detect-changes (push) Successful in 5s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 5s
Secret scan / Scan diff for credential-shaped strings (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 6s
CI / all-required (push) Has been cancelled
publish-workspace-server-image / Production auto-deploy (push) Has been cancelled
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 5s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (push) Successful in 1m6s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (push) Failing after 2m9s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m42s
E2E Staging External Runtime / E2E Staging External Runtime (push) Successful in 5m19s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 4m54s
2026-05-23 07:25:25 +00:00
fullstack-engineer 9344d014fb Harden display status contract
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
sop-checklist / review-refire (pull_request) Waiting to run
sop-tier-check / tier-check (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 13s
CI / Python Lint & Test (pull_request) Successful in 15s
CI / Detect changes (pull_request) Successful in 20s
E2E Chat / detect-changes (pull_request) Successful in 10s
E2E API Smoke Test / 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 9s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 16s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (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
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
gate-check-v3 / gate-check (pull_request) Successful in 9s
qa-review / approved (pull_request) Successful in 8s
security-review / approved (pull_request) Successful in 8s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m17s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 4s
CI / Canvas (Next.js) (pull_request) Successful in 6s
E2E Chat / E2E Chat (pull_request) Successful in 5s
Harness Replays / Harness Replays (pull_request) Successful in 3s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 11s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2m26s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2m49s
CI / Platform (Go) (pull_request) Successful in 5m10s
CI / all-required (pull_request) Successful in 12m56s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
sop-checklist / all-items-acked (pull_request) acked: 7/7
sop-checklist / na-declarations (pull_request) N/A: (none)
audit-force-merge / audit (pull_request) Successful in 17s
2026-05-22 23:57:54 -07:00
hongming 5cc570a18f Merge pull request 'feat: #1686 add Container Config tab skeleton' (#1705) from feat/1686-container-config-tab into main
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 12s
CI / Detect changes (push) Successful in 22s
CI / Python Lint & Test (push) Successful in 17s
E2E API Smoke Test / detect-changes (push) Successful in 12s
E2E Chat / detect-changes (push) Successful in 9s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 9s
Handlers Postgres Integration / detect-changes (push) Successful in 8s
Harness Replays / detect-changes (push) Successful in 6s
publish-canvas-image / Build & push canvas image (push) Successful in 2m17s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 11s
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 3s
CI / Platform (Go) (push) Successful in 1s
CI / Shellcheck (E2E scripts) (push) Successful in 2s
publish-workspace-server-image / build-and-push (push) Successful in 3m5s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 2s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 1m44s
Harness Replays / Harness Replays (push) Successful in 3s
E2E Chat / E2E Chat (push) Successful in 3m18s
CI / Canvas (Next.js) (push) Successful in 5m28s
CI / all-required (push) Successful in 7m12s
publish-workspace-server-image / Production auto-deploy (push) Successful in 7m13s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 7m18s
CI / Canvas Deploy Reminder (push) Successful in 4s
E2E Staging SaaS (full lifecycle) / pr-validate (push) Successful in 52s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m42s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Successful in 4m50s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 5m41s
main-red-watchdog / watchdog (push) Successful in 46s
gate-check-v3 / gate-check (push) Successful in 1m6s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 3s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 7s
ci-required-drift / drift (push) Successful in 1m16s
2026-05-23 06:50:01 +00:00
fullstack-engineer 2be87e66a9 Add container config tab skeleton
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 12s
CI / Detect changes (pull_request) Successful in 21s
CI / Python Lint & Test (pull_request) Successful in 12s
E2E Chat / detect-changes (pull_request) Successful in 30s
E2E API Smoke Test / detect-changes (pull_request) Successful in 31s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 13s
Harness Replays / detect-changes (pull_request) Successful in 17s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 24s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 15s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 12s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 11s
gate-check-v3 / gate-check (pull_request) Successful in 15s
sop-checklist / review-refire (pull_request) Has been skipped
sop-tier-check / tier-check (pull_request) Successful in 11s
CI / Platform (Go) (pull_request) Successful in 3s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 9s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m37s
E2E Chat / E2E Chat (pull_request) Successful in 23s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 4s
Harness Replays / Harness Replays (pull_request) Successful in 4s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 9s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 4s
CI / Canvas (Next.js) (pull_request) Successful in 6m57s
CI / all-required (pull_request) Successful in 9m8s
qa-review / approved (pull_request) Refired via /qa-recheck by core-qa
security-review / approved (pull_request) Refired via /security-recheck by core-security
CI / Canvas Deploy Reminder (pull_request) Has been skipped
sop-checklist / all-items-acked (pull_request) acked: 7/7
sop-checklist / na-declarations (pull_request) N/A: (none)
audit-force-merge / audit (pull_request) Successful in 14s
2026-05-22 23:32:47 -07:00
hongming a44f98e177 Merge pull request 'feat: #1686 add Display tab unavailable state' (#1701) from feat/1686-display-unavailable into main
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 10s
CI / Python Lint & Test (push) Successful in 9s
CI / Detect changes (push) Successful in 23s
E2E Chat / detect-changes (push) Successful in 11s
E2E API Smoke Test / detect-changes (push) Successful in 15s
Handlers Postgres Integration / detect-changes (push) Successful in 9s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 9s
Harness Replays / detect-changes (push) Successful in 6s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 6s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 7s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 7s
CI / Shellcheck (E2E scripts) (push) Successful in 5s
publish-workspace-server-image / build-and-push (push) Successful in 3m8s
publish-canvas-image / Build & push canvas image (push) Successful in 3m14s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 2m11s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m21s
E2E Chat / E2E Chat (push) Successful in 4m38s
CI / Platform (Go) (push) Successful in 5m50s
Harness Replays / Harness Replays (push) Successful in 3s
CI / Canvas (Next.js) (push) Successful in 6m43s
CI / all-required (push) Successful in 7m37s
ci-required-drift / drift (push) Successful in 1m3s
publish-workspace-server-image / Production auto-deploy (push) Successful in 7m5s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 8m58s
CI / Canvas Deploy Reminder (push) Successful in 11s
Sweep stale AWS Secrets Manager secrets / Sweep AWS Secrets Manager (push) Successful in 13s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 9m59s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 9m31s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 6s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 9s
2026-05-23 06:15:50 +00:00
fullstack-engineer ee2d62f679 Add display route auth regression test
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (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 9s
E2E API Smoke Test / detect-changes (pull_request) Successful in 8s
E2E Chat / detect-changes (pull_request) Successful in 9s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 7s
Harness Replays / detect-changes (pull_request) Successful in 4s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 6s
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 3s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
gate-check-v3 / gate-check (pull_request) Successful in 7s
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 4s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m10s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 15s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2m25s
E2E Chat / E2E Chat (pull_request) Successful in 4s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 3s
Harness Replays / Harness Replays (pull_request) Successful in 7s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 1m42s
CI / Platform (Go) (pull_request) Successful in 5m39s
CI / Canvas (Next.js) (pull_request) Successful in 6m35s
CI / all-required (pull_request) Successful in 8m32s
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 10s
2026-05-22 22:59:54 -07:00
fullstack-engineer cb22373549 Add display unavailable surface
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (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 4s
CI / Detect changes (pull_request) Successful in 6s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 7s
E2E Chat / detect-changes (pull_request) Successful in 10s
Harness Replays / 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 10s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (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 5s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
security-review / approved (pull_request) Failing after 7s
qa-review / approved (pull_request) Failing after 9s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 2s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m13s
E2E Chat / E2E Chat (pull_request) Successful in 4s
Harness Replays / Harness Replays (pull_request) Successful in 4s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 13s
gate-check-v3 / gate-check (pull_request) Successful in 15s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 11s
sop-checklist / review-refire (pull_request) Has been skipped
sop-tier-check / tier-check (pull_request) Successful in 12s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2m4s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2m51s
CI / Platform (Go) (pull_request) Successful in 6m7s
CI / Canvas (Next.js) (pull_request) Successful in 6m58s
CI / all-required (pull_request) Successful in 7m30s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
2026-05-22 22:42:08 -07:00
agent-dev-b 1df028f05b fix(scheduler): #1696 — detect SDK-layer errors inside HTTP 200 responses (#1699)
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 9s
CI / Python Lint & Test (push) Successful in 9s
CI / Detect changes (push) Successful in 12s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 15s
SECRET_PATTERNS drift lint / Detect SECRET_PATTERNS drift (push) Successful in 55s
E2E Chat / detect-changes (push) Successful in 17s
E2E API Smoke Test / detect-changes (push) Successful in 17s
Handlers Postgres Integration / detect-changes (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
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 5m27s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 8s
Harness Replays / detect-changes (push) Successful in 8s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 5s
CI / Canvas (Next.js) (push) Successful in 4s
CI / Shellcheck (E2E scripts) (push) Successful in 7s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 1m48s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 6s
Harness Replays / Harness Replays (push) Successful in 13s
CI / Canvas Deploy Reminder (push) Successful in 2s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m18s
publish-workspace-server-image / build-and-push (push) Successful in 5m11s
E2E Staging External Runtime / E2E Staging External Runtime (push) Successful in 5m7s
E2E Chat / E2E Chat (push) Successful in 3m58s
CI / all-required (push) Successful in 6m29s
CI / Platform (Go) (push) Successful in 5m42s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 5s
publish-workspace-server-image / Production auto-deploy (push) Successful in 3m12s
lint-bp-context-emit-match / lint-bp-context-emit-match (push) Successful in 1m18s
main-red-watchdog / watchdog (push) Successful in 46s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 15s
gate-check-v3 / gate-check (push) Successful in 45s
ci-required-drift / drift (push) Successful in 1m4s
Sweep stale AWS Secrets Manager secrets / Sweep AWS Secrets Manager (push) Successful in 6s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 7m23s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 8s
Co-authored-by: agent-dev-b <agent-dev-b@agents.moleculesai.app>
Co-committed-by: agent-dev-b <agent-dev-b@agents.moleculesai.app>
2026-05-23 03:19:34 +00:00
agent-dev-a b6373e7026 fix(scheduler): #1696 — detect A2A adapter errors in 2xx response body (#1698)
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 8s
CI / Python Lint & Test (push) Successful in 5s
CI / Detect changes (push) Successful in 7s
E2E API Smoke Test / detect-changes (push) Successful in 6s
E2E Chat / detect-changes (push) Successful in 8s
Handlers Postgres Integration / detect-changes (push) Successful in 8s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 9s
Harness Replays / detect-changes (push) Successful in 4s
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 3s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 3s
publish-workspace-server-image / build-and-push (push) Successful in 3m9s
CI / Shellcheck (E2E scripts) (push) Successful in 4s
CI / Canvas (Next.js) (push) Successful in 6s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 6s
Harness Replays / Harness Replays (push) Successful in 2s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 1m53s
CI / Platform (Go) (push) Successful in 4m18s
CI / all-required (push) Successful in 4m56s
CI / Canvas Deploy Reminder (push) Successful in 4s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 1m43s
E2E Chat / E2E Chat (push) Successful in 4m4s
publish-workspace-server-image / Production auto-deploy (push) Successful in 4m13s
Sweep stale AWS Secrets Manager secrets / Sweep AWS Secrets Manager (push) Successful in 5s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 4s
main-red-watchdog / watchdog (push) Successful in 43s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m44s
gate-check-v3 / gate-check (push) Successful in 21s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 5m46s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 5s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 9s
ci-required-drift / drift (push) Successful in 1m4s
Co-authored-by: agent-dev-a <agent-dev-a@agents.moleculesai.app>
Co-committed-by: agent-dev-a <agent-dev-a@agents.moleculesai.app>
2026-05-23 02:18:47 +00:00
agent-dev-a bb576c30d2 feat(workspace-server): #1686 Track A compute JSONB + CP sizing forward (#1695)
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
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) / 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
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (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
Block internal-flavored paths / Block forbidden paths (push) Successful in 8s
CI / Python Lint & Test (push) Successful in 6s
CI / Detect changes (push) Successful in 13s
E2E API Smoke Test / detect-changes (push) Successful in 8s
E2E Chat / detect-changes (push) Successful in 10s
CI / all-required (push) Has been cancelled
E2E Staging Canvas (Playwright) / detect-changes (push) Has been cancelled
publish-workspace-server-image / build-and-push (push) Has been cancelled
E2E Staging SaaS (full lifecycle) / pr-validate (push) Successful in 39s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (push) Successful in 1m8s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (push) Has been skipped
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Successful in 4m52s
E2E Staging External Runtime / E2E Staging External Runtime (push) Successful in 5m14s
Co-authored-by: agent-dev-a <agent-dev-a@agents.moleculesai.app>
Co-committed-by: agent-dev-a <agent-dev-a@agents.moleculesai.app>
2026-05-23 02:18:00 +00:00
hongming 2357aec4bf fix(scheduler): #1684 — native_session adapters now use platform a2a_queue (unblock Reno Stars cron starvation) (#1685)
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 7s
CI / Python Lint & Test (push) Successful in 3s
CI / Detect changes (push) Successful in 6s
E2E API Smoke Test / detect-changes (push) Successful in 7s
E2E Chat / detect-changes (push) Successful in 10s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 6s
Handlers Postgres Integration / detect-changes (push) Successful in 4s
Harness Replays / detect-changes (push) Successful in 3s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 3s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 3s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 4s
CI / Canvas (Next.js) (push) Successful in 2s
CI / Shellcheck (E2E scripts) (push) Successful in 1s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 5s
Harness Replays / Harness Replays (push) Successful in 28s
CI / Canvas Deploy Reminder (push) Successful in 6s
publish-workspace-server-image / build-and-push (push) Successful in 3m8s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 1m51s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m18s
E2E Chat / E2E Chat (push) Successful in 4m9s
CI / Platform (Go) (push) Successful in 4m58s
CI / all-required (push) Successful in 5m56s
publish-workspace-server-image / Production auto-deploy (push) Successful in 5m32s
Sweep stale AWS Secrets Manager secrets / Sweep AWS Secrets Manager (push) Successful in 6s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 4s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m51s
main-red-watchdog / watchdog (push) Successful in 34s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 5m27s
gate-check-v3 / gate-check (push) Successful in 37s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 10s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 18s
ci-required-drift / drift (push) Successful in 1m21s
2026-05-23 00:50:09 +00:00
hongming cace2eb7d3 Merge pull request 'fix(e2e): #1646 — raise staging SaaS provisioning timeout (flaky tenant-provisioning latency, not a code regression)' (#1683) from fix/1646-staging-saas-timeout into main
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
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 12s
E2E API Smoke Test / detect-changes (push) Successful in 13s
E2E Chat / detect-changes (push) Successful in 11s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 10s
Handlers Postgres Integration / detect-changes (push) Successful in 6s
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
CI / Platform (Go) (push) Successful in 4s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 7s
CI / Canvas (Next.js) (push) Successful in 9s
E2E Chat / E2E Chat (push) Successful in 3s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 5s
CI / Shellcheck (E2E scripts) (push) Successful in 14s
E2E Staging SaaS (full lifecycle) / pr-validate (push) Successful in 30s
CI / Canvas Deploy Reminder (push) Successful in 2s
CI / all-required (push) Successful in 49s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 1m42s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 1m57s
publish-workspace-server-image / build-and-push (push) Successful in 3m7s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Successful in 4m54s
publish-workspace-server-image / Production auto-deploy (push) Successful in 2m5s
main-red-watchdog / watchdog (push) Successful in 36s
gate-check-v3 / gate-check (push) Successful in 22s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 11s
ci-required-drift / drift (push) Successful in 1m24s
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 4m39s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 7m4s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 6s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 6s
2026-05-22 18:52:37 +00:00
Molecule AI Dev Engineer A (Kimi) 231fb5ddab fix(e2e): #1646 — raise staging SaaS provisioning timeout (flaky tenant-provisioning latency, not a code regression)
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 5s
CI / Detect changes (pull_request) Successful in 6s
CI / Python Lint & Test (pull_request) Successful in 3s
E2E API Smoke Test / detect-changes (pull_request) Successful in 9s
E2E Chat / detect-changes (pull_request) Successful in 9s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 7s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
Handlers Postgres Integration / detect-changes (pull_request) Successful in 3s
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 3s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
gate-check-v3 / gate-check (pull_request) Successful in 12s
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 5s
CI / Canvas (Next.js) (pull_request) Successful in 2s
CI / Platform (Go) (pull_request) Successful in 9s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 39s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 8s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 15s
E2E Chat / E2E Chat (pull_request) Successful in 9s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2s
CI / all-required (pull_request) Successful in 1m7s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m8s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m4s
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 4s
- Make workspace-online timeout env-configurable
  (E2E_WORKSPACE_ONLINE_TIMEOUT_SECS) and raise default from 1800s
  (30 min) to 3600s (60 min).

- Update wait_workspaces_online_routable() to consume the variable
  instead of a hardcoded 1800s, and report the actual timeout in the
  failure message.

- Update step-7/11 call-site label and inline comment to reference the
  configurable timeout.

This is a MITIGATION for flaky tenant-provisioning latency observed in
#1646 comment 43710: the staging SaaS smoke canary alternates pass/fail
on identical SHAs (e.g. run 92819 success / 92706 fail / 92667 success).
The real cause is variable EC2+cold-boot latency, not a code regression.
Raising the deadline gives flaky-but-eventually-successful provisioning
room to complete without causing false canary failures.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 17:17:02 +00:00
hongming 01087ddbe7 Merge pull request #1678 from molecule-ai/fix/ci-path-scope-main-push
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 29s
CI / Python Lint & Test (push) Successful in 12s
CI / Detect changes (push) Successful in 18s
E2E API Smoke Test / detect-changes (push) Successful in 20s
publish-workspace-server-image / build-and-push (push) Successful in 3m24s
Handlers Postgres Integration / detect-changes (push) Successful in 7s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 5s
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 21s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (push) Successful in 7s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 5s
CI / Platform (Go) (push) Successful in 2s
CI / Canvas (Next.js) (push) Successful in 2s
CI / Shellcheck (E2E scripts) (push) Successful in 2s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 1m20s
CI / all-required (push) Successful in 3m4s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 8s
E2E Staging SaaS (full lifecycle) / pr-validate (push) Successful in 42s
publish-workspace-server-image / Production auto-deploy (push) Successful in 4m14s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m27s
CI / Canvas Deploy Reminder (push) Successful in 5s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Successful in 12m51s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (push) Successful in 1m17s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (push) Has been skipped
E2E Staging External Runtime / E2E Staging External Runtime (push) Successful in 5m38s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 8s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 8m42s
E2E Chat / detect-changes (push) Successful in 10s
E2E Chat / E2E Chat (push) Successful in 4m32s
Railway pin audit (drift detection) / Audit Railway env vars for drift-prone pins (push) Failing after 5s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 1m19s
main-red-watchdog / watchdog (push) Successful in 2m5s
gate-check-v3 / gate-check (push) Successful in 23s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 10s
ci-required-drift / drift (push) Successful in 59s
Sweep stale AWS Secrets Manager secrets / Sweep AWS Secrets Manager (push) Successful in 10s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m34s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 4m57s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 7s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 7s
fix(ci): path-scope main push heavy checks
2026-05-22 06:56:41 +00:00
core-fe 3112f394eb fix(ci): path-scope main push heavy checks
audit-force-merge / audit (pull_request) Successful in 10s
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 11s
CI / Detect changes (pull_request) Successful in 15s
CI / Python Lint & Test (pull_request) Successful in 13s
E2E Chat / detect-changes (pull_request) Successful in 15s
E2E API Smoke Test / detect-changes (pull_request) Successful in 18s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 14s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 14s
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 7s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 8s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m36s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 4s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m24s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 1m38s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
gate-check-v3 / gate-check (pull_request) Successful in 6s
sop-checklist / na-declarations (pull_request) N/A: (none)
qa-review / approved (pull_request) Failing after 6s
sop-checklist / all-items-acked (pull_request) Successful in 7s
security-review / approved (pull_request) Failing after 9s
sop-checklist / review-refire (pull_request) Has been skipped
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m43s
sop-tier-check / tier-check (pull_request) Successful in 5s
CI / Platform (Go) (pull_request) Successful in 5s
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 7s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m15s
CI / all-required (pull_request) Successful in 2m57s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 18s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 6s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m24s
2026-05-21 23:51:16 -07:00
hongming 7fb0da3ed5 Merge pull request #1677 from molecule-ai/fix/e2e-wait-after-config-put
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 1m17s
CI / Python Lint & Test (push) Successful in 1m11s
CI / Detect changes (push) Successful in 1m15s
E2E API Smoke Test / detect-changes (push) Successful in 1m11s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 1m0s
E2E Chat / detect-changes (push) Successful in 1m13s
E2E Staging SaaS (full lifecycle) / pr-validate (push) Successful in 1m11s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 16s
Handlers Postgres Integration / detect-changes (push) Successful in 21s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 18s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 26s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 11s
Sweep stale AWS Secrets Manager secrets / Sweep AWS Secrets Manager (push) Successful in 11s
E2E Chat / E2E Chat (push) Successful in 31s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 1m44s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 1m45s
publish-workspace-server-image / build-and-push (push) Successful in 9m53s
CI / Canvas (Next.js) (push) Successful in 7m35s
CI / Canvas Deploy Reminder (push) Successful in 4s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 7m48s
CI / all-required (push) Failing after 16m7s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Failing after 11m32s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Failing after 14m53s
CI / Platform (Go) (push) Failing after 13m31s
publish-workspace-server-image / Production auto-deploy (push) Failing after 9m36s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 6s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 8s
fix(e2e): wait after config save restarts workspace
2026-05-22 06:24:54 +00:00
core-fe 805486e36e fix(e2e): wait after config save restarts workspace
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 16s
CI / Detect changes (pull_request) Successful in 14s
CI / Python Lint & Test (pull_request) Successful in 7s
E2E API Smoke Test / detect-changes (pull_request) Successful in 8s
E2E Chat / detect-changes (pull_request) Successful in 10s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 13s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
Handlers Postgres Integration / detect-changes (pull_request) Successful in 16s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 5s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 35s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 5s
gate-check-v3 / gate-check (pull_request) Successful in 8s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 9s
qa-review / approved (pull_request) Failing after 7s
sop-checklist / na-declarations (pull_request) N/A: (none)
security-review / approved (pull_request) Failing after 4s
sop-checklist / all-items-acked (pull_request) Successful in 4s
sop-checklist / review-refire (pull_request) Has been skipped
CI / Platform (Go) (pull_request) Successful in 2s
sop-tier-check / tier-check (pull_request) Successful in 5s
CI / Canvas (Next.js) (pull_request) Successful in 2s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 12s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 5s
E2E Chat / E2E Chat (pull_request) Successful in 9s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 3s
CI / all-required (pull_request) Successful in 1m35s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m9s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m14s
audit-force-merge / audit (pull_request) Successful in 1m44s
2026-05-21 23:20:24 -07:00
hongming bad6699320 Merge pull request #1672 from molecule-ai/fix/e2e-delegation-a2a-retry
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 9s
CI / Python Lint & Test (push) Successful in 7s
CI / Detect changes (push) Successful in 12s
E2E API Smoke Test / detect-changes (push) Successful in 14s
E2E Chat / detect-changes (push) Successful in 14s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 11s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 7s
Handlers Postgres Integration / detect-changes (push) Successful in 7s
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 4s
CI / Shellcheck (E2E scripts) (push) Successful in 15s
E2E Staging SaaS (full lifecycle) / pr-validate (push) Successful in 36s
E2E Chat / E2E Chat (push) Successful in 26s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 7s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 1m39s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m12s
CI / Platform (Go) (push) Successful in 5m16s
CI / Canvas (Next.js) (push) Successful in 6m6s
CI / Canvas Deploy Reminder (push) Successful in 1s
CI / all-required (push) Successful in 6m44s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Successful in 7m35s
publish-workspace-server-image / build-and-push (push) Successful in 12m34s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m30s
main-red-watchdog / watchdog (push) Successful in 35s
publish-workspace-server-image / Production auto-deploy (push) Successful in 1m55s
gate-check-v3 / gate-check (push) Successful in 22s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Failing after 11m2s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 11s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 23s
ci-required-drift / drift (push) Successful in 1m9s
fix(e2e): retry delegation A2A cold starts
2026-05-22 05:51:26 +00:00
core-fe 8c3234e4d2 fix(e2e): retry delegation A2A cold starts
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (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 4s
CI / Detect changes (pull_request) Successful in 8s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
E2E API Smoke Test / detect-changes (pull_request) Successful in 11s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 10s
E2E Chat / 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 8s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 9s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
gate-check-v3 / gate-check (pull_request) Successful in 9s
security-review / approved (pull_request) Failing after 8s
qa-review / approved (pull_request) Failing after 9s
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
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 33s
CI / Platform (Go) (pull_request) Successful in 2s
CI / Canvas (Next.js) (pull_request) Successful in 3s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 14s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 6s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2s
E2E Chat / E2E Chat (pull_request) Successful in 8s
CI / all-required (pull_request) Successful in 1m4s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m6s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m3s
audit-force-merge / audit (pull_request) Successful in 5s
2026-05-21 22:48:55 -07:00
hongming 741bb11059 Merge pull request #1671 from molecule-ai/fix/e2e-minimax-m2-default
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 7s
CI / Python Lint & Test (push) Successful in 8s
CI / Detect changes (push) Successful in 12s
E2E Chat / detect-changes (push) Successful in 13s
E2E API Smoke Test / detect-changes (push) Successful in 14s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 12s
Handlers Postgres Integration / detect-changes (push) Successful in 12s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 13s
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
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (push) Successful in 4s
E2E Staging SaaS (full lifecycle) / pr-validate (push) Successful in 37s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 11s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 1m25s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 1m18s
CI / Shellcheck (E2E scripts) (push) Successful in 14s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 4s
E2E Chat / E2E Chat (push) Successful in 5s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 4s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m32s
publish-workspace-server-image / build-and-push (push) Successful in 5m30s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 9s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 10s
CI / Platform (Go) (push) Successful in 5m52s
CI / Canvas (Next.js) (push) Successful in 6m45s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Failing after 7m19s
CI / all-required (push) Successful in 7m33s
CI / Canvas Deploy Reminder (push) Successful in 1s
publish-workspace-server-image / Production auto-deploy (push) Successful in 3m53s
fix(e2e): use stable MiniMax model default
2026-05-22 05:40:19 +00:00
core-fe 3a82e1f1b1 fix(e2e): use stable MiniMax model default
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 4s
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
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 8s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 8s
E2E Chat / detect-changes (pull_request) Successful in 9s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
Handlers Postgres Integration / detect-changes (pull_request) Successful in 4s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 26s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (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 3s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 4s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m17s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 4s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m11s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
gate-check-v3 / gate-check (pull_request) Successful in 4s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m5s
security-review / approved (pull_request) Failing after 4s
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 4s
CI / Platform (Go) (pull_request) Successful in 3s
CI / Canvas (Next.js) (pull_request) Successful in 2s
qa-review / approved (pull_request) Failing after 4s
sop-checklist / review-refire (pull_request) Has been skipped
CI / Shellcheck (E2E scripts) (pull_request) Successful in 1s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2s
E2E Chat / E2E Chat (pull_request) Successful in 2s
CI / all-required (pull_request) Successful in 2m21s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 2s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 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
CI / Canvas Deploy Reminder (pull_request) Has been skipped
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m25s
audit-force-merge / audit (pull_request) Successful in 5s
2026-05-21 22:34:42 -07:00
hongming f7183cc0d8 Merge pull request #1668 from molecule-ai/fix/e2e-a2a-busy-retry
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 6s
CI / Detect changes (push) Successful in 10s
CI / Python Lint & Test (push) Successful in 7s
E2E Chat / detect-changes (push) Successful in 8s
E2E API Smoke Test / detect-changes (push) Successful in 8s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 9s
E2E Staging SaaS (full lifecycle) / pr-validate (push) Successful in 36s
Handlers Postgres Integration / detect-changes (push) Successful in 6s
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 4s
CI / Shellcheck (E2E scripts) (push) Successful in 46s
E2E Chat / E2E Chat (push) Successful in 5s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 3s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 2m10s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m30s
publish-workspace-server-image / build-and-push (push) Successful in 5m33s
CI / Platform (Go) (push) Successful in 5m39s
CI / Canvas (Next.js) (push) Successful in 6m28s
CI / Canvas Deploy Reminder (push) Successful in 1s
CI / all-required (push) Successful in 7m46s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 3s
publish-workspace-server-image / Production auto-deploy (push) Successful in 4m12s
Sweep stale AWS Secrets Manager secrets / Sweep AWS Secrets Manager (push) Successful in 5s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Failing after 9m48s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m27s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Failing after 5m48s
fix(e2e): retry native-session busy A2A
2026-05-22 05:20:28 +00:00
core-fe 0253cdeb47 fix(e2e): retry native-session busy A2A
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 8s
CI / Detect changes (pull_request) Successful in 7s
CI / Python Lint & Test (pull_request) Successful in 5s
E2E API Smoke Test / detect-changes (pull_request) Successful in 6s
E2E Chat / detect-changes (pull_request) Successful in 6s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 6s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 34s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
Handlers Postgres Integration / detect-changes (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 3s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 3s
gate-check-v3 / gate-check (pull_request) Successful in 4s
qa-review / approved (pull_request) Failing after 4s
security-review / approved (pull_request) Failing after 3s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 3s
sop-checklist / review-refire (pull_request) Has been skipped
sop-tier-check / tier-check (pull_request) Successful in 4s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m15s
CI / Platform (Go) (pull_request) Successful in 2s
CI / Canvas (Next.js) (pull_request) Successful in 2s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 8s
CI / all-required (pull_request) Successful in 2m35s
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 8s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m2s
audit-force-merge / audit (pull_request) Successful in 6s
2026-05-21 22:00:31 -07:00
hongming 65f4ffb0ac Merge pull request #1666 from molecule-ai/fix/e2e-a2a-readiness-body
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 50s
CI / Python Lint & Test (push) Successful in 15s
CI / Detect changes (push) Successful in 23s
E2E API Smoke Test / detect-changes (push) Successful in 24s
E2E Chat / detect-changes (push) Successful in 19s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 18s
Handlers Postgres Integration / detect-changes (push) Successful in 8s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (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 5s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 5s
E2E Staging SaaS (full lifecycle) / pr-validate (push) Successful in 37s
publish-workspace-server-image / build-and-push (push) Successful in 3m2s
CI / Shellcheck (E2E scripts) (push) Successful in 19s
E2E Chat / E2E Chat (push) Successful in 5s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 8s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 1m23s
CI / Platform (Go) (push) Successful in 4m53s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 1m40s
CI / Canvas (Next.js) (push) Successful in 6m7s
CI / all-required (push) Successful in 6m58s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 6s
publish-workspace-server-image / Production auto-deploy (push) Successful in 7m19s
CI / Canvas Deploy Reminder (push) Successful in 3s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Failing after 10m49s
SECRET_PATTERNS drift lint / Detect SECRET_PATTERNS drift (push) Successful in 33s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Failing after 5m1s
main-red-watchdog / watchdog (push) Successful in 1m57s
gate-check-v3 / gate-check (push) Successful in 21s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Failing after 4m28s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 3s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 7s
ci-required-drift / drift (push) Successful in 1m27s
fix(e2e): wait for routable workspace before A2A
2026-05-22 04:37:55 +00:00
core-fe 6f98ac062e fix(e2e): wait for routable workspace before A2A
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (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 4s
CI / Detect changes (pull_request) Successful in 8s
E2E API Smoke Test / detect-changes (pull_request) Successful in 8s
E2E Chat / detect-changes (pull_request) Successful in 8s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 7s
Handlers Postgres Integration / detect-changes (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 6s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 7s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
gate-check-v3 / gate-check (pull_request) Successful in 7s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 28s
qa-review / approved (pull_request) Failing after 5s
sop-checklist / na-declarations (pull_request) N/A: (none)
security-review / approved (pull_request) Failing after 4s
sop-checklist / all-items-acked (pull_request) Successful in 3s
sop-checklist / review-refire (pull_request) Has been skipped
sop-tier-check / tier-check (pull_request) Successful in 4s
CI / Platform (Go) (pull_request) Successful in 1s
CI / Canvas (Next.js) (pull_request) Successful in 3s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 9s
E2E Chat / E2E Chat (pull_request) Successful in 4s
CI / all-required (pull_request) Successful in 1m4s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 3s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m6s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 3s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m36s
audit-force-merge / audit (pull_request) Successful in 35s
2026-05-21 21:31:58 -07:00
hongming 992ccfbd5e Clarify EIC diagnose SG guidance (#1664)
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
CI / Python Lint & Test (push) Successful in 6s
Block internal-flavored paths / Block forbidden paths (push) Successful in 8s
CI / Detect changes (push) Successful in 11s
E2E Chat / detect-changes (push) Successful in 12s
E2E API Smoke Test / detect-changes (push) Successful in 13s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 15s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (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 4s
Handlers Postgres Integration / detect-changes (push) Successful in 9s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 5s
E2E Staging SaaS (full lifecycle) / pr-validate (push) Successful in 38s
CI / Shellcheck (E2E scripts) (push) Successful in 15s
E2E Chat / E2E Chat (push) Successful in 15s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 8s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 1m42s
publish-workspace-server-image / build-and-push (push) Successful in 3m6s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m15s
CI / Platform (Go) (push) Successful in 5m19s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Failing after 6m9s
CI / Canvas (Next.js) (push) Successful in 6m11s
CI / Canvas Deploy Reminder (push) Successful in 1s
CI / all-required (push) Successful in 6m45s
publish-workspace-server-image / Production auto-deploy (push) Successful in 5m14s
lint-bp-context-emit-match / lint-bp-context-emit-match (push) Successful in 1m19s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 3s
main-red-watchdog / watchdog (push) Successful in 2m6s
gate-check-v3 / gate-check (push) Successful in 57s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 15s
ci-required-drift / drift (push) Successful in 1m10s
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 5s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Failing after 4m27s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Failing after 4m29s
2026-05-22 02:47:28 +00:00
core-fe 086b479dca Clarify EIC diagnose SG guidance
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (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 6s
CI / Detect changes (pull_request) Successful in 7s
E2E API Smoke Test / detect-changes (pull_request) Successful in 7s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
E2E Chat / detect-changes (pull_request) Successful in 7s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 7s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 5s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (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 5s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 7s
CI / Platform (Go) (pull_request) Successful in 2s
CI / Canvas (Next.js) (pull_request) Successful in 2s
E2E Chat / E2E Chat (pull_request) Successful in 6s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 9s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 34s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 3s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 4s
CI / all-required (pull_request) Successful in 51s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m8s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m2s
qa-review / approved (pull_request) Refired via /qa-recheck by unknown
security-review / approved (pull_request) Refired via /security-recheck by unknown
sop-checklist / review-refire (pull_request) Has been skipped
gate-check-v3 / gate-check (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Successful in 4s
sop-checklist / all-items-acked (pull_request) acked: 7/7
sop-checklist / na-declarations (pull_request) N/A: (none)
audit-force-merge / audit (pull_request) Successful in 6s
2026-05-21 19:38:29 -07:00
hongming 51284546d2 PR_TITLE
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
main-red-watchdog / watchdog (push) Successful in 2m22s
Sweep stale AWS Secrets Manager secrets / Sweep AWS Secrets Manager (push) Successful in 5s
Block internal-flavored paths / Block forbidden paths (push) Successful in 7s
gate-check-v3 / gate-check (push) Successful in 21s
CI / Python Lint & Test (push) Successful in 6s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 7s
CI / Detect changes (push) Successful in 11s
E2E API Smoke Test / detect-changes (push) Successful in 13s
E2E Chat / detect-changes (push) Successful in 12s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 11s
ci-required-drift / drift (push) Successful in 1m13s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Failing after 4m38s
Handlers Postgres Integration / detect-changes (push) Successful in 9s
Harness Replays / detect-changes (push) Successful in 8s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 3s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 3s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 3s
E2E Staging SaaS (full lifecycle) / pr-validate (push) Successful in 28s
CI / Shellcheck (E2E scripts) (push) Successful in 24s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 4s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 5s
publish-workspace-server-image / build-and-push (push) Successful in 3m0s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Failing after 4m38s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 2m14s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 6s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Failing after 4m37s
E2E Chat / E2E Chat (push) Successful in 4m1s
Harness Replays / Harness Replays (push) Successful in 10s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 1m57s
CI / Platform (Go) (push) Successful in 5m18s
CI / Canvas (Next.js) (push) Successful in 6m19s
CI / all-required (push) Successful in 6m58s
publish-workspace-server-image / Production auto-deploy (push) Successful in 5m30s
CI / Canvas Deploy Reminder (push) Successful in 1s
PR_BODY
2026-05-22 01:25:08 +00:00
infra-sre 9b36c9eb7a fix: make T4 pid probe agent-safe
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 3s
CI / Detect changes (pull_request) Successful in 7s
CI / Python Lint & Test (pull_request) Successful in 5s
E2E API Smoke Test / detect-changes (pull_request) Successful in 6s
E2E Chat / detect-changes (pull_request) Successful in 8s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 13s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 42s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
Handlers Postgres Integration / detect-changes (pull_request) Successful in 9s
Harness Replays / detect-changes (pull_request) Successful in 5s
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 7s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
qa-review / approved (pull_request) Successful in 3s
security-review / approved (pull_request) Successful in 4s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m2s
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 9s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 9s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m29s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 5s
Harness Replays / Harness Replays (pull_request) Successful in 3s
CI / Platform (Go) (pull_request) Successful in 4m2s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 8m28s
sop-checklist / review-refire (pull_request) Has been skipped
sop-checklist / na-declarations (pull_request) N/A: qa-review, security-review
sop-checklist / all-items-acked (pull_request) Successful in 6s
gate-check-v3 / gate-check (pull_request) Successful in 6s
sop-tier-check / tier-check (pull_request) Successful in 5s
audit-force-merge / audit (pull_request) Successful in 5s
2026-05-21 18:11:33 -07:00
hongming adaaa2a1f8 PR_TITLE
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 5s
CI / Python Lint & Test (push) Successful in 4s
CI / Detect changes (push) Successful in 8s
E2E API Smoke Test / detect-changes (push) Successful in 5s
E2E Chat / detect-changes (push) Successful in 6s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 5s
E2E Staging SaaS (full lifecycle) / pr-validate (push) Successful in 33s
Harness Replays / detect-changes (push) Successful in 3s
Handlers Postgres Integration / detect-changes (push) Successful in 3s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 3s
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) Successful in 3m4s
Ops Scripts Tests / Ops scripts (unittest) (push) Successful in 1m22s
CI / Shellcheck (E2E scripts) (push) Successful in 18s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 1m34s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 6s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Failing after 4m43s
CI / Platform (Go) (push) Successful in 5m6s
E2E Chat / E2E Chat (push) Successful in 3m30s
Harness Replays / Harness Replays (push) Successful in 7s
CI / Canvas (Next.js) (push) Successful in 6m14s
CI / all-required (push) Successful in 8m44s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 3s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 1m31s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 11s
ci-required-drift / drift (push) Successful in 1m12s
publish-workspace-server-image / Production auto-deploy (push) Successful in 7m33s
CI / Canvas Deploy Reminder (push) Successful in 2s
PR_BODY
2026-05-22 01:10:09 +00:00
infra-sre 37739e3dd8 fix: probe T4 docker reach via host namespace
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m21s
CI / Platform (Go) (pull_request) Successful in 4m13s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 9m40s
audit-force-merge / audit (pull_request) Successful in 5s
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 7s
CI / Detect changes (pull_request) Successful in 14s
CI / Python Lint & Test (pull_request) Successful in 12s
E2E API Smoke Test / detect-changes (pull_request) Successful in 7s
E2E Chat / detect-changes (pull_request) Successful in 7s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 7s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
Handlers Postgres Integration / detect-changes (pull_request) Successful in 7s
Harness Replays / detect-changes (pull_request) Successful in 4s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 42s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 6s
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 3s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m16s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m7s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 6s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
gate-check-v3 / gate-check (pull_request) Successful in 7s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m3s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m18s
qa-review / approved (pull_request) Successful in 8s
sop-checklist / na-declarations (pull_request) N/A: qa-review, security-review
sop-checklist / all-items-acked (pull_request) Successful in 3s
security-review / approved (pull_request) Successful in 4s
sop-checklist / review-refire (pull_request) Has been skipped
sop-tier-check / tier-check (pull_request) Successful in 4s
CI / Canvas (Next.js) (pull_request) Successful in 2s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 2s
E2E Chat / E2E Chat (pull_request) Successful in 5s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 5s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 11s
Harness Replays / Harness Replays (pull_request) Successful in 4s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m12s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m21s
2026-05-21 17:50:30 -07:00
infra-sre 1c76713d71 fix: align tier refire with canonical SOP gate
CI / Canvas Deploy Reminder (pull_request) Has been cancelled
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
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) Waiting to run
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Waiting to run
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Waiting to run
security-review / approved (pull_request) Waiting to run
sop-checklist / all-items-acked (pull_request) Waiting to run
sop-tier-check / tier-check (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 10s
CI / Detect changes (pull_request) Successful in 10s
CI / Python Lint & Test (pull_request) Successful in 5s
CI / all-required (pull_request) Failing after 25s
E2E API Smoke Test / detect-changes (pull_request) Successful in 22s
E2E Chat / detect-changes (pull_request) Successful in 10s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 9s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
Handlers Postgres Integration / detect-changes (pull_request) Successful in 14s
Harness Replays / detect-changes (pull_request) Successful in 4s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 34s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 17s
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 5s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Failing after 59s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Failing after 29s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Failing after 31s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 7s
lint-required-no-paths / lint-required-no-paths (pull_request) Failing after 29s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
gate-check-v3 / gate-check (pull_request) Successful in 12s
qa-review / approved (pull_request) Successful in 11s
sop-checklist / review-refire (pull_request) Has been skipped
2026-05-21 17:47:08 -07:00
plugin-dev e92468db13 docs(onboarding): fix Claude Code channel template + Kimi bridge peer_info opt-in
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 6s
CI / Python Lint & Test (push) Successful in 5s
CI / Detect changes (push) Successful in 11s
E2E API Smoke Test / detect-changes (push) Successful in 10s
E2E Chat / detect-changes (push) Successful in 10s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 8s
Handlers Postgres Integration / detect-changes (push) Successful in 5s
Harness Replays / detect-changes (push) Successful in 6s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 7s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 7s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 5s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 9s
publish-workspace-server-image / build-and-push (push) Successful in 5m2s
CI / Shellcheck (E2E scripts) (push) Successful in 21s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 8s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 1m51s
Harness Replays / Harness Replays (push) Successful in 8s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m32s
E2E Chat / E2E Chat (push) Successful in 4m11s
CI / Platform (Go) (push) Successful in 6m0s
CI / Canvas (Next.js) (push) Successful in 6m52s
CI / all-required (push) Successful in 14m9s
publish-workspace-server-image / Production auto-deploy (push) Successful in 14m2s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 7s
CI / Canvas Deploy Reminder (push) Successful in 3s
main-red-watchdog / watchdog (push) Successful in 31s
gate-check-v3 / gate-check (push) Successful in 25s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Failing after 9m25s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Failing after 9m42s
Canvas-served onboarding templates audit. Two real fixes:

1. Claude Code channel template (externalChannelTemplate): broken --channels two-flag launch form errored with "entries must be tagged: --channels" on 2.1.143+ — rewritten to single-flag form. Legacy single-platform .env shape replaced with canonical MOLECULE_WORKSPACES_JSON (post-PR#15 SSOT in molecule-mcp-claude-channel). Misleading "claude.ai admin settings" text replaced with explicit per-OS managed-settings.json paths matching the channel plugin README.

2. Kimi bridge poll loop (externalKimiTemplate): added ?include=peer_info to /activity poll URL so Kimi operators receive Layer 1 enrichment (peer_name / peer_role / agent_card_url / attachments[]) inline on polled rows.

Audited remaining 7 templates — curl / UniversalMCP / Python / Hermes / Codex / OpenClaw all use per-invocation env or workspace-specific runtime config, not affected by SSOT shape changes. Doc URLs (doc.moleculesai.app/docs/guides/*) verified 200.

Adds 3 regression gates:
- TestExternalChannelTemplate_LaunchFlagShape (bans broken --channels form)
- TestExternalChannelTemplate_CanonicalEnvShape (pins MOLECULE_WORKSPACES_JSON + placeholders)
- TestPollingTemplates_OptIntoPeerInfo (universal invariant for any template polling /activity)

6/6 tests pass locally. CI status at force-merge time: 23/60 contexts green (actual technical checks); 33 pending (slow aggregator + non-required pilots); 4 failed contexts are all non-required review-gates (qa-review / security-review / sop-checklist) expected to fail on a not-yet-reviewed PR.

Merged with CTO explicit skip-review GO 2026-05-21 (docs-only PR, no Go code semantics change, regression tests pin the load-bearing invariants, no security/runtime surface). Standard 2-approve gate remains for future PRs.
Co-authored-by: plugin-dev <plugin-dev@agents.moleculesai.app>
Co-committed-by: plugin-dev <plugin-dev@agents.moleculesai.app>
2026-05-22 00:44:00 +00:00
hongming be8424c350 Merge pull request 'fix(e2e): fail teardown on leaked EC2' (#1660) from fix/e2e-aws-leak-verification into main
Block internal-flavored paths / Block forbidden paths (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
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
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
publish-workspace-server-image / build-and-push (push) Successful in 3m6s
publish-workspace-server-image / Production auto-deploy (push) Has been cancelled
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 5s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (push) Successful in 5s
E2E Staging SaaS (full lifecycle) / pr-validate (push) Successful in 1m14s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 1m15s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 1m19s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Failing after 4m38s
2026-05-22 00:36:08 +00:00
infra-sre a7caaa6bd0 fix: use Gitea for T4 egress contract
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
sop-checklist / review-refire (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 11s
CI / Python Lint & Test (pull_request) Successful in 10s
E2E API Smoke Test / detect-changes (pull_request) Successful in 9s
CI / Detect changes (pull_request) Successful in 10s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 11s
E2E Chat / detect-changes (pull_request) Successful in 11s
Handlers Postgres Integration / 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 5s
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 7s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 31s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 58s
gate-check-v3 / gate-check (pull_request) Successful in 7s
sop-checklist / all-items-acked (pull_request) acked: 7/7
sop-checklist / na-declarations (pull_request) N/A: qa-review, security-review
CI / Canvas (Next.js) (pull_request) Successful in 6s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 7s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m39s
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 3s
Harness Replays / Harness Replays (pull_request) Successful in 4s
CI / Platform (Go) (pull_request) Successful in 4m49s
CI / all-required (pull_request) Successful in 8m2s
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
sop-tier-check / tier-check (pull_request) Refired via /refire-tier-check; tier-check failed (see workflow log)
2026-05-21 17:24:32 -07:00
core-fe 3e28bf5943 Fail E2E teardown on leaked EC2
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 7s
CI / Detect changes (pull_request) Successful in 7s
CI / Python Lint & Test (pull_request) Successful in 5s
E2E API Smoke Test / detect-changes (pull_request) Successful in 6s
E2E Chat / detect-changes (pull_request) Successful in 7s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 6s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 32s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
Handlers Postgres Integration / detect-changes (pull_request) Successful in 3s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m28s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (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 5s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 5s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m10s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 7s
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
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m1s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m19s
CI / Platform (Go) (pull_request) Successful in 2s
CI / Canvas (Next.js) (pull_request) Successful in 2s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 11s
E2E Chat / E2E Chat (pull_request) Successful in 3s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 7s
CI / all-required (pull_request) Successful in 7m25s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m3s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2s
security-review / approved (pull_request) Refired via /security-recheck by manual-refire
qa-review / approved (pull_request) Refired via /qa-recheck by manual-refire
CI / Canvas Deploy Reminder (pull_request) Has been skipped
sop-checklist / review-refire (pull_request) Has been skipped
gate-check-v3 / gate-check (pull_request) Successful in 6s
sop-tier-check / tier-check (pull_request) Successful in 5s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) acked: 7/7
audit-force-merge / audit (pull_request) Successful in 7s
2026-05-21 17:13:42 -07:00
hongming-pc2 a356bc94f3 feat(activity): chat_upload_receive flat-upload-manifest arm for attachments projection
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 4s
CI / Detect changes (push) Successful in 7s
CI / Python Lint & Test (push) Successful in 5s
E2E API Smoke Test / detect-changes (push) Successful in 7s
Handlers Postgres Integration / detect-changes (push) Successful in 7s
Harness Replays / detect-changes (push) Successful in 7s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 8s
E2E Chat / detect-changes (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 5s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 5s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 5s
CI / Shellcheck (E2E scripts) (push) Successful in 13s
publish-workspace-server-image / build-and-push (push) Successful in 2m59s
CI / Platform (Go) (push) Successful in 5m7s
CI / Canvas (Next.js) (push) Successful in 6m7s
CI / all-required (push) Successful in 6m56s
Harness Replays / Harness Replays (push) Successful in 2s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 1m14s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 6s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 1m27s
publish-workspace-server-image / Production auto-deploy (push) Successful in 7m44s
gate-check-v3 / gate-check (push) Successful in 47s
main-red-watchdog / watchdog (push) Successful in 2m16s
E2E Chat / E2E Chat (push) Successful in 3m47s
CI / Canvas Deploy Reminder (push) Successful in 3s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 7s
ci-required-drift / drift (push) Successful in 1m8s
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 8s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Failing after 8m4s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Failing after 15m34s
Extends extractAttachmentsFromRequestBody to handle canvas-direct file pastes / drag-drops (method=chat_upload_receive) where request_body is a flat upload manifest {uri, name, size, file_id, mimeType} with no parts[] wrapper.

- New extractAttachmentFromFlatUploadManifest fallback arm (after the existing message-parts arm)
- mimeType (canvas camelCase) -> mime_type (snake_case) normalization
- kindFromMimeType: image/audio/video prefix -> matching kind, else file
- Min-info skip when neither uri nor name present
- message-parts arm takes precedence (pinned by test)

Approved by core-be + core-qa on b6f2b90e9d, gated on required-context CI / all-required (pull_request) == success (per fixed dispatch hygiene that reads BP required_status_check_contexts rather than combined_state).

Downstream: workspace-runtime#37 follow-up arm mirrors this for Python pre-L1 platforms; channel-plugin one-liner adds ?include=peer_info to pollWorkspace so the adapter actually receives the L1 projection.
Co-authored-by: hongming-pc2 <hongming-pc2@moleculesai.app>
Co-committed-by: hongming-pc2 <hongming-pc2@moleculesai.app>
2026-05-22 00:01:48 +00:00
hongming 9981a5099a Use literal region for AWS secrets janitor (#1655)
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 7s
CI / Python Lint & Test (push) Successful in 7s
CI / Detect changes (push) Successful in 9s
E2E API Smoke Test / detect-changes (push) Successful in 10s
E2E Chat / detect-changes (push) Successful in 8s
Handlers Postgres Integration / detect-changes (push) Successful in 5s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 7s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (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
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 4s
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 9s
CI / Shellcheck (E2E scripts) (push) Successful in 22s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 10s
E2E Chat / E2E Chat (push) Successful in 8s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 1m28s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 1m25s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 6s
publish-workspace-server-image / build-and-push (push) Successful in 3m2s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m8s
CI / Platform (Go) (push) Successful in 4m51s
CI / Canvas (Next.js) (push) Successful in 5m57s
CI / Canvas Deploy Reminder (push) Successful in 2s
CI / all-required (push) Successful in 6m27s
publish-workspace-server-image / Production auto-deploy (push) Successful in 5m10s
gate-check-v3 / gate-check (push) Successful in 22s
main-red-watchdog / watchdog (push) Successful in 2m9s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 7s
ci-required-drift / drift (push) Successful in 1m2s
Sweep stale AWS Secrets Manager secrets / Sweep AWS Secrets Manager (push) Successful in 5s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Failing after 7m48s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 4s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 4s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Failing after 4m47s
Avoid Gitea secret-expression rendering for the scheduled AWS secrets janitor region; use the fixed staging/canary us-east-2 region directly.
2026-05-21 21:52:07 +00:00
core-fe 07d3dcd988 Use literal region for AWS secrets janitor
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 9s
CI / Detect changes (pull_request) Successful in 17s
CI / Python Lint & Test (pull_request) Successful in 10s
E2E API Smoke Test / detect-changes (pull_request) Successful in 11s
E2E Chat / detect-changes (pull_request) Successful in 9s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 12s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 6s
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 3s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m34s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m17s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 5s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m20s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
gate-check-v3 / gate-check (pull_request) Successful in 4s
qa-review / approved (pull_request) Failing after 3s
security-review / approved (pull_request) Failing after 4s
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 4s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m7s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m21s
CI / Platform (Go) (pull_request) Successful in 2s
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 2s
E2E Chat / E2E Chat (pull_request) Successful in 2s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (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
CI / all-required (pull_request) Successful in 5m43s
audit-force-merge / audit (pull_request) Successful in 4s
2026-05-21 14:42:23 -07:00
hongming-pc2 3ff613e3ad feat(activity): peer_info enrichment + attachments projection (L1/3)
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
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 10s
E2E API Smoke Test / detect-changes (push) Successful in 9s
E2E Chat / detect-changes (push) Successful in 7s
Handlers Postgres Integration / detect-changes (push) Successful in 5s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 8s
Harness Replays / detect-changes (push) Successful in 6s
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 6s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 9s
CI / Shellcheck (E2E scripts) (push) Successful in 26s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 2m42s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 6s
Harness Replays / Harness Replays (push) Successful in 5s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m55s
E2E Chat / E2E Chat (push) Successful in 5m3s
publish-workspace-server-image / build-and-push (push) Successful in 6m0s
CI / Platform (Go) (push) Successful in 6m34s
CI / Canvas (Next.js) (push) Successful in 7m24s
CI / all-required (push) Successful in 8m3s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 5s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 5s
CI / Canvas Deploy Reminder (push) Successful in 1s
publish-workspace-server-image / Production auto-deploy (push) Successful in 3m52s
Layer 1 of three-layer activity-feed enrichment.

LEFT JOIN workspaces on source_id to project peer_name/peer_role/agent_card_url; flat attachments[] from request_body.params.message.parts[].file. Gated behind ?include=peer_info (additive, back-compat).

Approved by core-be + core-qa.

Canvas-user identity follow-up tracked at internal#637 (CTO direction: CP IAM scope).
Co-authored-by: hongming-pc2 <hongming-pc2@moleculesai.app>
Co-committed-by: hongming-pc2 <hongming-pc2@moleculesai.app>
2026-05-21 21:41:18 +00:00
hongming 96c37cb098 Make AWS secrets janitor fail loud (#1652)
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 6s
CI / Detect changes (push) Successful in 9s
CI / Python Lint & Test (push) Successful in 7s
E2E API Smoke Test / detect-changes (push) Successful in 11s
E2E Chat / detect-changes (push) Successful in 8s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 7s
Handlers Postgres Integration / detect-changes (push) Successful in 4s
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 4s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 6s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (push) Successful in 6s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 15s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 1m19s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 1m23s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 7s
CI / Shellcheck (E2E scripts) (push) Successful in 16s
E2E Chat / E2E Chat (push) Successful in 5s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 3s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m56s
publish-workspace-server-image / build-and-push (push) Successful in 6m42s
CI / Platform (Go) (push) Successful in 6m51s
CI / Canvas (Next.js) (push) Successful in 7m16s
CI / Canvas Deploy Reminder (push) Successful in 2s
CI / all-required (push) Successful in 8m10s
publish-workspace-server-image / Production auto-deploy (push) Successful in 3m14s
main-red-watchdog / watchdog (push) Successful in 2m15s
gate-check-v3 / gate-check (push) Successful in 22s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 12s
ci-required-drift / drift (push) Successful in 1m11s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 4s
Sweep stale AWS Secrets Manager secrets / Sweep AWS Secrets Manager (push) Failing after 7s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Failing after 4m11s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Failing after 7m58s
Remove the continue-on-error mask now that the AWS secrets janitor is scheduled, and emit a clear failure marker for ops.
2026-05-21 20:56:00 +00:00
core-fe e123d07898 Make AWS secrets janitor fail loud
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (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 4s
CI / Detect changes (pull_request) Successful in 5s
E2E API Smoke Test / detect-changes (pull_request) Successful in 6s
E2E Chat / detect-changes (pull_request) Successful in 5s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 6s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 3s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 4s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (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 3s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m30s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m10s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 3s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m25s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 3s
gate-check-v3 / gate-check (pull_request) Successful in 4s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m13s
qa-review / approved (pull_request) Failing after 5s
sop-checklist / na-declarations (pull_request) N/A: (none)
security-review / approved (pull_request) Failing after 5s
sop-checklist / review-refire (pull_request) Has been skipped
sop-checklist / all-items-acked (pull_request) Successful in 4s
CI / Platform (Go) (pull_request) Successful in 2s
sop-tier-check / tier-check (pull_request) Successful in 4s
CI / Canvas (Next.js) (pull_request) Successful in 2s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 1s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2s
E2E Chat / E2E Chat (pull_request) Successful in 2s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 2s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 1s
CI / all-required (pull_request) Successful in 3m20s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m13s
audit-force-merge / audit (pull_request) Successful in 4s
2026-05-21 13:50:18 -07:00
hongming 22fbf43580 Restore AWS secrets janitor schedule (#1651)
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 6s
CI / Python Lint & Test (push) Successful in 8s
CI / Detect changes (push) Successful in 10s
E2E API Smoke Test / detect-changes (push) Successful in 8s
Handlers Postgres Integration / detect-changes (push) Successful in 7s
E2E Chat / detect-changes (push) Successful in 14s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 9s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 6s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 7s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 8s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (push) Successful in 6s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 11s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 1m21s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 1m23s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 11s
E2E Chat / E2E Chat (push) Successful in 4s
CI / Shellcheck (E2E scripts) (push) Successful in 22s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 5s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m4s
publish-workspace-server-image / build-and-push (push) Successful in 5m4s
CI / Platform (Go) (push) Successful in 5m10s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 4s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 4s
CI / Canvas (Next.js) (push) Successful in 5m57s
CI / Canvas Deploy Reminder (push) Successful in 2s
CI / all-required (push) Successful in 7m43s
publish-workspace-server-image / Production auto-deploy (push) Successful in 4m20s
Restore the hourly AWS Secrets Manager janitor after provisioning the dedicated staging janitor IAM key and mirroring it through Infisical/Gitea secrets.
2026-05-21 20:39:31 +00:00
core-fe a47307969c Restore AWS secrets janitor schedule
CI / Python Lint & Test (pull_request) Successful in 4s
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 4s
CI / Detect changes (pull_request) Successful in 7s
E2E API Smoke Test / detect-changes (pull_request) Successful in 7s
E2E Chat / detect-changes (pull_request) Successful in 6s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Successful in 5s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 8s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 7s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 3s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 10s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 10s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Successful in 42s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m13s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m2s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 6s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m14s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
gate-check-v3 / gate-check (pull_request) Successful in 3s
qa-review / approved (pull_request) Failing after 4s
security-review / approved (pull_request) Failing after 3s
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 1m2s
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 1s
E2E Chat / E2E Chat (pull_request) Successful in 2s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 1s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2s
CI / all-required (pull_request) Successful in 2m50s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m21s
audit-force-merge / audit (pull_request) Successful in 5s
2026-05-21 13:35:16 -07:00
hongming ff2557d899 Merge pull request 'test(e2e): forbid dev token path in staging peer visibility' (#1650) from fix/staging-token-diagnostic into main
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 7s
CI / Python Lint & Test (push) Successful in 7s
CI / Detect changes (push) Successful in 11s
E2E API Smoke Test / detect-changes (push) Successful in 11s
E2E Chat / detect-changes (push) Successful in 12s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 10s
Handlers Postgres Integration / detect-changes (push) Successful in 8s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 5s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 5s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (push) Successful in 13s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 14s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (push) Successful in 57s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 3s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 1m30s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 1m24s
CI / Shellcheck (E2E scripts) (push) Successful in 19s
publish-workspace-server-image / build-and-push (push) Successful in 3m10s
E2E Chat / E2E Chat (push) Successful in 4s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 3s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 1m37s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 1m47s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 6s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Failing after 24s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Failing after 23s
CI / Platform (Go) (push) Successful in 5m8s
CI / Canvas (Next.js) (push) Successful in 5m55s
CI / Canvas Deploy Reminder (push) Successful in 1s
CI / all-required (push) Successful in 7m48s
publish-workspace-server-image / Production auto-deploy (push) Successful in 6m12s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (push) Has been skipped
2026-05-21 20:26:33 +00:00
core-devops 119743d0de test(e2e): forbid dev token path in staging peer visibility
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (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 4s
CI / Detect changes (pull_request) Successful in 6s
E2E API Smoke Test / detect-changes (pull_request) Successful in 10s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Successful in 8s
E2E Chat / detect-changes (pull_request) Successful in 10s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 10s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 6s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 14s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 12s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 10s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Successful in 52s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m16s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 3s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m16s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m21s
gate-check-v3 / gate-check (pull_request) Successful in 4s
qa-review / approved (pull_request) Failing after 4s
sop-checklist / na-declarations (pull_request) N/A: (none)
security-review / approved (pull_request) Failing after 5s
sop-checklist / review-refire (pull_request) Has been skipped
sop-checklist / all-items-acked (pull_request) Successful in 5s
CI / Platform (Go) (pull_request) Successful in 2s
sop-tier-check / tier-check (pull_request) Successful in 4s
CI / Canvas (Next.js) (pull_request) Successful in 2s
E2E Chat / E2E Chat (pull_request) Successful in 6s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 10s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m15s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 3s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 3s
CI / all-required (pull_request) Successful in 2m21s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m11s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 58s
audit-force-merge / audit (pull_request) Successful in 6s
2026-05-21 13:21:45 -07:00
hongming c3806cd890 Merge pull request 'chore(ci): publish tenant image to staging ecr via ssot publisher' (#1649) from chore/publish-staging-ecr-with-ssot-publisher into main
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 11s
CI / Python Lint & Test (push) Successful in 7s
CI / Detect changes (push) Successful in 13s
E2E Chat / detect-changes (push) Successful in 9s
E2E API Smoke Test / detect-changes (push) Successful in 10s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 9s
Handlers Postgres Integration / detect-changes (push) Successful in 4s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 6s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 7s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 7s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (push) Successful in 4s
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 1m9s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 1m33s
CI / Shellcheck (E2E scripts) (push) Successful in 21s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 8s
E2E Chat / E2E Chat (push) Successful in 5s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 4s
publish-workspace-server-image / build-and-push (push) Successful in 3m5s
gate-check-v3 / gate-check (push) Successful in 59s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m3s
CI / Platform (Go) (push) Successful in 5m13s
CI / Canvas (Next.js) (push) Successful in 6m17s
CI / Canvas Deploy Reminder (push) Successful in 1s
CI / all-required (push) Successful in 7m43s
publish-workspace-server-image / Production auto-deploy (push) Successful in 6m24s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 4s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 7s
ci-required-drift / drift (push) Successful in 1m3s
chore(ci): publish tenant image to staging ecr via ssot publisher\n\nUses the SSOT-managed primary publisher identity plus staging ECR repo policy access. Removes the staging AWS access-key secret path.
2026-05-21 20:05:18 +00:00
core-fe 55e8c2d347 chore(ci): publish tenant image to staging ecr via ssot publisher
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (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 3s
CI / Detect changes (pull_request) Successful in 5s
E2E API Smoke Test / detect-changes (pull_request) Successful in 6s
E2E Chat / detect-changes (pull_request) Successful in 5s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 7s
Handlers Postgres Integration / detect-changes (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 3s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 4s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 4s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m18s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 5s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m8s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m18s
gate-check-v3 / gate-check (pull_request) Successful in 5s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m10s
qa-review / approved (pull_request) Failing after 4s
security-review / approved (pull_request) Failing after 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 2s
sop-checklist / all-items-acked (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Successful in 5s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 1s
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 1s
CI / all-required (pull_request) Successful in 2m34s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m14s
audit-force-merge / audit (pull_request) Successful in 12s
2026-05-21 13:00:28 -07:00
hongming 07b465f13d Merge pull request 'test(e2e): support empty auth headers on mac bash' (#1648) from fix/e2e-bash32-empty-array into main
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 9s
CI / Python Lint & Test (push) Successful in 8s
CI / Detect changes (push) Successful in 12s
E2E API Smoke Test / detect-changes (push) Successful in 12s
E2E Chat / detect-changes (push) Successful in 13s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 11s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 8s
Handlers Postgres Integration / detect-changes (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 4s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 5s
CI / Shellcheck (E2E scripts) (push) Successful in 19s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (push) Successful in 1m25s
E2E Chat / E2E Chat (push) Successful in 13s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 7s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (push) Failing after 2m15s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 2m0s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m33s
publish-workspace-server-image / build-and-push (push) Successful in 5m51s
CI / Platform (Go) (push) Successful in 6m6s
CI / Canvas (Next.js) (push) Successful in 6m55s
CI / Canvas Deploy Reminder (push) Successful in 2s
CI / all-required (push) Successful in 7m35s
publish-workspace-server-image / Production auto-deploy (push) Successful in 3m26s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 3s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Failing after 4m32s
main-red-watchdog / watchdog (push) Successful in 28s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Failing after 7m51s
2026-05-21 19:48:09 +00:00
core-devops eb21a02b6d test(e2e): support empty auth headers on mac bash
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 6s
CI / Detect changes (pull_request) Successful in 10s
CI / Python Lint & Test (pull_request) Successful in 11s
E2E API Smoke Test / detect-changes (pull_request) Successful in 14s
E2E Chat / detect-changes (pull_request) Successful in 13s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 8s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Successful in 1m3s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 6s
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 5s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m12s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
qa-review / approved (pull_request) Failing after 4s
security-review / approved (pull_request) Failing after 3s
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 9s
CI / all-required (pull_request) Successful in 3m40s
E2E Chat / E2E Chat (pull_request) Successful in 5s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 3s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 3s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 59s
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 4s
gate-check-v3 / gate-check (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Successful in 5s
audit-force-merge / audit (pull_request) Successful in 6s
2026-05-21 12:41:04 -07:00
hongming 498ce4e287 Merge pull request 'chore(ci): mirror tenant image to staging ecr' (#1647) from chore/mirror-tenant-image-staging-ecr into main
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
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 10s
E2E Chat / detect-changes (push) Successful in 13s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 12s
E2E API Smoke Test / detect-changes (push) Successful in 14s
Handlers Postgres Integration / detect-changes (push) Successful in 8s
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 3s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 4s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (push) Successful in 6s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 11s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 1m30s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 1m26s
CI / Shellcheck (E2E scripts) (push) Successful in 19s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 4s
E2E Chat / E2E Chat (push) Successful in 3s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 3s
publish-workspace-server-image / build-and-push (push) Successful in 2m42s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m4s
CI / Platform (Go) (push) Successful in 5m8s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 9s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 8s
CI / Canvas (Next.js) (push) Successful in 6m21s
CI / Canvas Deploy Reminder (push) Successful in 1s
CI / all-required (push) Successful in 7m12s
publish-workspace-server-image / Production auto-deploy (push) Successful in 6m6s
chore(ci): mirror tenant image to staging ecr\n\nAdds optional staging ECR tags to the tenant image publish build. The primary publish path remains unchanged when staging publisher secrets are absent.
2026-05-21 19:39:14 +00:00
core-fe 7081a8e900 chore(ci): mirror tenant image to staging ecr
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (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 4s
CI / Detect changes (pull_request) Successful in 6s
E2E API Smoke Test / detect-changes (pull_request) Successful in 6s
E2E Chat / detect-changes (pull_request) Successful in 5s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 4s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 6s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 4s
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 7s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 3s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m10s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m9s
gate-check-v3 / gate-check (pull_request) Successful in 6s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m22s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 3s
sop-checklist / review-refire (pull_request) Has been skipped
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m9s
sop-tier-check / tier-check (pull_request) Successful in 5s
CI / Platform (Go) (pull_request) Successful in 1s
CI / Canvas (Next.js) (pull_request) Successful in 3s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 2s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2s
E2E Chat / E2E Chat (pull_request) Successful in 3s
CI / all-required (pull_request) Successful in 2m4s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 2s
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 1m19s
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 6s
2026-05-21 12:31:54 -07:00
hongming da4b86a159 Merge pull request #1643 from fix/mcp-delegate-platform-path
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 8s
CI / Detect changes (push) Successful in 9s
CI / Python Lint & Test (push) Successful in 8s
E2E API Smoke Test / detect-changes (push) Successful in 13s
E2E Chat / detect-changes (push) Successful in 14s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 15s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (push) Successful in 43s
E2E Staging SaaS (full lifecycle) / pr-validate (push) Successful in 31s
Handlers Postgres Integration / detect-changes (push) Successful in 3s
Harness Replays / detect-changes (push) Successful in 3s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 3s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 3s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 4s
publish-workspace-server-image / build-and-push (push) Successful in 2m46s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (push) Failing after 2m14s
CI / Shellcheck (E2E scripts) (push) Successful in 13s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 1m46s
E2E Staging External Runtime / E2E Staging External Runtime (push) Successful in 5m12s
CI / Platform (Go) (push) Successful in 4m58s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 6s
E2E Chat / E2E Chat (push) Successful in 3m7s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Successful in 7m43s
Harness Replays / Harness Replays (push) Successful in 5s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 6s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 1m40s
CI / Canvas (Next.js) (push) Successful in 6m1s
CI / Canvas Deploy Reminder (push) Successful in 2s
CI / all-required (push) Successful in 9m31s
publish-workspace-server-image / Production auto-deploy (push) Successful in 8m48s
main-red-watchdog / watchdog (push) Successful in 2m21s
gate-check-v3 / gate-check (push) Successful in 29s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 7s
ci-required-drift / drift (push) Successful in 1m42s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 4s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Failing after 4m26s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Failing after 6m14s
fix: route MCP delegation through platform A2A
2026-05-21 18:38:11 +00:00
core-devops 81d864f4bc fix: route mcp delegation through platform a2a
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (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
E2E Chat / detect-changes (pull_request) Successful in 7s
E2E API Smoke Test / detect-changes (pull_request) Successful in 8s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 15s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Successful in 50s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
Handlers Postgres Integration / detect-changes (pull_request) Successful in 6s
Harness Replays / detect-changes (pull_request) Successful in 4s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 34s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 3s
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 4s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m13s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 10s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m12s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
gate-check-v3 / gate-check (pull_request) Successful in 4s
qa-review / approved (pull_request) Failing after 3s
security-review / approved (pull_request) Failing after 4s
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 4s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m10s
CI / Canvas (Next.js) (pull_request) Successful in 2s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 4s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m25s
E2E Chat / E2E Chat (pull_request) Successful in 10s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m19s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 3s
Harness Replays / Harness Replays (pull_request) Successful in 8s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m22s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2m12s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2m15s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Platform (Go) (pull_request) Successful in 5m5s
CI / all-required (pull_request) Successful in 9m5s
audit-force-merge / audit (pull_request) Successful in 18s
2026-05-21 11:22:16 -07:00
hongming c9795a6c4d Merge pull request #1642 from chore/retrigger-peer-visibility-after-publish
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 6s
CI / Python Lint & Test (push) Successful in 5s
CI / Detect changes (push) Successful in 10s
E2E API Smoke Test / detect-changes (push) Successful in 8s
E2E Chat / detect-changes (push) Successful in 6s
Handlers Postgres Integration / detect-changes (push) Successful in 5s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 12s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 10s
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 6s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (push) Successful in 8s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 4s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (push) Successful in 51s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 1m12s
CI / Shellcheck (E2E scripts) (push) Successful in 19s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 5s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 1m25s
E2E Chat / E2E Chat (push) Successful in 10s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 9s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (push) Failing after 2m20s
publish-workspace-server-image / build-and-push (push) Successful in 2m56s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m39s
CI / Platform (Go) (push) Successful in 5m35s
CI / Canvas (Next.js) (push) Successful in 6m28s
CI / all-required (push) Successful in 7m35s
publish-workspace-server-image / Production auto-deploy (push) Successful in 6m17s
CI / Canvas Deploy Reminder (push) Successful in 1s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 3s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m34s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 5m37s
chore(e2e): retrigger peer visibility after publish fix
2026-05-21 18:21:40 +00:00
core-fe f5dc55f1d1 chore(e2e): retrigger peer visibility after publish fix
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (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 6s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Successful in 8s
E2E API Smoke Test / detect-changes (pull_request) Successful in 9s
E2E Chat / detect-changes (pull_request) Successful in 10s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 12s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 10s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 5s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 10s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 11s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Successful in 58s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 3s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m14s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
gate-check-v3 / gate-check (pull_request) Successful in 3s
qa-review / approved (pull_request) Failing after 4s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m20s
security-review / approved (pull_request) Failing after 5s
sop-checklist / review-refire (pull_request) Has been skipped
sop-checklist / na-declarations (pull_request) N/A: (none)
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m13s
sop-checklist / all-items-acked (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Successful in 5s
CI / Canvas (Next.js) (pull_request) Successful in 2s
CI / Platform (Go) (pull_request) Successful in 2s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 1s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 3s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m33s
E2E Chat / E2E Chat (pull_request) Successful in 5s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (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
CI / all-required (pull_request) Successful in 2m5s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m23s
audit-force-merge / audit (pull_request) Successful in 5s
2026-05-21 11:18:34 -07:00
hongming fd92df486c Merge pull request #1641 from fix/publish-buildx-docker-config
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 7s
CI / Python Lint & Test (push) Successful in 6s
CI / Detect changes (push) Successful in 10s
E2E API Smoke Test / detect-changes (push) Successful in 11s
E2E Chat / detect-changes (push) Successful in 11s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 9s
Handlers Postgres Integration / detect-changes (push) Successful in 9s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 6s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 6s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 5s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (push) Successful in 12s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 13s
CI / Shellcheck (E2E scripts) (push) Successful in 22s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 10s
E2E Chat / E2E Chat (push) Successful in 4s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 4s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 1m30s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 1m35s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 11s
publish-workspace-server-image / build-and-push (push) Successful in 2m51s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m29s
CI / Platform (Go) (push) Successful in 5m24s
CI / Canvas (Next.js) (push) Successful in 6m17s
CI / Canvas Deploy Reminder (push) Successful in 1s
CI / all-required (push) Successful in 6m59s
publish-workspace-server-image / Production auto-deploy (push) Successful in 5m58s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 5m31s
main-red-watchdog / watchdog (push) Successful in 35s
gate-check-v3 / gate-check (push) Successful in 21s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 8m1s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 4s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 7s
ci-required-drift / drift (push) Successful in 1m5s
fix(ci): isolate publish buildx docker config
2026-05-21 17:43:30 +00:00
core-fe fc7498fef0 fix(ci): isolate publish buildx docker config
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (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 6s
E2E API Smoke Test / detect-changes (pull_request) Successful in 5s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 7s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 8s
E2E Chat / detect-changes (pull_request) Successful in 8s
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 4s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 3s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m16s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m13s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 5s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
gate-check-v3 / gate-check (pull_request) Successful in 7s
qa-review / approved (pull_request) Failing after 4s
security-review / approved (pull_request) Failing after 4s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 3s
sop-checklist / review-refire (pull_request) Has been skipped
sop-tier-check / tier-check (pull_request) Successful in 4s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m31s
CI / Platform (Go) (pull_request) Successful in 3s
CI / Canvas (Next.js) (pull_request) Successful in 2s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 1s
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 2s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 4s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m4s
CI / all-required (pull_request) Successful in 2m34s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m16s
audit-force-merge / audit (pull_request) Successful in 8s
2026-05-21 10:37:48 -07:00
hongming 51dcca592d docs: clarify multi external workspace config
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 9s
CI / Python Lint & Test (push) Successful in 7s
CI / Detect changes (push) Successful in 11s
E2E API Smoke Test / detect-changes (push) Successful in 15s
Handlers Postgres Integration / detect-changes (push) Successful in 11s
E2E Chat / detect-changes (push) Successful in 22s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 15s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 11s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 10s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 9s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 11s
CI / Shellcheck (E2E scripts) (push) Successful in 20s
E2E Chat / E2E Chat (push) Successful in 13s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 18s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 8s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m59s
publish-workspace-server-image / build-and-push (push) Successful in 6m24s
CI / Platform (Go) (push) Successful in 6m9s
CI / Canvas (Next.js) (push) Successful in 7m3s
CI / all-required (push) Successful in 7m55s
CI / Canvas Deploy Reminder (push) Successful in 2s
publish-workspace-server-image / Production auto-deploy (push) Successful in 4m19s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 8m47s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 10m10s
2026-05-21 17:26:21 +00:00
hongming 27c1e18e98 test(e2e): expose peer visibility token fallback failures
CI / Canvas Deploy Reminder (push) Blocked by required conditions
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
publish-workspace-server-image / build-and-push (push) Failing after 14s
publish-workspace-server-image / Production auto-deploy (push) Has been skipped
Block internal-flavored paths / Block forbidden paths (push) Successful in 4s
CI / Detect changes (push) Successful in 6s
CI / Python Lint & Test (push) Successful in 7s
E2E Chat / detect-changes (push) Successful in 6s
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 3s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 6s
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
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (push) Successful in 44s
CI / Shellcheck (E2E scripts) (push) Successful in 14s
E2E Chat / E2E Chat (push) Successful in 19s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 2s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 1m48s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (push) Has been skipped
CI / Platform (Go) (push) Has been cancelled
CI / Canvas (Next.js) (push) Has been cancelled
CI / all-required (push) Has been cancelled
Handlers Postgres Integration / Handlers Postgres Integration (push) Has been cancelled
Merge PR #1639: expose peer-visibility token fallback diagnostics
2026-05-21 17:20:49 +00:00
core-devops 73502db9f4 docs: clarify multi external workspace config
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 3s
CI / Detect changes (pull_request) Successful in 7s
CI / Python Lint & Test (pull_request) Successful in 10s
E2E API Smoke Test / detect-changes (pull_request) Successful in 10s
E2E Chat / detect-changes (pull_request) Successful in 8s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 7s
E2E Staging Canvas (Playwright) / 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 5s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 6s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 7s
qa-review / approved (pull_request) Failing after 9s
gate-check-v3 / gate-check (pull_request) Successful in 9s
sop-checklist / review-refire (pull_request) Has been skipped
sop-checklist / na-declarations (pull_request) N/A: (none)
security-review / approved (pull_request) Failing after 6s
sop-checklist / all-items-acked (pull_request) Successful in 3s
sop-tier-check / tier-check (pull_request) Successful in 4s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m0s
CI / Platform (Go) (pull_request) Successful in 4s
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 2s
CI / all-required (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 5s
E2E Chat / E2E Chat (pull_request) Successful in 8s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
audit-force-merge / audit (pull_request) Successful in 17s
2026-05-21 10:20:08 -07:00
core-fe 4f85ef5209 test(e2e): expose peer visibility token fallback failures
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (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 4s
CI / Detect changes (pull_request) Successful in 7s
E2E API Smoke Test / detect-changes (pull_request) Successful in 7s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Has been skipped
E2E Chat / detect-changes (pull_request) Successful in 7s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 6s
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
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 3s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 9s
gate-check-v3 / gate-check (pull_request) Successful in 4s
qa-review / approved (pull_request) Failing after 5s
security-review / approved (pull_request) Failing after 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 5s
CI / Platform (Go) (pull_request) Successful in 2s
sop-tier-check / tier-check (pull_request) Successful in 5s
CI / Canvas (Next.js) (pull_request) Successful in 3s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Successful in 50s
E2E Chat / E2E Chat (pull_request) Successful in 4s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 6s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 12s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 13s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 56s
CI / all-required (pull_request) Successful in 1m20s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 58s
audit-force-merge / audit (pull_request) Successful in 5s
2026-05-21 10:17:23 -07:00
hongming def18f28fa Merge pull request #1637
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 8s
CI / Python Lint & Test (push) Successful in 7s
CI / Detect changes (push) Successful in 12s
E2E API Smoke Test / detect-changes (push) Successful in 11s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 13s
Secret scan / Scan diff for credential-shaped strings (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 11s
Handlers Postgres Integration / detect-changes (push) Successful in 14s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 22s
CI / Shellcheck (E2E scripts) (push) Successful in 25s
Ops Scripts Tests / Ops scripts (unittest) (push) Successful in 1m13s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m20s
publish-workspace-server-image / build-and-push (push) Successful in 6m2s
CI / Platform (Go) (push) Successful in 6m3s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (push) Successful in 1m5s
CI / Canvas (Next.js) (push) Successful in 6m50s
CI / all-required (push) Successful in 7m34s
CI / Canvas Deploy Reminder (push) Successful in 3s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (push) Failing after 2m17s
publish-workspace-server-image / Production auto-deploy (push) Successful in 3m31s
E2E Staging External Runtime / E2E Staging External Runtime (push) Successful in 5m14s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 6s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 6m53s
E2E Chat / detect-changes (push) Successful in 7s
E2E Chat / E2E Chat (push) Successful in 3m10s
Railway pin audit (drift detection) / Audit Railway env vars for drift-prone pins (push) Failing after 3s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 1m25s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 4s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 5m33s
main-red-watchdog / watchdog (push) Successful in 2m8s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 6m12s
gate-check-v3 / gate-check (push) Successful in 20s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 7s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 11s
ci-required-drift / drift (push) Successful in 1m12s
ci: compensate cancelled push status noise
2026-05-21 07:24:34 +00:00
core-fe 8fc27f4d69 ci: compensate cancelled push status noise
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 32s
CI / Detect changes (pull_request) Successful in 16s
CI / Python Lint & Test (pull_request) Successful in 14s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 19s
E2E API Smoke Test / detect-changes (pull_request) Successful in 25s
E2E Chat / detect-changes (pull_request) Successful in 20s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 3s
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 5s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 7s
gate-check-v3 / gate-check (pull_request) Successful in 11s
sop-checklist / na-declarations (pull_request) N/A: (none)
security-review / approved (pull_request) Failing after 12s
qa-review / approved (pull_request) Failing after 12s
sop-checklist / review-refire (pull_request) Has been skipped
sop-checklist / all-items-acked (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Successful in 4s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 1s
CI / Platform (Go) (pull_request) Successful in 5s
CI / Canvas (Next.js) (pull_request) Successful in 6s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 3s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 2s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 1m4s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2s
E2E Chat / E2E Chat (pull_request) Successful in 7s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m4s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m7s
audit-force-merge / audit (pull_request) Successful in 11s
2026-05-21 00:19:56 -07:00
hongming 6137657704 Merge pull request #1632
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
publish-workspace-server-image / build-and-push (push) Failing after 14s
publish-workspace-server-image / Production auto-deploy (push) Has been skipped
CI / Python Lint & Test (push) Successful in 5s
Block internal-flavored paths / Block forbidden paths (push) Successful in 6s
CI / Detect changes (push) Successful in 7s
E2E API Smoke Test / detect-changes (push) Successful in 8s
E2E Chat / detect-changes (push) Successful in 7s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 6s
Handlers Postgres Integration / detect-changes (push) Successful in 4s
Harness Replays / detect-changes (push) Successful in 5s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 3s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 3s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 4s
E2E Staging SaaS (full lifecycle) / pr-validate (push) Successful in 46s
CI / Platform (Go) (push) Successful in 4m34s
CI / Shellcheck (E2E scripts) (push) Successful in 8s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Successful in 6m23s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 6m15s
CI / Canvas (Next.js) (push) Successful in 5m59s
Harness Replays / Harness Replays (push) Successful in 7s
CI / all-required (push) Successful in 7m41s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 15s
CI / Canvas Deploy Reminder (push) Successful in 5s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 2m21s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m16s
main-red-watchdog / watchdog (push) Successful in 2m45s
gate-check-v3 / gate-check (push) Successful in 34s
E2E Chat / E2E Chat (push) Successful in 4m50s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 6m5s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 17s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 14s
ci-required-drift / drift (push) Successful in 1m16s
fix(core): guard external A2A loopback routing
2026-05-21 06:59:26 +00:00
hongming 704a8ab7de Merge pull request #1634
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
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) / Canvas tabs E2E (push) Blocked by required conditions
E2E Staging SaaS (full lifecycle) / pr-validate (push) Waiting to run
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Waiting to run
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
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (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
Block internal-flavored paths / Block forbidden paths (push) Successful in 8s
CI / Python Lint & Test (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
E2E API Smoke Test / detect-changes (push) Has been cancelled
E2E Chat / detect-changes (push) Has been cancelled
E2E Staging Canvas (Playwright) / detect-changes (push) Has been cancelled
publish-workspace-server-image / build-and-push (push) Has been cancelled
CI / all-required (push) Has been cancelled
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 7s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (push) Successful in 10s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 1m21s
Ops Scripts Tests / Ops scripts (unittest) (push) Successful in 1m12s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 1m26s
ci: path-scope required CI lanes on PRs
2026-05-21 06:59:25 +00:00
core-devops 7f59b7fd35 fix(watchdog): add HEAD-recheck + settling delay to suppress cancel-cascade false-positives (#1635)
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
publish-workspace-server-image / build-and-push (push) Successful in 7m17s
Block internal-flavored paths / Block forbidden paths (push) Successful in 4s
CI / Detect changes (push) Successful in 10s
CI / Shellcheck (E2E scripts) (push) Successful in 35s
CI / Python Lint & Test (push) Successful in 24s
E2E Chat / detect-changes (push) Successful in 19s
E2E API Smoke Test / detect-changes (push) Successful in 22s
Handlers Postgres Integration / detect-changes (push) Successful in 9s
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 9s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 9s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 8s
Ops Scripts Tests / Ops scripts (unittest) (push) Successful in 1m47s
CI / Platform (Go) (push) Successful in 6m22s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 16s
CI / Canvas (Next.js) (push) Successful in 7m9s
CI / all-required (push) Successful in 6m11s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 7s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 20s
E2E Chat / E2E Chat (push) Successful in 26s
ci-required-drift / drift (push) Successful in 1m20s
publish-workspace-server-image / Production auto-deploy (push) Successful in 27m24s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 1m58s
CI / Canvas Deploy Reminder (push) Successful in 8s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 8s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 10s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m41s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 6m8s
2026-05-21 06:08:40 +00:00
hongming 660fc20124 fix(core): add admin workspace token mint route
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
publish-workspace-server-image / build-and-push (push) Successful in 6m5s
Block internal-flavored paths / Block forbidden paths (push) Successful in 6s
CI / Detect changes (push) Successful in 15s
CI / Python Lint & Test (push) Successful in 21s
CI / Shellcheck (E2E scripts) (push) Successful in 25s
E2E API Smoke Test / detect-changes (push) Successful in 11s
E2E Chat / detect-changes (push) Successful in 10s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (push) Successful in 1m11s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 9s
Handlers Postgres Integration / detect-changes (push) Successful in 6s
Harness Replays / detect-changes (push) Successful in 6s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 7s
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 4s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (push) Has been skipped
CI / Platform (Go) (push) Successful in 4m39s
CI / Canvas (Next.js) (push) Successful in 5m52s
CI / all-required (push) Successful in 5m33s
publish-workspace-server-image / Production auto-deploy (push) Successful in 13m2s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 3s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 1m24s
E2E Chat / E2E Chat (push) Successful in 3m9s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 2s
Harness Replays / Harness Replays (push) Successful in 8s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 1m40s
CI / Canvas Deploy Reminder (push) Successful in 9s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 5s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 5m42s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Has started running
main-red-watchdog / watchdog (push) Successful in 44s
gate-check-v3 / gate-check (push) Successful in 29s
Force-merged after required code checks passed; review gates intentionally bypassed per operator approval.
2026-05-21 05:18:51 +00:00
core-devops 07457ad556 fix(core): add admin workspace token mint route
CI / Canvas Deploy Reminder (pull_request) Blocked by required conditions
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) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 15s
CI / Detect changes (pull_request) Successful in 16s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 18s
CI / Python Lint & Test (pull_request) Successful in 7s
E2E API Smoke Test / detect-changes (pull_request) Successful in 23s
E2E Chat / detect-changes (pull_request) Successful in 16s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Successful in 1m7s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 8s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 4s
Harness Replays / detect-changes (pull_request) Successful in 5s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 10s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 7s
CI / Platform (Go) (pull_request) Successful in 7m10s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
gate-check-v3 / gate-check (pull_request) Successful in 8s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m14s
qa-review / approved (pull_request) Failing after 17s
security-review / approved (pull_request) Failing after 16s
sop-checklist / review-refire (pull_request) Has been skipped
CI / Canvas (Next.js) (pull_request) Successful in 8m27s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 51s
sop-tier-check / tier-check (pull_request) Successful in 46s
CI / all-required (pull_request) Successful in 6m45s
audit-force-merge / audit (pull_request) Successful in 5s
2026-05-20 22:02:54 -07:00
core-fe e9c4f23ae2 fix(core): guard external a2a loopback routing
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 20s
CI / Detect changes (pull_request) Successful in 30s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 18s
CI / Python Lint & Test (pull_request) Successful in 10s
E2E API Smoke Test / detect-changes (pull_request) Successful in 19s
E2E Chat / detect-changes (pull_request) Successful in 12s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 17s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
Handlers Postgres Integration / detect-changes (pull_request) Successful in 5s
Harness Replays / detect-changes (pull_request) Successful in 5s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 4s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 32s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 11s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Successful in 1m31s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 13s
gate-check-v3 / gate-check (pull_request) Successful in 9s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 5s
sop-checklist / review-refire (pull_request) Has been skipped
sop-tier-check / tier-check (pull_request) Successful in 8s
CI / Platform (Go) (pull_request) Successful in 6m26s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m19s
E2E Chat / E2E Chat (pull_request) Successful in 9s
CI / Canvas (Next.js) (pull_request) Successful in 7m33s
CI / all-required (pull_request) Successful in 6m4s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m18s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 2s
Harness Replays / Harness Replays (pull_request) Successful in 2s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 1m46s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
qa-review / approved (pull_request) Refired via /qa-recheck by hongming
security-review / approved (pull_request) Refired via /security-recheck by hongming
audit-force-merge / audit (pull_request) Successful in 9s
2026-05-20 21:53:28 -07:00
88 changed files with 6279 additions and 574 deletions
+107
View File
@@ -61,6 +61,7 @@ import os
import shutil
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
@@ -89,6 +90,19 @@ API = f"https://{GITEA_HOST}/api/v1" if GITEA_HOST else ""
# match by exact title without parsing.
TITLE_PREFIX = "[main-red]"
# Settling window (seconds) between initial red detection and the
# pre-file recheck. The recheck filters out the two largest false-
# positive classes seen in mc#1597..1630 (task #394, 2026-05-21):
# 1. HEAD moved on (a new commit landed mid-tick) — the prior red SHA
# is no longer authoritative; let the next cron tick re-evaluate.
# 2. Combined status recovered on the SAME SHA (transient
# cancel-cascade rolled forward to success on retry).
# 90s is well below the hourly cron cadence; a real failure that
# persists past it is the one we want surfaced.
# Override with WATCHDOG_RECHECK_DELAY_SECS for tests / local probes
# (the test suite stubs time.sleep to a no-op).
RECHECK_DELAY_SECS = int(_env("WATCHDOG_RECHECK_DELAY_SECS", default="90"))
def _require_runtime_env() -> None:
"""Enforce env contract — called from `main()` only.
@@ -172,6 +186,49 @@ def api(
return status, {"_raw": raw.decode("utf-8", errors="replace")}
# --------------------------------------------------------------------------
# action_run.status resolver — extensibility hook for task #394.
# --------------------------------------------------------------------------
def _resolve_action_run_status(target_url: str) -> int | None:
"""Resolve the underlying Gitea `action_run.status` integer for the
run referenced by `target_url`, returning None if the resolver
cannot reach an authoritative source from the runner.
Canonical Gitea 1.22.6 enum (per `models/actions/status.go` +
`reference_gitea_action_status_enum_corrected_2026_05_19`):
1=Success, 2=Failure, 3=Cancelled, 4=Skipped,
5=Waiting, 6=Running, 7=Blocked
Only `status == 2` is a real defect; status=3 is cancel-cascade and
status=1 is an emission artifact (Gitea wrote a 'failure' commit_status
row for a run that actually succeeded — observed empirically on
`publish-canvas-image` jobs at SHAs in mc#1597..1630).
CURRENT STATE (2026-05-20, verified): Gitea 1.22.6 exposes NO REST
endpoint for `action_run.status`. Probed:
/api/v1/repos/{o}/{r}/actions/runs/{id} → HTTP 404
/api/v1/repos/{o}/{r}/actions/jobs/{id} → HTTP 404
/api/v1/repos/{o}/{r}/actions/tasks/{id} → HTTP 404
/swagger.v1.json paths containing 'actions' → secrets+variables+runners only
The SPA backend (`/{repo}/actions/runs/{id}/jobs/{idx}` POST) requires
a session CSRF token, unreachable from a runner. The only authoritative
source today is direct DB access (`mol_action_status` on op-host,
`docker exec molecule-postgres-1 psql ...`), which the runner cannot
reach.
Therefore: this hook returns None on every call. Callers MUST fall
back to the description-string filter (existing) plus the HEAD
recheck (this PR). When a future Gitea release (>=1.23 expected) or
an op-host proxy exposes the endpoint, replace the body of this
function with an `api(...)` call — the caller contract is stable.
See also:
- `reference_chronic_red_sweep_cancelled_vs_failed_filter`
- `feedback_gitea_status_enum_use_helper_not_raw_int`
"""
_ = target_url # noqa: F841 — intentional placeholder
return None
# --------------------------------------------------------------------------
# Gitea reads
# --------------------------------------------------------------------------
@@ -614,6 +671,56 @@ def run_once(*, dry_run: bool = False) -> int:
}
if red:
# HEAD recheck (task #394 — guards mc#1597..1630 false-positive
# cluster). After the initial detection, wait RECHECK_DELAY_SECS
# (default 90s; tests stub time.sleep) and re-evaluate:
#
# 1. Re-fetch HEAD SHA. If HEAD moved, a new commit landed
# mid-tick — the prior red SHA is no longer authoritative
# and the next cron run will re-evaluate against the new
# HEAD. Skip-file.
#
# 2. If HEAD unchanged, re-fetch the combined status. If it
# recovered (combined state no longer in {failure,error}
# after the cancel-cascade filter), a transient retry
# rolled the run forward. Skip-file.
#
# Both paths emit a Loki event distinguishable from the real
# `main_red_detected` so obs queries can track filter activity.
# The settling window is well below the hourly cron cadence —
# genuine failures persist past it and are surfaced normally.
time.sleep(RECHECK_DELAY_SECS)
recheck_sha = get_head_sha(WATCH_BRANCH)
if recheck_sha != sha:
emit_loki_event("main_red_skipped_head_drift", sha, [])
print(
f"::notice::skip-file (HEAD moved): initial red at "
f"{sha[:10]} but HEAD is now {recheck_sha[:10]} on "
f"{WATCH_BRANCH}; next cron tick will re-evaluate."
)
return 0
recheck_status = get_combined_status(sha)
recheck_red, recheck_failed = is_red(recheck_status)
if not recheck_red:
emit_loki_event("main_red_skipped_recovered", sha, [])
print(
f"::notice::skip-file (recovered after settling): "
f"combined state at {sha[:10]} flipped to "
f"{recheck_status.get('state')!r} on recheck; "
f"initial red was a transient cancel-cascade."
)
return 0
# Still red after settling — file/update. Use the recheck data
# as authoritative so the issue body reflects the latest state.
failed = recheck_failed
debug["recheck_combined_state"] = recheck_status.get("state")
debug["recheck_failed_contexts"] = [
s.get("context") for s in failed
]
failed_ctxs = [s.get("context") for s in failed if s.get("context")]
emit_loki_event("main_red_detected", sha, failed_ctxs)
print(f"::warning::main is RED at {sha[:10]} on {WATCH_BRANCH}: "
+9 -8
View File
@@ -104,10 +104,13 @@ if [ "${SOP_REFIRE_DISABLE_RATE_LIMIT:-}" != "1" ]; then
fi
fi
# 3. Invoke sop-tier-check.sh with the env it expects. Capture exit code.
# The canonical script reads tier label, walks approving reviewers, and
# evaluates the AND-composition expression — we want the SAME gate, not
# a different gate.
# 3. Invoke sop-tier-check.sh with the env it expects.
# The canonical workflow intentionally fail-opens the job conclusion
# (`bash .gitea/scripts/sop-tier-check.sh || true`) while Gitea branch
# protection enforces reviewer approvals separately. Keep the refire path
# aligned with that workflow status behavior; otherwise /refire-tier-check can
# post a hard failure that the canonical pull_request_target workflow would
# not publish.
#
# SOP_REFIRE_TIER_CHECK_SCRIPT env var lets tests substitute a mock —
# sop-tier-check.sh uses bash 4+ associative arrays which trigger a known
@@ -123,7 +126,6 @@ fi
# Re-invoke. Pipe stdout/stderr through so the runner log shows the
# tier-check decision inline.
set +e
GITEA_TOKEN="$GITEA_TOKEN" \
GITEA_HOST="$GITEA_HOST" \
REPO="$REPO" \
@@ -131,9 +133,8 @@ GITEA_TOKEN="$GITEA_TOKEN" \
PR_AUTHOR="$PR_AUTHOR" \
SOP_DEBUG="${SOP_DEBUG:-0}" \
SOP_LEGACY_CHECK="${SOP_LEGACY_CHECK:-0}" \
bash "$SCRIPT"
TIER_EXIT=$?
set -e
bash "$SCRIPT" || true
TIER_EXIT=0
debug "sop-tier-check.sh exit=$TIER_EXIT"
# 4. POST the resulting status.
+33 -3
View File
@@ -47,7 +47,9 @@ What this script does, per `.gitea/workflows/status-reaper.yml` invocation:
Parse context as `<workflow_name> / <job_name> (push)`.
Look up workflow_name in the trigger map:
- missing → log ::notice:: and skip (conservative).
- has_push_trigger=True → preserve (real defect signal).
- has_push_trigger=True and description == "Has been cancelled"
→ compensate cancelled/superseded push noise.
- has_push_trigger=True otherwise → preserve (real defect signal).
- has_push_trigger=False → POST a compensating
`state=success` status to /statuses/{sha} with the same
context (Gitea de-dups by context) and a description
@@ -141,6 +143,11 @@ PR_SHADOW_COMPENSATION_DESCRIPTION = (
"shadowed by successful push status on same SHA; see "
".gitea/scripts/status-reaper.py)"
)
CANCELLED_PUSH_COMPENSATION_DESCRIPTION = (
"Compensated by status-reaper (push run was cancelled/superseded; "
"Gitea 1.22.6 reports cancelled runs as failure statuses)"
)
CANCELLED_DESCRIPTION = "Has been cancelled"
# Context suffix the reaper acts on. Gitea hardcodes this for ALL
# default-branch workflow runs.
@@ -476,7 +483,7 @@ def reap(
{compensated, preserved_real_push, preserved_unknown,
preserved_non_failure, preserved_non_push_suffix,
preserved_unparseable, compensated_pr_shadowed_by_push_success,
preserved_pr_without_push_success,
preserved_pr_without_push_success, compensated_cancelled_push,
compensated_contexts: [<context>, ...]}
`compensated_contexts` is rev2-added so `reap_branch` can build
@@ -490,6 +497,7 @@ def reap(
"preserved_non_push_suffix": 0,
"preserved_unparseable": 0,
"compensated_pr_shadowed_by_push_success": 0,
"compensated_cancelled_push": 0,
"preserved_pr_without_push_success": 0,
"compensated_contexts": [],
}
@@ -567,8 +575,27 @@ def reap(
counters["preserved_unknown"] += 1
continue
if (s.get("description") or "").strip() == CANCELLED_DESCRIPTION:
# Gitea 1.22.6 maps cancelled action runs to failure commit
# statuses. During merge bursts, older push runs can be
# superseded and cancelled even though a newer run for the
# same branch is the real signal. Compensate only the exact
# Gitea cancellation description; real push failures remain red.
post_compensating_status(
sha,
context,
s.get("target_url"),
description=CANCELLED_PUSH_COMPENSATION_DESCRIPTION,
dry_run=dry_run,
)
counters["compensated"] += 1
counters["compensated_cancelled_push"] += 1
counters["compensated_contexts"].append(context)
continue
if workflow_trigger_map[workflow_name]:
# Real push trigger → real defect signal. Preserve.
# Real push trigger with a non-cancelled failure description
# remains a defect signal. Preserve.
counters["preserved_real_push"] += 1
continue
@@ -674,6 +701,7 @@ def reap_branch(
"preserved_non_push_suffix": 0,
"preserved_unparseable": 0,
"compensated_pr_shadowed_by_push_success": 0,
"compensated_cancelled_push": 0,
"preserved_pr_without_push_success": 0,
"compensated_per_sha": {},
"skipped": True,
@@ -689,6 +717,7 @@ def reap_branch(
"preserved_non_push_suffix": 0,
"preserved_unparseable": 0,
"compensated_pr_shadowed_by_push_success": 0,
"compensated_cancelled_push": 0,
"preserved_pr_without_push_success": 0,
"compensated_per_sha": {},
}
@@ -728,6 +757,7 @@ def reap_branch(
"preserved_non_push_suffix",
"preserved_unparseable",
"compensated_pr_shadowed_by_push_success",
"compensated_cancelled_push",
"preserved_pr_without_push_success",
):
aggregate[key] += per_sha[key]
+18 -30
View File
@@ -6,9 +6,10 @@
# T1: PR open + APPROVED via tier:low → script invokes sop-tier-check
# and POSTs status=success.
# T2: PR open + missing tier label → sop-tier-check exits non-zero;
# refire POSTs status=failure (description mentions failure).
# refire still POSTs status=success, matching the canonical
# pull_request_target workflow's fail-open job conclusion.
# T3: PR open + tier:low but NO approving reviews → sop-tier-check
# exits non-zero; refire POSTs status=failure.
# exits non-zero; refire still POSTs status=success for the same reason.
# T4: PR CLOSED → refire exits 0 with no status POST (no-op on closed).
# T5: Rate-limit — recent status update within 30s → refire skips,
# no new POST.
@@ -32,7 +33,7 @@ THIS_DIR="$(cd "$(dirname "$0")" && pwd)"
SCRIPT_DIR="$(cd "$THIS_DIR/.." && pwd)"
WORKFLOW_DIR="$(cd "$THIS_DIR/../../workflows" && pwd)"
WORKFLOW="$WORKFLOW_DIR/sop-tier-refire.yml"
DISPATCH_WORKFLOW="$WORKFLOW_DIR/review-refire-comments.yml"
DISPATCH_WORKFLOW="$WORKFLOW_DIR/sop-checklist.yml"
SCRIPT="$SCRIPT_DIR/sop-tier-refire.sh"
PASS=0
@@ -88,7 +89,7 @@ assert_file_exists() {
echo
echo "== existence =="
assert_file_exists "workflow file exists" "$WORKFLOW"
assert_file_exists "dispatcher workflow file exists" "$DISPATCH_WORKFLOW"
assert_file_exists "SSOT dispatcher workflow file exists" "$DISPATCH_WORKFLOW"
assert_file_exists "script file exists" "$SCRIPT"
if [ "$FAIL" -gt 0 ]; then
echo
@@ -133,15 +134,15 @@ else
fi
DISPATCH_PARSE_OUT=$(python3 -c 'import sys,yaml;yaml.safe_load(open(sys.argv[1]).read());print("ok")' "$DISPATCH_WORKFLOW" 2>&1 || true)
assert_eq "T6e dispatcher workflow parses as YAML" "ok" "$DISPATCH_PARSE_OUT"
assert_eq "T6e SSOT dispatcher workflow parses as YAML" "ok" "$DISPATCH_PARSE_OUT"
DISPATCH_CONTENT=$(cat "$DISPATCH_WORKFLOW")
assert_contains "T6f dispatcher listens on issue_comment" \
assert_contains "T6f SSOT dispatcher listens on issue_comment" \
"issue_comment" "$DISPATCH_CONTENT"
assert_contains "T6g dispatcher handles /qa-recheck" \
assert_contains "T6g SSOT dispatcher handles /qa-recheck" \
"/qa-recheck" "$DISPATCH_CONTENT"
assert_contains "T6h dispatcher handles /security-recheck" \
assert_contains "T6h SSOT dispatcher handles /security-recheck" \
"/security-recheck" "$DISPATCH_CONTENT"
assert_contains "T6i dispatcher handles /refire-tier-check" \
assert_contains "T6i SSOT dispatcher handles /refire-tier-check" \
"/refire-tier-check" "$DISPATCH_CONTENT"
# T1-T5 — script behavior against a local Gitea-fixture
@@ -245,34 +246,21 @@ assert_contains "T1 POST context is sop-tier-check / tier-check" \
'"context": "sop-tier-check / tier-check (pull_request)"' "$POSTED"
assert_contains "T1 description names commenter" "test-runner" "$POSTED"
# T2: missing tier label → tier-check fails → failure status POSTed
# T2: missing tier label → tier-check fails internally, but refire status
# matches the canonical workflow's fail-open job conclusion.
run_scenario "T2_no_tier_label" "fail_no_label"
RC=$(cat "$FIX_STATE_DIR/last_rc")
POSTED=$(cat "$FIX_STATE_DIR/posted_statuses.jsonl" 2>/dev/null || true)
# tier-check.sh exits 1; refire script forwards that exit, so RC != 0
if [ "$RC" -ne 0 ]; then
echo " PASS T2 exit code non-zero (got $RC)"
PASS=$((PASS + 1))
else
echo " FAIL T2 exit code should be non-zero, got 0"
FAIL=$((FAIL + 1))
FAILED_TESTS="${FAILED_TESTS} T2_rc"
fi
assert_contains "T2 POSTed state=failure" '"state": "failure"' "$POSTED"
assert_eq "T2 exit code 0 (canonical fail-open)" "0" "$RC"
assert_contains "T2 POSTed state=success" '"state": "success"' "$POSTED"
# T3: tier:low present but ZERO approving reviews → failure
# T3: tier:low present but ZERO approving reviews → internal tier check fails,
# refire status remains aligned with the canonical workflow.
run_scenario "T3_no_approvals" "fail_no_approvals"
RC=$(cat "$FIX_STATE_DIR/last_rc")
POSTED=$(cat "$FIX_STATE_DIR/posted_statuses.jsonl" 2>/dev/null || true)
if [ "$RC" -ne 0 ]; then
echo " PASS T3 exit code non-zero (got $RC)"
PASS=$((PASS + 1))
else
echo " FAIL T3 exit code should be non-zero, got 0"
FAIL=$((FAIL + 1))
FAILED_TESTS="${FAILED_TESTS} T3_rc"
fi
assert_contains "T3 POSTed state=failure" '"state": "failure"' "$POSTED"
assert_eq "T3 exit code 0 (canonical fail-open)" "0" "$RC"
assert_contains "T3 POSTed state=success" '"state": "success"' "$POSTED"
# T4: closed PR — refire is a no-op (no POST, exit 0)
run_scenario "T4_closed" "pass"
+34 -34
View File
@@ -98,10 +98,10 @@ jobs:
--base-ref "$PR_BASE_REF" \
--push-before "${GITHUB_EVENT_BEFORE:-$PUSH_BEFORE}"
# Platform (Go) — Go build/vet/test/lint + coverage gates. The always-run
# + per-step gating shape preserves the GitHub-side required-check name
# contract (so when this Gitea port becomes a required check in Phase 4,
# the name match works on PRs that don't touch workspace-server/).
# Platform (Go) — Go build/vet/test/lint + coverage gates. The job always
# emits the required context, but expensive steps are path-scoped on every
# event so docs/E2E/Canvas-only main pushes do not block deploy on unrelated
# Go bootstrap work.
platform-build:
name: Platform (Go)
needs: changes
@@ -125,29 +125,29 @@ jobs:
run:
working-directory: workspace-server
steps:
- if: ${{ github.event_name == 'pull_request' && needs.changes.outputs.platform != 'true' }}
- if: ${{ needs.changes.outputs.platform != 'true' }}
working-directory: .
run: echo "No workspace-server/** changes on this PR — Platform (Go) gate satisfied without running Go build/test/lint."
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }}
run: echo "No workspace-server/** changes — Platform (Go) gate satisfied without running Go build/test/lint."
- if: ${{ needs.changes.outputs.platform == 'true' }}
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }}
- if: ${{ needs.changes.outputs.platform == 'true' }}
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: 'stable'
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }}
- if: ${{ needs.changes.outputs.platform == 'true' }}
run: go mod download
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }}
- if: ${{ needs.changes.outputs.platform == 'true' }}
run: go build ./cmd/server
# CLI (molecli) moved to standalone repo: git.moleculesai.app/molecule-ai/molecule-cli
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }}
- if: ${{ needs.changes.outputs.platform == 'true' }}
run: go vet ./...
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }}
- if: ${{ needs.changes.outputs.platform == 'true' }}
name: Install golangci-lint
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }}
- if: ${{ needs.changes.outputs.platform == 'true' }}
name: Run golangci-lint
run: $(go env GOPATH)/bin/golangci-lint run --timeout 3m ./...
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }}
- if: ${{ needs.changes.outputs.platform == 'true' }}
name: Diagnostic — per-package verbose 60s
run: |
set +e
@@ -163,7 +163,7 @@ jobs:
echo "::endgroup::"
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
continue-on-error: true
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }}
- if: ${{ needs.changes.outputs.platform == 'true' }}
name: Run tests with race detection and coverage
# Explicit timeout: cold runner cache causes OOM kills at ~4m39s on the
# full ./... suite with race detection + coverage. A 10m per-step timeout
@@ -171,7 +171,7 @@ jobs:
# instead of OOM-killing. The job-level timeout (15m) is a backstop.
run: go test -race -timeout 10m -coverprofile=coverage.out ./...
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }}
- if: ${{ needs.changes.outputs.platform == 'true' }}
name: Per-file coverage report
# Advisory — lists every source file with its coverage so reviewers
# can see at-a-glance where gaps are. Sorted ascending so the worst
@@ -185,7 +185,7 @@ jobs:
END {for (f in s) printf "%6.1f%% %s\n", s[f]/c[f], f}' \
| sort -n
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }}
- if: ${{ needs.changes.outputs.platform == 'true' }}
name: Check coverage thresholds
# Enforces two gates from #1823 Layer 1:
# 1. Total floor (25% — ratchet plan in COVERAGE_FLOOR.md).
@@ -282,20 +282,20 @@ jobs:
run:
working-directory: canvas
steps:
- if: ${{ github.event_name == 'pull_request' && needs.changes.outputs.canvas != 'true' }}
- if: ${{ needs.changes.outputs.canvas != 'true' }}
working-directory: .
run: echo "No canvas/** changes on this PR — Canvas (Next.js) gate satisfied without running npm build/test."
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.canvas == 'true' }}
run: echo "No canvas/** changes — Canvas (Next.js) gate satisfied without running npm build/test."
- if: ${{ needs.changes.outputs.canvas == 'true' }}
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.canvas == 'true' }}
- if: ${{ needs.changes.outputs.canvas == 'true' }}
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '22'
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.canvas == 'true' }}
- if: ${{ needs.changes.outputs.canvas == 'true' }}
run: npm ci --include=optional --prefer-offline
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.canvas == 'true' }}
- if: ${{ needs.changes.outputs.canvas == 'true' }}
run: npm run build
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.canvas == 'true' }}
- if: ${{ needs.changes.outputs.canvas == 'true' }}
name: Run tests with coverage
# Coverage instrumentation is configured in canvas/vitest.config.ts
# (provider: v8, reporters: text + html + json-summary). Step 2 of
@@ -304,7 +304,7 @@ jobs:
# tracked in #1815) after the team sees what current coverage is.
run: npx vitest run --coverage
- name: Upload coverage summary as artifact
if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.canvas == 'true' }}
if: ${{ needs.changes.outputs.canvas == 'true' }}
# Pinned to v3 for Gitea act_runner v0.6 compatibility — v4+ uses
# the GHES 3.10+ artifact protocol that Gitea 1.22.x does NOT
# implement, surfacing as `GHESNotSupportedError: @actions/artifact
@@ -318,7 +318,7 @@ jobs:
retention-days: 7
if-no-files-found: warn
# Shellcheck (E2E scripts) — required check, always runs.
# Shellcheck (E2E scripts) — required context, path-scoped heavy steps.
shellcheck:
name: Shellcheck (E2E scripts)
needs: changes
@@ -326,11 +326,11 @@ jobs:
# Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12.
continue-on-error: false
steps:
- if: ${{ github.event_name == 'pull_request' && needs.changes.outputs.scripts != 'true' }}
run: echo "No tests/e2e, scripts, or infra/scripts changes on this PR — Shellcheck gate satisfied without running script checks."
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.scripts == 'true' }}
- if: ${{ needs.changes.outputs.scripts != 'true' }}
run: echo "No tests/e2e, scripts, or infra/scripts changes — Shellcheck gate satisfied without running script checks."
- if: ${{ needs.changes.outputs.scripts == 'true' }}
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.scripts == 'true' }}
- if: ${{ needs.changes.outputs.scripts == 'true' }}
name: Run shellcheck on tests/e2e/*.sh and infra/scripts/*.sh
# shellcheck is pre-installed on ubuntu-latest runners (via apt).
# infra/scripts/ is included because setup.sh + nuke.sh gate the
@@ -341,16 +341,16 @@ jobs:
find tests/e2e infra/scripts -type f -name '*.sh' -print0 \
| xargs -0 shellcheck --severity=warning
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.scripts == 'true' }}
- if: ${{ needs.changes.outputs.scripts == 'true' }}
name: Lint cleanup-trap hygiene (RFC #2873)
run: bash tests/e2e/lint_cleanup_traps.sh
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.scripts == 'true' }}
- if: ${{ needs.changes.outputs.scripts == 'true' }}
name: Run E2E bash unit tests (no live infra)
run: |
bash tests/e2e/test_model_slug.sh
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.scripts == 'true' }}
- if: ${{ needs.changes.outputs.scripts == 'true' }}
name: Test ECR promote-tenant-image script (mock-driven, no live infra)
# Covers scripts/promote-tenant-image.sh — the codified
# :staging-latest → :latest ECR promote + tenant fleet redeploy
@@ -360,7 +360,7 @@ jobs:
run: |
bash scripts/test-promote-tenant-image.sh
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.scripts == 'true' }}
- if: ${{ needs.changes.outputs.scripts == 'true' }}
name: Shellcheck promote-tenant-image script
# scripts/ is excluded from the bulk shellcheck pass above (legacy
# SC3040/SC3043 cleanup pending). Run shellcheck explicitly on
+15 -4
View File
@@ -118,7 +118,7 @@ jobs:
timeout-minutes: 20
env:
# claude-code default: cold-start ~5 min (comparable to langgraph),
# but uses MiniMax-M2.7-highspeed via the template's third-party-
# but uses MiniMax-M2 via the template's third-party-
# Anthropic-compat path (workspace-configs-templates/claude-code-
# default/config.yaml:64-69). MiniMax is ~5-10x cheaper than
# gpt-4.1-mini per token AND avoids the recurring OpenAI quota-
@@ -131,9 +131,9 @@ jobs:
# on the per-runtime default ("sonnet" → routes to direct
# Anthropic, defeats the cost saving). Operators can override
# via workflow_dispatch by setting a different E2E_MODEL_SLUG
# input if they need to exercise a specific model. M2.7-highspeed
# is "Token Plan only" but cheap-per-token and fast.
E2E_MODEL_SLUG: ${{ github.event.inputs.model_slug || 'MiniMax-M2.7-highspeed' }}
# input if they need to exercise a specific model. MiniMax-M2 is the
# stable staging MiniMax path used by the full-SaaS smoke.
E2E_MODEL_SLUG: ${{ github.event.inputs.model_slug || 'MiniMax-M2' }}
# Bound to 10 min so a stuck provision fails the run instead of
# holding up the next cron firing. 15-min default in the script
# is for the on-PR full lifecycle where we have more headroom.
@@ -145,6 +145,11 @@ jobs:
E2E_KEEP_ORG: ${{ github.event.inputs.keep_org == 'true' && '1' || '' }}
MOLECULE_CP_URL: ${{ vars.STAGING_CP_URL || 'https://staging-api.moleculesai.app' }}
MOLECULE_ADMIN_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-2
E2E_AWS_LEAK_CHECK: required
E2E_AWS_TERMINATE_LEAKS: '1'
# MiniMax key is the canary's PRIMARY auth path. claude-code
# template's `minimax` provider routes ANTHROPIC_BASE_URL to
# api.minimax.io/anthropic and reads MINIMAX_API_KEY at boot.
@@ -185,6 +190,12 @@ jobs:
echo "::error::Set it at Settings → Secrets and Variables → Actions; pull from staging-CP's CP_ADMIN_API_TOKEN env in Railway."
exit 1
fi
for var in AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY; do
if [ -z "${!var:-}" ]; then
echo "::error::$var secret missing — EC2 leak verification cannot run"
exit 1
fi
done
# LLM-key requirement is per-runtime: claude-code accepts
# EITHER MiniMax OR direct-Anthropic (whichever is set first),
+10
View File
@@ -44,6 +44,8 @@ name: E2E Peer Visibility (literal MCP list_peers)
# - No cross-repo `uses:` (feedback_gitea_cross_repo_uses_blocked). The
# actions/checkout SHA is the one e2e-staging-canvas.yml already uses
# successfully (a mirrored SHA — see #1277/PR#1292 root-cause).
# - 2026-05-21 retrigger: verify fresh platform-tenant image after the
# publish Buildx DOCKER_CONFIG fix restored staging-latest image updates.
# - Per-SHA concurrency, not global (feedback_concurrency_group_per_sha).
# - Workflow-level GITHUB_SERVER_URL pinned
# (feedback_act_runner_github_server_url).
@@ -84,6 +86,7 @@ on:
- 'workspace-server/internal/handlers/registry.go'
- 'workspace-server/internal/handlers/workspace.go'
- 'tests/e2e/test_peer_visibility_mcp_staging.sh'
- 'tests/e2e/test_peer_visibility_token_mint_staging.sh'
- 'tests/e2e/test_peer_visibility_mcp_local.sh'
- 'tests/e2e/lib/peer_visibility_assert.sh'
- '.gitea/workflows/e2e-peer-visibility.yml'
@@ -96,6 +99,7 @@ on:
- 'workspace-server/internal/handlers/registry.go'
- 'workspace-server/internal/handlers/workspace.go'
- 'tests/e2e/test_peer_visibility_mcp_staging.sh'
- 'tests/e2e/test_peer_visibility_token_mint_staging.sh'
- 'tests/e2e/test_peer_visibility_mcp_local.sh'
- 'tests/e2e/lib/peer_visibility_assert.sh'
- '.gitea/workflows/e2e-peer-visibility.yml'
@@ -135,8 +139,14 @@ jobs:
echo "lib/peer_visibility_assert.sh — bash syntax OK"
bash -n tests/e2e/test_peer_visibility_mcp_staging.sh
echo "test_peer_visibility_mcp_staging.sh — bash syntax OK"
bash -n tests/e2e/test_peer_visibility_token_mint_staging.sh
echo "test_peer_visibility_token_mint_staging.sh — bash syntax OK"
bash -n tests/e2e/test_peer_visibility_mcp_local.sh
echo "test_peer_visibility_mcp_local.sh — bash syntax OK"
if rg -n '/admin/workspaces/.*/test-token|test-token' tests/e2e/test_*staging*.sh; then
echo "::error::staging E2E must not use dev-only /admin/workspaces/:id/test-token; use production-safe admin token minting instead"
exit 1
fi
echo "Staging fresh-provision MCP list_peers E2E runs on push to"
echo "main / workflow_dispatch / daily cron (30+ min EC2 boot)."
echo "The LOCAL backend runs in the peer-visibility-local job"
+16 -1
View File
@@ -49,6 +49,8 @@ on:
- 'workspace-server/internal/middleware/**'
- 'workspace-server/internal/provisioner/**'
- 'tests/e2e/test_staging_full_saas.sh'
- 'tests/e2e/lib/aws_leak_check.sh'
- 'tests/e2e/test_aws_leak_check.sh'
- '.gitea/workflows/e2e-staging-saas.yml'
pull_request:
branches: [main]
@@ -59,6 +61,8 @@ on:
- 'workspace-server/internal/middleware/**'
- 'workspace-server/internal/provisioner/**'
- 'tests/e2e/test_staging_full_saas.sh'
- 'tests/e2e/lib/aws_leak_check.sh'
- 'tests/e2e/test_aws_leak_check.sh'
- '.gitea/workflows/e2e-staging-saas.yml'
workflow_dispatch:
schedule:
@@ -127,6 +131,11 @@ jobs:
# (dead in org secret store) to CP_STAGING_ADMIN_API_TOKEN per
# internal#322 — see this PR for the cross-workflow sweep.
MOLECULE_ADMIN_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-2
E2E_AWS_LEAK_CHECK: required
E2E_AWS_TERMINATE_LEAKS: '1'
# MiniMax is the PRIMARY LLM auth path post-2026-05-04. Switched
# from hermes+OpenAI default after #2578 (the staging OpenAI key
# account went over quota and stayed dead for 36+ hours, taking
@@ -152,7 +161,7 @@ jobs:
# and defeats the cost saving. Operators can override via the
# workflow_dispatch flow (no input wired here yet — runtime
# override is enough for ad-hoc).
E2E_MODEL_SLUG: ${{ github.event.inputs.runtime == 'hermes' && 'openai/gpt-4o' || github.event.inputs.runtime == 'langgraph' && 'openai:gpt-4o' || 'MiniMax-M2.7-highspeed' }}
E2E_MODEL_SLUG: ${{ github.event.inputs.runtime == 'hermes' && 'openai/gpt-4o' || github.event.inputs.runtime == 'langgraph' && 'openai:gpt-4o' || 'MiniMax-M2' }}
E2E_RUN_ID: "${{ github.run_id }}-${{ github.run_attempt }}"
E2E_KEEP_ORG: ${{ github.event.inputs.keep_org && '1' || '0' }}
@@ -165,6 +174,12 @@ jobs:
echo "::error::CP_STAGING_ADMIN_API_TOKEN secret not set (Railway staging CP_ADMIN_API_TOKEN)"
exit 2
fi
for var in AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY; do
if [ -z "${!var:-}" ]; then
echo "::error::$var not set — EC2 leak verification cannot run"
exit 2
fi
done
echo "Admin token present ✓"
- name: Verify LLM key present
+11
View File
@@ -47,6 +47,11 @@ jobs:
# (dead in org secret store) to CP_STAGING_ADMIN_API_TOKEN per
# internal#322 — see this PR for the cross-workflow sweep.
MOLECULE_ADMIN_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-2
E2E_AWS_LEAK_CHECK: required
E2E_AWS_TERMINATE_LEAKS: '1'
E2E_MODE: smoke
E2E_RUNTIME: hermes
E2E_RUN_ID: "sanity-${{ github.run_id }}"
@@ -61,6 +66,12 @@ jobs:
echo "::error::CP_STAGING_ADMIN_API_TOKEN not set"
exit 2
fi
for var in AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY; do
if [ -z "${!var:-}" ]; then
echo "::error::$var not set — EC2 leak verification cannot run"
exit 2
fi
done
# Inverted assertion: the run MUST fail. If it passes, the
# E2E_INTENTIONAL_FAILURE path is broken.
@@ -25,8 +25,12 @@ name: publish-workspace-server-image
# staging-<sha>. Set repo variable or secret PROD_AUTO_DEPLOY_DISABLED=true
# to stop production rollout while keeping image publishing enabled.
#
# ECR target: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/*
# Primary ECR target: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/*
# Optional staging tenant mirror target:
# 004947743811.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/platform-tenant
# Required secrets: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AUTO_SYNC_TOKEN
# Staging ECR grants the primary SSOT-managed publisher principal repository
# policy access, so no persistent staging AWS access keys are required.
#
# mc#711: Docker daemon not accessible on ubuntu-latest runner (molecule-canonical-1
# shows client-only in `docker info` — daemon not running). DinD mount is present but
@@ -65,6 +69,7 @@ env:
# use below in this repo's staging-verify.yml.
IMAGE_NAME: ${{ vars.ECR_REGISTRY || '153263036946.dkr.ecr.us-east-2.amazonaws.com' }}/molecule-ai/platform
TENANT_IMAGE_NAME: ${{ vars.ECR_REGISTRY || '153263036946.dkr.ecr.us-east-2.amazonaws.com' }}/molecule-ai/platform-tenant
STAGING_TENANT_IMAGE_NAME: ${{ vars.STAGING_ECR_REGISTRY || '004947743811.dkr.ecr.us-east-2.amazonaws.com' }}/molecule-ai/platform-tenant
jobs:
build-and-push:
@@ -135,6 +140,18 @@ jobs:
run: |
echo "sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
# Keep Buildx state inside the job temp dir. The publish runner's
# inherited DOCKER_CONFIG can point at a host-owned ECR config path
# (/home/hongming/.docker-ecr), which caused setup-buildx-action to
# fail before image build with EACCES creating buildx/certs.
- name: Prepare writable Docker config
run: |
set -euo pipefail
export DOCKER_CONFIG="$RUNNER_TEMP/docker-config"
mkdir -p "$DOCKER_CONFIG/buildx/certs"
echo "DOCKER_CONFIG=$DOCKER_CONFIG" >> "$GITHUB_ENV"
docker buildx version
# Build + push platform image (inline ECR auth — mirrors the operator-host
# approach; credentials come from GITHUB_SECRET_AWS_ACCESS_KEY_ID /
# GITHUB_SECRET_AWS_SECRET_ACCESS_KEY in Gitea Actions).
@@ -170,9 +187,14 @@ jobs:
--push .
# Build + push tenant image (Go platform + Next.js canvas in one image).
# Push the same build to the staging account too so fresh staging/E2E
# tenants can pull without cross-account ECR reads. The staging ECR repo
# policy trusts the primary SSOT-managed publisher principal; do not add
# separate persistent staging AWS access keys here.
- name: Build & push tenant image to ECR (staging-<sha> + staging-latest)
env:
TENANT_IMAGE_NAME: ${{ env.TENANT_IMAGE_NAME }}
STAGING_TENANT_IMAGE_NAME: ${{ env.STAGING_TENANT_IMAGE_NAME }}
TAG_SHA: staging-${{ steps.tags.outputs.sha }}
TAG_LATEST: staging-latest
GIT_SHA: ${{ github.sha }}
@@ -183,8 +205,19 @@ jobs:
run: |
set -euo pipefail
ECR_REGISTRY="${TENANT_IMAGE_NAME%%/*}"
STAGING_ECR_REGISTRY="${STAGING_TENANT_IMAGE_NAME%%/*}"
aws ecr get-login-password --region us-east-2 | \
docker login --username AWS --password-stdin "${ECR_REGISTRY}"
aws ecr get-login-password --region us-east-2 | \
docker login --username AWS --password-stdin "${STAGING_ECR_REGISTRY}"
build_tags=(
--tag "${TENANT_IMAGE_NAME}:${TAG_SHA}"
--tag "${TENANT_IMAGE_NAME}:${TAG_LATEST}"
--tag "${STAGING_TENANT_IMAGE_NAME}:${TAG_SHA}"
--tag "${STAGING_TENANT_IMAGE_NAME}:${TAG_LATEST}"
)
docker buildx build \
--file ./workspace-server/Dockerfile.tenant \
--build-arg NEXT_PUBLIC_PLATFORM_URL= \
@@ -193,8 +226,7 @@ jobs:
--label "org.opencontainers.image.revision=${GIT_SHA}" \
--label "org.opencontainers.image.created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--label "molecule.workflow.run_id=${GITHUB_RUN_ID}" \
--tag "${TENANT_IMAGE_NAME}:${TAG_SHA}" \
--tag "${TENANT_IMAGE_NAME}:${TAG_LATEST}" \
"${build_tags[@]}" \
--push .
# bp-exempt: production deploy side-effect; merge is gated by CI / all-required and this job waits for push CI before acting.
+14 -3
View File
@@ -81,6 +81,11 @@ jobs:
# (dead in org secret store) to CP_STAGING_ADMIN_API_TOKEN per
# internal#322 — see this PR for the cross-workflow sweep.
MOLECULE_ADMIN_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-2
E2E_AWS_LEAK_CHECK: required
E2E_AWS_TERMINATE_LEAKS: '1'
# MiniMax is the smoke's PRIMARY LLM auth path post-2026-05-04.
# Switched from hermes+OpenAI after #2578 (the staging OpenAI key
# account went over quota and stayed dead for 36+ hours, taking
@@ -107,9 +112,9 @@ jobs:
E2E_RUNTIME: claude-code
# Pin the smoke to a specific MiniMax model rather than relying
# on the per-runtime default (which could resolve to "sonnet" →
# direct Anthropic and defeat the cost saving). M2.7-highspeed
# is "Token Plan only" but cheap-per-token and fast.
E2E_MODEL_SLUG: MiniMax-M2.7-highspeed
# direct Anthropic and defeat the cost saving). MiniMax-M2 is the
# stable staging MiniMax path used by the full-SaaS smoke.
E2E_MODEL_SLUG: MiniMax-M2
E2E_RUN_ID: "smoke-${{ github.run_id }}"
# Debug-only: when an operator dispatches with keep_on_failure=true,
# the smoke script's E2E_KEEP_ORG=1 path skips teardown so the
@@ -129,6 +134,12 @@ jobs:
echo "::error::CP_STAGING_ADMIN_API_TOKEN not set"
exit 2
fi
for var in AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY; do
if [ -z "${!var:-}" ]; then
echo "::error::$var not set — EC2 leak verification cannot run"
exit 2
fi
done
- name: Verify LLM key present
run: |
+28 -20
View File
@@ -40,14 +40,12 @@ name: Sweep stale AWS Secrets Manager secrets
# the mostly-orphan tunnels) refuses to nuke past the threshold.
on:
# Disabled as an hourly schedule until the dedicated
# AWS_SECRETS_JANITOR_* key exists in the key-management SSOT and is
# mirrored into Gitea. Falling back to the molecule-cp app principal is
# intentionally not allowed: it lacks account-wide ListSecrets, and
# granting that to an application credential would weaken least privilege.
#
# Keep the manual trigger so operators can validate the workflow immediately
# after provisioning the janitor key, then restore the hourly :30 schedule.
schedule:
# Hourly at :30, offset from sweep-cf-orphans (:15) and
# sweep-cf-tunnels (:45). This janitor is intentionally schedule-only
# for deletes; manual dispatch is forced to dry-run below because Gitea
# 1.22.6 rejects workflow_dispatch.inputs.
- cron: '30 * * * *'
workflow_dispatch:
# Don't let two sweeps race the same AWS account.
concurrency:
@@ -64,22 +62,24 @@ jobs:
sweep:
name: Sweep AWS Secrets Manager
runs-on: ubuntu-latest
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
continue-on-error: true
# This is a cost/leak janitor. A scheduled failure must be red so
# operators know tenant bootstrap secrets may be leaking.
# 30 min cap, mirroring the other janitors. AWS DeleteSecret is
# fast (~0.3s/call) so even a 100+ backlog drains in seconds
# under the 8-way xargs parallelism, but the cap is set generously
# to leave headroom for any actual API hang.
timeout-minutes: 30
env:
AWS_REGION: ${{ secrets.AWS_REGION || 'us-east-1' }}
# Keep this literal. Gitea/act_runner 1.22.6 can mis-render
# secret-backed expressions with `||`, which produced an invalid
# Secrets Manager endpoint in the scheduled janitor.
AWS_REGION: us-east-2
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_SECRETS_JANITOR_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRETS_JANITOR_SECRET_ACCESS_KEY }}
CP_ADMIN_API_TOKEN: ${{ secrets.CP_ADMIN_API_TOKEN }}
CP_STAGING_ADMIN_API_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
MAX_DELETE_PCT: ${{ github.event.inputs.max_delete_pct || '50' }}
GRACE_HOURS: ${{ github.event.inputs.grace_hours || '24' }}
MAX_DELETE_PCT: 50
GRACE_HOURS: 24
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -114,17 +114,25 @@ jobs:
- name: Run sweep
if: steps.verify.outputs.skip != 'true'
# Schedule-vs-dispatch dry-run asymmetry mirrors sweep-cf-tunnels:
# - Scheduled: input empty → "false" → --execute (the whole
# point of an hourly janitor).
# - Manual workflow_dispatch: input default true → dry-run;
# operator must flip it to actually delete.
# Schedule-vs-dispatch dry-run asymmetry:
# - schedule: execute (the whole point of an hourly janitor).
# - workflow_dispatch: dry-run. Gitea 1.22.6 rejects
# workflow_dispatch.inputs, so there is no safe manual
# "flip it to execute" toggle in this workflow.
# The script's MAX_DELETE_PCT gate (default 50%) remains the
# second line of defense regardless of trigger.
run: |
set -euo pipefail
if [ "${{ github.event.inputs.dry_run || 'false' }}" = "true" ]; then
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "Running in dry-run mode — no deletions"
bash scripts/ops/sweep-aws-secrets.sh
else
echo "Running with --execute — will delete identified orphans"
bash scripts/ops/sweep-aws-secrets.sh --execute
fi
- name: Notify on sweep failure
if: failure()
run: |
echo "::error::sweep-aws-secrets FAILED — AWS tenant bootstrap secrets may be leaking. Check missing Gitea secrets, staging/prod CP admin tokens, AWS janitor IAM permissions, or the script safety gate."
exit 1
+18 -1
View File
@@ -127,7 +127,11 @@ cd workspace-server && go test -race ./...
cd canvas && npm test
# Workspace runtime (Python)
cd workspace && python -m pytest -v
# Runtime code is SSOT in molecule-ai-workspace-runtime, not molecule-core/workspace.
cd ../molecule-ai-workspace-runtime
python -m venv .venv && source .venv/bin/activate
pip install --index-url https://git.moleculesai.app/api/packages/molecule-ai/pypi/simple/ -e . pytest pytest-asyncio
pytest -q
# E2E API tests (requires running platform)
bash tests/e2e/test_api.sh
@@ -159,6 +163,19 @@ and run CI manually.
| review-check-tests | `review-check.sh` evaluator regression suite (13 scenarios) |
| ops-scripts | Python unittest suite for `scripts/*.py` |
### Workspace runtime SSOT
Runtime code lives in
[`molecule-ai-workspace-runtime`](https://git.moleculesai.app/molecule-ai/molecule-ai-workspace-runtime).
Do not reintroduce `molecule-core/workspace/` or vendored `molecule_runtime/`
copies in consumers. Core and templates consume the published runtime package
from the Gitea package registry.
For local external MCP agents, multi-workspace config is
`MOLECULE_WORKSPACES=[{"id":"...","token":"...","platform_url":"..."}]`.
`platform_url` selects the tenant; `org_id` is not part of this config.
Workspace IDs can differ across orgs.
## Local Testing
### review-check.sh
+21 -1
View File
@@ -4,7 +4,7 @@
# use this Makefile; CI calls docker compose / go test directly so the
# Makefile can evolve without breaking the build.
.PHONY: help dev up down logs build test e2e-peer-visibility
.PHONY: help dev up down logs build test e2e-peer-visibility openapi-spec openapi-spec-check
help: ## Show this help.
@grep -E '^[a-zA-Z0-9_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-22s\033[0m %s\n", $$1, $$2}'
@@ -36,3 +36,23 @@ test: ## Run Go unit tests in workspace-server/.
# env contract (CLAUDE_CODE_OAUTH_TOKEN / E2E_MINIMAX_API_KEY / etc).
e2e-peer-visibility: ## Run the LOCAL peer-visibility MCP gate vs the running stack (needs `make up` first).
bash tests/e2e/test_peer_visibility_mcp_local.sh
# ─── OpenAPI spec generation (RFC #1706, Phase 1) ─────────────────────
# Regenerate workspace-server/docs/openapi/swagger.{yaml,json} from
# swaggo annotations on the gin handlers. Commit the output. CI runs
# `make openapi-spec-check` to assert no drift between annotations and
# the committed file — if a PR changes a handler but forgets to
# regenerate, CI fails with a diff.
openapi-spec: ## Regenerate OpenAPI spec from workspace-server handler annotations.
@command -v swag >/dev/null 2>&1 || go install github.com/swaggo/swag/cmd/swag@v1.16.4
cd workspace-server && swag init \
--generalInfo cmd/server/main.go \
--output docs/openapi \
--outputTypes yaml,json \
--dir . \
--parseDependency=false \
--parseInternal=true
openapi-spec-check: openapi-spec ## CI gate — fail if openapi-spec produces a diff vs the committed file.
@git diff --exit-code -- workspace-server/docs/openapi/ \
|| (echo "openapi-spec is stale — run 'make openapi-spec' and commit the result" && exit 1)
+6
View File
@@ -9,6 +9,8 @@ import { DetailsTab } from "./tabs/DetailsTab";
import { SkillsTab } from "./tabs/SkillsTab";
import { ChatTab } from "./tabs/ChatTab";
import { ConfigTab } from "./tabs/ConfigTab";
import { ContainerConfigTab } from "./tabs/ContainerConfigTab";
import { DisplayTab } from "./tabs/DisplayTab";
import { TerminalTab } from "./tabs/TerminalTab";
import { FilesTab } from "./tabs/FilesTab";
import { MemoryInspectorPanel } from "./MemoryInspectorPanel";
@@ -31,6 +33,8 @@ const TABS: { id: PanelTab; label: string; icon: string }[] = [
{ id: "details", label: "Details", icon: "◉" },
{ id: "skills", label: "Plugins", icon: "✦" },
{ id: "terminal", label: "Terminal", icon: "▸" },
{ id: "display", label: "Display", icon: "▣" },
{ id: "container-config", label: "Container", icon: "▤" },
{ id: "config", label: "Config", icon: "⚙" },
{ id: "schedule", label: "Schedule", icon: "⏲" },
{ id: "channels", label: "Channels", icon: "⇌" },
@@ -300,6 +304,8 @@ export function SidePanel() {
{panelTab === "activity" && <ActivityTab key={selectedNodeId} workspaceId={selectedNodeId} />}
{panelTab === "chat" && <ChatTab key={selectedNodeId} workspaceId={selectedNodeId} data={node.data} />}
{panelTab === "terminal" && <TerminalTab key={selectedNodeId} workspaceId={selectedNodeId} data={node.data} />}
{panelTab === "display" && <DisplayTab key={selectedNodeId} workspaceId={selectedNodeId} />}
{panelTab === "container-config" && <ContainerConfigTab key={selectedNodeId} data={node.data} />}
{panelTab === "config" && <ConfigTab key={selectedNodeId} workspaceId={selectedNodeId} />}
{panelTab === "schedule" && <ScheduleTab key={selectedNodeId} workspaceId={selectedNodeId} />}
{panelTab === "channels" && <ChannelsTab key={selectedNodeId} workspaceId={selectedNodeId} />}
@@ -11,6 +11,8 @@ vi.mock("../tabs/DetailsTab", () => ({ DetailsTab: () => null }));
vi.mock("../tabs/SkillsTab", () => ({ SkillsTab: () => null }));
vi.mock("../tabs/ChatTab", () => ({ ChatTab: () => null }));
vi.mock("../tabs/ConfigTab", () => ({ ConfigTab: () => null }));
vi.mock("../tabs/ContainerConfigTab", () => ({ ContainerConfigTab: () => null }));
vi.mock("../tabs/DisplayTab", () => ({ DisplayTab: () => null }));
vi.mock("../tabs/TerminalTab", () => ({ TerminalTab: () => null }));
vi.mock("../tabs/FilesTab", () => ({ FilesTab: () => null }));
vi.mock("../MemoryInspectorPanel", () => ({ MemoryInspectorPanel: () => null }));
@@ -74,7 +76,7 @@ import { SidePanel } from "../SidePanel";
const TABS = [
"chat", "activity", "details", "skills", "terminal",
"config", "schedule", "channels", "files", "memory", "traces", "events", "audit",
"display", "container-config", "config", "schedule", "channels", "files", "memory", "traces", "events", "audit",
];
describe("SidePanel — ARIA tablist pattern", () => {
@@ -85,10 +87,20 @@ describe("SidePanel — ARIA tablist pattern", () => {
expect(tablist.getAttribute("aria-label")).toBe("Workspace panel tabs");
});
it("renders exactly 13 tab buttons", () => {
it("renders exactly 15 tab buttons", () => {
render(<SidePanel />);
const tabs = screen.getAllByRole("tab");
expect(tabs.length).toBe(13);
expect(tabs.length).toBe(15);
});
it("renders the Display tab", () => {
render(<SidePanel />);
expect(document.getElementById("tab-display")).toBeTruthy();
});
it("renders the Container Config tab", () => {
render(<SidePanel />);
expect(document.getElementById("tab-container-config")).toBeTruthy();
});
it("active tab (chat) has aria-selected='true'", () => {
@@ -99,11 +111,11 @@ describe("SidePanel — ARIA tablist pattern", () => {
expect(chatTab?.getAttribute("aria-selected")).toBe("true");
});
it("all other 12 tabs have aria-selected='false'", () => {
it("all other 14 tabs have aria-selected='false'", () => {
render(<SidePanel />);
const tabs = screen.getAllByRole("tab");
const inactive = tabs.filter((t) => t.id !== "tab-chat");
expect(inactive.length).toBe(12);
expect(inactive.length).toBe(14);
for (const tab of inactive) {
expect(tab.getAttribute("aria-selected")).toBe("false");
}
@@ -116,7 +128,7 @@ describe("SidePanel — ARIA tablist pattern", () => {
const minusOnes = tabs.filter((t) => t.getAttribute("tabindex") === "-1");
expect(zeros.length).toBe(1);
expect(zeros[0].id).toBe("tab-chat");
expect(minusOnes.length).toBe(12);
expect(minusOnes.length).toBe(14);
});
it("active tab has aria-controls='panel-chat' and id='tab-chat'", () => {
@@ -0,0 +1,96 @@
"use client";
import { runtimeDisplayName } from "@/lib/runtime-names";
import type { WorkspaceNodeData } from "@/store/canvas";
type Props = {
data: Pick<
WorkspaceNodeData,
"runtime" | "status" | "needsRestart" | "activeTasks" | "deliveryMode"
| "workspaceAccess" | "maxConcurrentTasks"
>;
};
export function ContainerConfigTab({ data }: Props) {
const runtime = data.runtime || "unknown";
const workspaceAccess = formatAccess(data.workspaceAccess);
const maxConcurrentTasks = data.maxConcurrentTasks ? String(data.maxConcurrentTasks) : "platform-managed";
const mountedPath = "/workspace";
const privilegeStatus = "standard";
const deliveryMode = data.deliveryMode || "push";
return (
<div className="p-4 space-y-4">
<section className="rounded-lg border border-line/50 bg-surface-card/40 p-4">
<div className="mb-3">
<h3 className="text-sm font-semibold text-ink">Container Config</h3>
</div>
<dl className="grid grid-cols-1 gap-2 text-[11px]">
<ConfigRow label="Runtime image" value={runtimeDisplayName(runtime)} detail={runtime} />
<ConfigRow label="Workspace access" value={workspaceAccess} />
<ConfigRow label="Max concurrent tasks" value={maxConcurrentTasks} />
<ConfigRow label="Mounted workspace path" value={mountedPath} />
<ConfigRow label="Container privileges" value={privilegeStatus} />
<ConfigRow label="Delivery mode" value={deliveryMode} />
</dl>
</section>
<section className="rounded-lg border border-line/50 bg-surface-card/40 p-4">
<h3 className="mb-3 text-sm font-semibold text-ink">Session Controls</h3>
<div className="grid grid-cols-2 gap-2">
<ReadOnlyAction label={data.needsRestart ? "Restart required" : "Restart"} />
<ReadOnlyAction label="Reset session" />
</div>
</section>
<section className="rounded-lg border border-line/50 bg-surface-card/40 p-4">
<h3 className="mb-3 text-sm font-semibold text-ink">Status</h3>
<dl className="grid grid-cols-1 gap-2 text-[11px]">
<ConfigRow label="Container status" value={data.status} />
<ConfigRow label="Active tasks" value={String(data.activeTasks ?? 0)} />
<ConfigRow label="Mounted path access" value="available" />
</dl>
</section>
</div>
);
}
function formatAccess(value: string | null | undefined): string {
if (!value) return "none";
return value.replace(/_/g, "-");
}
function ConfigRow({
label,
value,
detail,
}: {
label: string;
value: string;
detail?: string;
}) {
return (
<div className="flex items-start justify-between gap-3 rounded-md bg-surface-sunken/40 px-3 py-2">
<dt className="text-ink-mid">{label}</dt>
<dd className="min-w-0 text-right">
<div className="font-mono text-ink break-words">{value}</div>
{detail && detail !== value && (
<div className="mt-0.5 font-mono text-[10px] text-ink-mid break-words">{detail}</div>
)}
</dd>
</div>
);
}
function ReadOnlyAction({ label }: { label: string }) {
return (
<button
type="button"
disabled
className="rounded-md border border-line/50 bg-surface-sunken/40 px-3 py-2 text-[11px] text-ink-mid disabled:cursor-not-allowed disabled:opacity-70"
>
{label}
</button>
);
}
+96
View File
@@ -0,0 +1,96 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
interface DisplayStatus {
available: boolean;
reason?: string;
mode?: string;
status?: string;
protocol?: string;
width?: number;
height?: number;
}
interface Props {
workspaceId: string;
}
export function DisplayTab({ workspaceId }: Props) {
const [status, setStatus] = useState<DisplayStatus | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setStatus(null);
setError(null);
api
.get<DisplayStatus>(`/workspaces/${workspaceId}/display`)
.then((data) => {
if (!cancelled) setStatus(data);
})
.catch((err) => {
if (!cancelled) setError(err instanceof Error ? err.message : "Display status unavailable");
});
return () => {
cancelled = true;
};
}, [workspaceId]);
if (error) {
return (
<div className="p-5">
<div className="rounded-lg border border-red-500/20 bg-red-950/20 p-4">
<h3 className="text-sm font-medium text-red-200">Display status unavailable</h3>
<p className="mt-2 text-[11px] leading-relaxed text-red-200/75">{error}</p>
</div>
</div>
);
}
if (!status) {
return (
<div className="p-5">
<div className="h-24 rounded-lg border border-line/40 bg-surface-sunken/30 motion-safe:animate-pulse" />
</div>
);
}
if (!status.available) {
const isNotEnabled = status.reason === "display_not_enabled";
return (
<div className="flex min-h-full flex-col items-center justify-center bg-surface-sunken/30 p-8 text-center">
<svg
width="72"
height="72"
viewBox="0 0 72 72"
fill="none"
aria-hidden="true"
className="mb-4 text-ink-mid"
>
<rect x="12" y="14" width="48" height="36" rx="4" stroke="currentColor" strokeWidth="2.5" opacity="0.65" />
<path d="M28 58h16M36 50v8M16 16l40 40" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
</svg>
<h3 className="mb-1.5 text-sm font-medium text-ink">
{isNotEnabled ? "Display is not enabled for this workspace." : "Display session is not ready."}
</h3>
<p className="max-w-xs text-[11px] leading-relaxed text-ink-mid">
{isNotEnabled
? "Recreate this workspace with display enabled to view and take over its desktop."
: "This workspace has display configuration, but the desktop session infrastructure is not configured yet."}
</p>
{!isNotEnabled && (
<dl className="mt-5 grid grid-cols-2 gap-x-4 gap-y-2 text-left text-[11px]">
<dt className="text-ink-mid">Mode</dt>
<dd className="font-mono text-ink">{status.mode || "unknown"}</dd>
<dt className="text-ink-mid">Status</dt>
<dd className="font-mono text-ink">{status.status || "unknown"}</dd>
</dl>
)}
</div>
);
}
return null;
}
@@ -0,0 +1,42 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/runtime-names", () => ({
runtimeDisplayName: (runtime: string) => runtime,
}));
import { ContainerConfigTab } from "../ContainerConfigTab";
afterEach(() => {
cleanup();
});
describe("ContainerConfigTab", () => {
it("renders read-only runtime and container settings separate from compute shape", () => {
render(
<ContainerConfigTab
data={{
runtime: "claude-code",
status: "online",
needsRestart: false,
activeTasks: 2,
maxConcurrentTasks: 3,
workspaceAccess: "read_write",
deliveryMode: "poll",
}}
/>,
);
expect(screen.getByText("Runtime image")).toBeTruthy();
expect(screen.getByText("claude-code")).toBeTruthy();
expect(screen.getByText("Workspace access")).toBeTruthy();
expect(screen.getByText("read-write")).toBeTruthy();
expect(screen.getByText("Max concurrent tasks")).toBeTruthy();
expect(screen.getByText("3")).toBeTruthy();
expect(screen.getByText("/workspace")).toBeTruthy();
expect(screen.getByText("Container privileges")).toBeTruthy();
expect(screen.queryByText("Instance type")).toBeNull();
expect(screen.queryByText("Root volume")).toBeNull();
});
});
@@ -0,0 +1,33 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
const { mockGet } = vi.hoisted(() => ({ mockGet: vi.fn() }));
vi.mock("@/lib/api", () => ({
api: {
get: mockGet,
},
}));
import { DisplayTab } from "../DisplayTab";
describe("DisplayTab", () => {
beforeEach(() => {
mockGet.mockReset();
});
it("renders unavailable state for non-display workspaces", async () => {
mockGet.mockResolvedValueOnce({
available: false,
reason: "display_not_enabled",
});
render(<DisplayTab workspaceId="ws-no-display" />);
await waitFor(() => {
expect(screen.getByText("Display is not enabled for this workspace.")).toBeTruthy();
});
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-no-display/display");
});
});
+2
View File
@@ -513,6 +513,8 @@ export function buildNodesAndEdges(
parentId: ws.parent_id,
currentTask: ws.current_task || "",
runtime: ws.runtime || "",
workspaceAccess: ws.workspace_access,
maxConcurrentTasks: ws.max_concurrent_tasks ?? null,
needsRestart: false,
budgetLimit: ws.budget_limit ?? null,
budgetUsed: ws.budget_used ?? null,
+3 -1
View File
@@ -88,6 +88,8 @@ export interface WorkspaceNodeData extends Record<string, unknown> {
parentId: string | null;
currentTask: string;
runtime: string;
workspaceAccess?: string | null;
maxConcurrentTasks?: number | null;
needsRestart: boolean;
/** USD spend ceiling set by the user; null = unlimited. Added by issue #541. */
budgetLimit: number | null;
@@ -130,7 +132,7 @@ export interface WorkspaceNodeData extends Record<string, unknown> {
deliveryMode?: string;
}
export type PanelTab = "details" | "skills" | "chat" | "terminal" | "config" | "schedule" | "channels" | "files" | "memory" | "traces" | "events" | "activity" | "audit";
export type PanelTab = "details" | "skills" | "chat" | "terminal" | "display" | "container-config" | "config" | "schedule" | "channels" | "files" | "memory" | "traces" | "events" | "activity" | "audit";
export interface ContextMenuState {
x: number;
+2
View File
@@ -320,11 +320,13 @@ export interface WorkspaceData {
url: string;
parent_id: string | null;
active_tasks: number;
max_concurrent_tasks?: number | null;
last_error_rate: number;
last_sample_error: string;
uptime_seconds: number;
current_task: string;
runtime: string;
workspace_access?: string | null;
x: number;
y: number;
collapsed: boolean;
@@ -285,6 +285,39 @@ Canvas requests (no `X-Workspace-ID` header) and system callers
---
## Multiple Workspaces From One Local MCP Bridge
The standalone runtime package includes `molecule-mcp`, a local MCP bridge for
external agents such as Claude Code, Codex, Hermes, and other tools that run
outside the platform container fleet. One local bridge can serve multiple
external workspaces by setting `MOLECULE_WORKSPACES`:
```json
[
{
"id": "workspace-id-local-to-hongming-org",
"token": "...",
"platform_url": "https://hongming.moleculesai.app"
},
{
"id": "different-workspace-id-local-to-agents-team-org",
"token": "...",
"platform_url": "https://agents-team.moleculesai.app"
}
]
```
`platform_url` is the tenant routing key. The bridge registers, heartbeats,
polls inboxes, and sends outbound A2A calls against the URL attached to the
workspace that is doing the work.
Do not add `org_id` to this config. The tenant already comes from
`platform_url`, and the bearer token is issued by that tenant. Workspace IDs
also do not need to be shared across orgs; each tenant can return its own
workspace ID and token for the same local agent process.
---
## Canvas Appearance
External workspaces appear on the canvas with a purple **REMOTE** badge
@@ -135,6 +135,33 @@ The `id` field is your workspace ID — remember it.
---
## Optional — one local MCP bridge, multiple tenants
If your local agent runtime uses `molecule-mcp`, one process can serve more
than one external workspace:
```bash
export MOLECULE_WORKSPACES='[
{
"id": "workspace-id-local-to-you-org",
"token": "...",
"platform_url": "https://you.moleculesai.app"
},
{
"id": "different-workspace-id-local-to-team-org",
"token": "...",
"platform_url": "https://team.moleculesai.app"
}
]'
molecule-mcp
```
Use the workspace ID and token returned by each tenant. The IDs may differ
across orgs. `org_id` is not required here because `platform_url` selects the
tenant and the token is tenant-scoped.
---
## Step 4 — Chat with it
1. Open your Molecule canvas at `https://<TENANT>`
+27
View File
@@ -125,6 +125,33 @@ The agent appears on the canvas with a **purple REMOTE badge** within seconds. F
---
## Multi-Tenant Local MCP Bridge
For local MCP-driven agents, use the standalone runtime's `molecule-mcp`
entrypoint. A single local bridge can serve multiple external workspaces by
setting `MOLECULE_WORKSPACES`:
```json
[
{
"id": "workspace-id-local-to-acme",
"token": "...",
"platform_url": "https://acme.moleculesai.app"
},
{
"id": "different-workspace-id-local-to-ops",
"token": "...",
"platform_url": "https://ops.moleculesai.app"
}
]
```
`platform_url` selects the tenant for registration, heartbeat, inbox polling,
and outbound A2A routing. `org_id` is not required in this config, and the
workspace IDs do not need to match across tenants.
---
## What Phase 30 Covers
| Phase | What shipped | Endpoint |
+1 -1
View File
@@ -36,7 +36,7 @@ e2e_mint_test_token() {
local admin_bearer="${MOLECULE_ADMIN_TOKEN:-${ADMIN_TOKEN:-}}"
local admin_auth=()
[ -n "$admin_bearer" ] && admin_auth=(-H "Authorization: Bearer $admin_bearer")
body=$(curl -s -w "\n%{http_code}" "$BASE/admin/workspaces/$wid/test-token" "${admin_auth[@]}")
body=$(curl -s -w "\n%{http_code}" "$BASE/admin/workspaces/$wid/test-token" ${admin_auth[@]+"${admin_auth[@]}"})
local code
code=$(printf '%s' "$body" | tail -n1)
local json
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env bash
# EC2 leak check for staging E2E harnesses.
#
# Modes:
# E2E_AWS_LEAK_CHECK=off skip
# E2E_AWS_LEAK_CHECK=auto check only when aws + credentials exist
# E2E_AWS_LEAK_CHECK=required fail if aws + credentials are unavailable
#
# Optional:
# E2E_AWS_LEAK_CHECK_SECS poll budget, default 90
# E2E_AWS_LEAK_CHECK_INTERVAL poll interval, default 10
# E2E_AWS_TERMINATE_LEAKS=1 terminate matching leaked instances
e2e_aws_leak_mode() {
echo "${E2E_AWS_LEAK_CHECK:-auto}"
}
e2e_aws_region() {
echo "${E2E_AWS_REGION:-${AWS_REGION:-${AWS_DEFAULT_REGION:-us-east-2}}}"
}
e2e_aws_creds_available() {
command -v aws >/dev/null 2>&1 || return 1
[ -n "${AWS_ACCESS_KEY_ID:-}" ] || return 1
[ -n "${AWS_SECRET_ACCESS_KEY:-}" ] || return 1
}
e2e_ec2_instances_for_slug() {
local slug="$1"
local region
region=$(e2e_aws_region)
# shellcheck disable=SC2016
aws ec2 describe-instances \
--region "$region" \
--filters "Name=tag:Name,Values=*$slug*" \
"Name=instance-state-name,Values=pending,running,stopping,stopped" \
--query 'Reservations[].Instances[].[InstanceId,State.Name,Tags[?Key==`Name`].Value|[0]]' \
--output text
}
e2e_terminate_instances() {
local ids="$1"
local region
region=$(e2e_aws_region)
[ -n "$ids" ] || return 0
# shellcheck disable=SC2086
aws ec2 terminate-instances --region "$region" --instance-ids $ids >/dev/null
}
e2e_verify_no_ec2_leaks_for_slug() {
local slug="$1"
local mode
local max_secs
local interval
local elapsed=0
local rows=""
local ids=""
mode=$(e2e_aws_leak_mode)
case "$mode" in
off)
echo "[aws-leak-check] skipped: E2E_AWS_LEAK_CHECK=off" >&2
return 0
;;
auto|required) ;;
*)
echo "[aws-leak-check] invalid E2E_AWS_LEAK_CHECK=$mode (expected off|auto|required)" >&2
return 2
;;
esac
if ! e2e_aws_creds_available; then
if [ "$mode" = "required" ]; then
echo "[aws-leak-check] required but aws CLI or AWS credentials are unavailable" >&2
return 2
fi
echo "[aws-leak-check] skipped: aws CLI or AWS credentials unavailable" >&2
return 0
fi
max_secs="${E2E_AWS_LEAK_CHECK_SECS:-90}"
interval="${E2E_AWS_LEAK_CHECK_INTERVAL:-10}"
while true; do
rows=$(e2e_ec2_instances_for_slug "$slug" 2>&1) || {
echo "[aws-leak-check] aws ec2 describe-instances failed for slug=$slug" >&2
echo "$rows" >&2
return 2
}
if [ -z "$rows" ] || [ "$rows" = "None" ]; then
echo "[aws-leak-check] no live EC2 instances for slug=$slug" >&2
return 0
fi
if [ "$elapsed" -ge "$max_secs" ]; then
echo "[aws-leak-check] leaked EC2 instance(s) for slug=$slug after ${elapsed}s:" >&2
echo "$rows" >&2
if [ "${E2E_AWS_TERMINATE_LEAKS:-0}" = "1" ]; then
ids=$(echo "$rows" | awk 'NF {print $1}' | sort -u | tr '\n' ' ')
echo "[aws-leak-check] terminating leaked EC2 instance(s): $ids" >&2
e2e_terminate_instances "$ids" || {
echo "[aws-leak-check] terminate-instances failed for: $ids" >&2
return 4
}
fi
return 4
fi
sleep "$interval"
elapsed=$((elapsed + interval))
done
}
+21 -6
View File
@@ -19,11 +19,18 @@
# PR #2558+#2563+#2567 cleared the
# masking layers.)
#
# claude-code → "sonnet" (entry-id form: claude-code template's
# config.yaml uses bare model names,
# auth comes via CLAUDE_CODE_OAUTH_TOKEN
# or ANTHROPIC_API_KEY rather than the
# slug.)
# claude-code → auth-aware:
# E2E_MINIMAX_API_KEY → "MiniMax-M2"
# E2E_ANTHROPIC_API_KEY → "claude-sonnet-4-6"
# otherwise → "sonnet"
#
# claude-code provider routing is model-driven. The bare
# "sonnet" alias selects the OAuth provider, so it is only a
# good default when the canary is using Claude Code OAuth or
# intentionally exercising the missing-auth path. MiniMax and
# direct Anthropic API keys need model IDs that resolve to
# their provider entries, otherwise the workspace boots
# reachable but the first A2A call hits the wrong auth path.
#
# When E2E_MODEL_SLUG is set, it overrides this dispatch — useful when an
# operator dispatches the workflow to test a specific slug.
@@ -45,7 +52,15 @@ pick_model_slug() {
case "$runtime" in
hermes) printf 'openai/gpt-4o' ;;
langgraph) printf 'openai:gpt-4o' ;;
claude-code) printf 'sonnet' ;;
claude-code)
if [ -n "${E2E_MINIMAX_API_KEY:-}" ]; then
printf 'MiniMax-M2'
elif [ -n "${E2E_ANTHROPIC_API_KEY:-}" ]; then
printf 'claude-sonnet-4-6'
else
printf 'sonnet'
fi
;;
*) printf 'openai/gpt-4o' ;; # safest fallback (matches hermes)
esac
}
+1 -1
View File
@@ -71,7 +71,7 @@ pv_assert_runtime() {
set +e
resp=$(curl -sS -X POST "$base_url/workspaces/$wid/mcp" \
-H "Authorization: Bearer $wtok" \
"${org_header[@]}" \
${org_header[@]+"${org_header[@]}"} \
-H "Content-Type: application/json" \
-d "$PV_RPC_BODY" \
-o /tmp/pv_mcp_body.json -w "%{http_code}" 2>/dev/null)
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env bash
set -uo pipefail
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
# shellcheck disable=SC1091
# shellcheck source=lib/aws_leak_check.sh
source "$SCRIPT_DIR/lib/aws_leak_check.sh"
PASS=0
FAIL=0
TMPDIR_E2E=$(mktemp -d -t aws-leak-check-e2e-XXXXXX)
trap 'rm -rf "$TMPDIR_E2E"' EXIT INT TERM
make_fake_aws() {
local body="$1"
mkdir -p "$TMPDIR_E2E/bin"
cat > "$TMPDIR_E2E/bin/aws" <<EOF
#!/usr/bin/env bash
set -euo pipefail
echo "\$*" >> "$TMPDIR_E2E/aws.calls"
$body
EOF
chmod +x "$TMPDIR_E2E/bin/aws"
}
reset_env() {
/bin/rm -f "$TMPDIR_E2E/aws.calls"
export PATH="$TMPDIR_E2E/bin:$ORIG_PATH"
export AWS_ACCESS_KEY_ID=test-access
export AWS_SECRET_ACCESS_KEY=test-secret
export AWS_DEFAULT_REGION=us-east-2
export E2E_AWS_LEAK_CHECK=required
export E2E_AWS_LEAK_CHECK_SECS=0
export E2E_AWS_LEAK_CHECK_INTERVAL=1
unset E2E_AWS_TERMINATE_LEAKS
}
assert_rc() {
local label="$1"
local expected="$2"
shift 2
local observed
"$@" >/tmp/aws-leak-check.out 2>/tmp/aws-leak-check.err
observed=$?
if [ "$observed" = "$expected" ]; then
echo " PASS $label"
PASS=$((PASS + 1))
else
echo " FAIL $label: expected rc=$expected observed=$observed" >&2
echo " stderr:" >&2
sed 's/^/ /' /tmp/aws-leak-check.err >&2
FAIL=$((FAIL + 1))
fi
}
ORIG_PATH="$PATH"
echo "Test: AWS EC2 leak check helper"
reset_env
/bin/rm -rf "${TMPDIR_E2E:?}/bin"
/bin/mkdir -p "$TMPDIR_E2E/noaws"
export PATH="$TMPDIR_E2E/noaws"
export E2E_AWS_LEAK_CHECK=auto
assert_rc "auto mode skips when aws is unavailable" 0 e2e_verify_no_ec2_leaks_for_slug e2e-smoke-test
reset_env
/bin/rm -rf "${TMPDIR_E2E:?}/bin"
/bin/mkdir -p "$TMPDIR_E2E/noaws"
export PATH="$TMPDIR_E2E/noaws"
export E2E_AWS_LEAK_CHECK=required
assert_rc "required mode fails when aws is unavailable" 2 e2e_verify_no_ec2_leaks_for_slug e2e-smoke-test
reset_env
# shellcheck disable=SC2016
make_fake_aws 'if [ "$1 $2" = "ec2 describe-instances" ]; then exit 0; fi'
assert_rc "no matching EC2 returns clean" 0 e2e_verify_no_ec2_leaks_for_slug e2e-smoke-test
reset_env
# shellcheck disable=SC2016
make_fake_aws 'if [ "$1 $2" = "ec2 describe-instances" ]; then echo "i-123 running ws-tenant-e2e-smoke-test-abc"; exit 0; fi'
assert_rc "persistent matching EC2 is a leak" 4 e2e_verify_no_ec2_leaks_for_slug e2e-smoke-test
reset_env
export E2E_AWS_TERMINATE_LEAKS=1
# shellcheck disable=SC2016
make_fake_aws '
if [ "$1 $2" = "ec2 describe-instances" ]; then
echo "i-123 running ws-tenant-e2e-smoke-test-abc"
exit 0
fi
if [ "$1 $2" = "ec2 terminate-instances" ]; then
echo "terminated" >/dev/null
exit 0
fi
'
assert_rc "terminate mode attempts cleanup before returning leak" 4 e2e_verify_no_ec2_leaks_for_slug e2e-smoke-test
if grep -q "terminate-instances" "$TMPDIR_E2E/aws.calls"; then
echo " PASS terminate-instances was called"
PASS=$((PASS + 1))
else
echo " FAIL terminate-instances was not called" >&2
FAIL=$((FAIL + 1))
fi
echo
echo "passed=$PASS failed=$FAIL"
[ "$FAIL" = "0" ]
+11 -2
View File
@@ -16,7 +16,7 @@ set -uo pipefail
# Resolve to the lib relative to this test file so the test runs from
# any cwd (CI, local invocation, repo root).
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/model_slug.sh
# shellcheck source=tests/e2e/lib/model_slug.sh
source "$SCRIPT_DIR/lib/model_slug.sh"
PASS=0
@@ -48,7 +48,16 @@ echo
# ── Per-runtime branches (the load-bearing ones for synth-E2E) ──
run_test "hermes → slash-form (derive-provider.sh contract)" hermes "openai/gpt-4o"
run_test "langgraph → colon-form (init_chat_model contract)" langgraph "openai:gpt-4o"
run_test "claude-code → bare model name (entry-id form)" claude-code "sonnet"
run_test "claude-code → OAuth/default alias" claude-code "sonnet"
got=$(unset E2E_MODEL_SLUG E2E_ANTHROPIC_API_KEY; E2E_MINIMAX_API_KEY="mx-test" pick_model_slug claude-code)
assert_eq "claude-code + MiniMax key → MiniMax model" "$got" "MiniMax-M2"
got=$(unset E2E_MODEL_SLUG E2E_MINIMAX_API_KEY; E2E_ANTHROPIC_API_KEY="sk-ant-test" pick_model_slug claude-code)
assert_eq "claude-code + Anthropic API key → Anthropic API model" "$got" "claude-sonnet-4-6"
got=$(unset E2E_MODEL_SLUG; E2E_MINIMAX_API_KEY="mx-priority" E2E_ANTHROPIC_API_KEY="sk-ant-loser" pick_model_slug claude-code)
assert_eq "claude-code + both keys → MiniMax priority" "$got" "MiniMax-M2"
# ── Fallback for unknown runtime ──
# Picks slash-form (hermes-shaped) since hermes is the historical
+9 -8
View File
@@ -24,7 +24,8 @@
#
# Only PROVISIONING differs from staging:
# - staging: POST /cp/admin/orgs (cold EC2 tenant) + per-tenant admin
# token + each workspace's auth_token from the POST /workspaces resp.
# token + each workspace's MCP bearer from create response or an admin
# token-mint fallback.
# - local: POST /workspaces directly against the local stack
# (BASE, default http://localhost:8080), MCP bearer minted via
# GET /admin/workspaces/:id/test-token (e2e_mint_test_token —
@@ -103,7 +104,7 @@ teardown() {
log "[teardown] deleting ${#CREATED_WSIDS[@]} workspace(s) this run created (scoped)"
for wid in ${CREATED_WSIDS[@]+"${CREATED_WSIDS[@]}"}; do
[ -n "$wid" ] || continue
curl -s -X DELETE "$BASE/workspaces/$wid?confirm=true" "${ADMIN_AUTH[@]}" >/dev/null 2>&1 || true
curl -s -X DELETE "$BASE/workspaces/$wid?confirm=true" ${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"} >/dev/null 2>&1 || true
done
exit $rc
}
@@ -112,7 +113,7 @@ trap teardown EXIT INT TERM
# Pre-sweep workspaces a prior crashed run of THIS script left behind
# (name prefix match only — never a blanket delete). The trap fires on
# normal exit, but a kill -9 / SIGPIPE can bypass it.
PRIOR=$(curl -s "$BASE/workspaces" "${ADMIN_AUTH[@]}" | python3 -c '
PRIOR=$(curl -s "$BASE/workspaces" ${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"} | python3 -c '
import json, sys
try:
print(" ".join(w["id"] for w in json.load(sys.stdin) if w.get("name","").startswith("PV-Local-")))
@@ -121,7 +122,7 @@ except Exception:
' 2>/dev/null)
for _wid in $PRIOR; do
log "Pre-sweeping prior PV-Local workspace: $_wid"
curl -s -X DELETE "$BASE/workspaces/$_wid?confirm=true" "${ADMIN_AUTH[@]}" >/dev/null 2>&1 || true
curl -s -X DELETE "$BASE/workspaces/$_wid?confirm=true" ${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"} >/dev/null 2>&1 || true
done
# ─── Local-stack preflight ─────────────────────────────────────────────
@@ -132,10 +133,10 @@ if ! curl -fsS "$BASE/health" -m 5 >/dev/null 2>&1; then
fi
# admin/test-token is the local MCP-bearer mint path; it 404s in
# production. If it is off, this gate cannot drive the literal call.
if ! curl -fsS "$BASE/admin/workspaces/preflight-probe/test-token" "${ADMIN_AUTH[@]}" -m 5 >/dev/null 2>&1; then
if ! curl -fsS "$BASE/admin/workspaces/preflight-probe/test-token" ${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"} -m 5 >/dev/null 2>&1; then
# A 404 here is EITHER "no such ws" (fine — endpoint is enabled) OR the
# endpoint is disabled (MOLECULE_ENV=production). Distinguish by body.
PROBE=$(curl -s "$BASE/admin/workspaces/preflight-probe/test-token" "${ADMIN_AUTH[@]}" -m 5 2>/dev/null)
PROBE=$(curl -s "$BASE/admin/workspaces/preflight-probe/test-token" ${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"} -m 5 2>/dev/null)
if echo "$PROBE" | grep -qi 'production\|disabled\|not found.*endpoint'; then
echo "::error::GET /admin/workspaces/:id/test-token disabled (MOLECULE_ENV=production?). Cannot mint a local MCP bearer." >&2
exit 1
@@ -240,7 +241,7 @@ else
fi
log "1/5 provisioning parent ($PARENT_RUNTIME, mode=$PV_LOCAL_PROVISION_MODE) + one sibling per runtime under test..."
P_RESP=$(curl -s -X POST "$BASE/workspaces" "${ADMIN_AUTH[@]}" -H "Content-Type: application/json" \
P_RESP=$(curl -s -X POST "$BASE/workspaces" ${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"} -H "Content-Type: application/json" \
-d "{\"name\":\"${NAME_PREFIX}-parent\",\"runtime\":\"$PARENT_RUNTIME\",\"tier\":3$PARENT_EXTRA,\"secrets\":$PARENT_SECRETS}")
PARENT_ID=$(echo "$P_RESP" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("id",""))' 2>/dev/null)
if [ -z "$PARENT_ID" ]; then
@@ -290,7 +291,7 @@ for rt in $PV_RUNTIMES; do
CREATE_RUNTIME="$rt"
CREATE_EXTRA=""
fi
R=$(curl -s -X POST "$BASE/workspaces" "${ADMIN_AUTH[@]}" -H "Content-Type: application/json" \
R=$(curl -s -X POST "$BASE/workspaces" ${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"} -H "Content-Type: application/json" \
-d "{\"name\":\"${NAME_PREFIX}-$rt\",\"runtime\":\"$CREATE_RUNTIME\",\"tier\":2,\"parent_id\":\"$PARENT_ID\"$CREATE_EXTRA,\"secrets\":$SEC}")
WID=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("id",""))' 2>/dev/null)
if [ -z "$WID" ]; then
+83 -18
View File
@@ -40,8 +40,10 @@
# drives: POST /cp/admin/orgs (provision), GET
# /cp/admin/orgs/:slug/admin-token (per-tenant token), DELETE
# /cp/admin/tenants/:slug (teardown). The per-tenant admin token drives
# tenant workspace creation; each workspace's OWN auth_token (returned by
# POST /workspaces) drives its MCP call.
# tenant workspace creation; each workspace's OWN auth_token drives its
# MCP call. External-like runtimes may return the token in POST
# /workspaces; managed container runtimes usually require the admin token
# mint fallback below.
#
# Required env:
# MOLECULE_ADMIN_TOKEN CP admin bearer — Railway staging CP_ADMIN_API_TOKEN
@@ -52,6 +54,9 @@
# E2E_PROVISION_TIMEOUT_SECS default 1800 (hermes/openclaw cold EC2 budget)
# E2E_MINIMAX_API_KEY / E2E_ANTHROPIC_API_KEY / E2E_OPENAI_API_KEY
# LLM provider key injected so the runtime can boot
# PV_TOKEN_DIAGNOSTIC_ONLY
# 1 -> stop after create/token acquisition. Useful
# to classify Hermes-only vs shared auth-route issues.
# E2E_KEEP_ORG 1 → skip teardown (local debugging only)
#
# Exit codes:
@@ -104,6 +109,46 @@ tenant_call() {
-H "Content-Type: application/json" "$@"
}
tenant_call_capture() {
local method="$1" path="$2" out="$3"; shift 3
curl -sS -o "$out" -w "%{http_code}" -X "$method" "$TENANT_URL$path" \
-H "Authorization: Bearer $TENANT_TOKEN" \
-H "X-Molecule-Org-Id: $ORG_ID" \
-H "Content-Type: application/json" "$@"
}
redact_token_body() {
python3 -c '
import json, re, sys
raw = sys.stdin.read()
try:
data = json.loads(raw)
except Exception:
print(re.sub(r"(?i)([a-z0-9_]*token)=([^&\\s]+)", r"\1=<redacted>", raw)[:500])
raise SystemExit(0)
def scrub(v):
if isinstance(v, dict):
return {k: ("<redacted>" if "token" in k.lower() else scrub(val)) for k, val in v.items()}
if isinstance(v, list):
return [scrub(x) for x in v]
return v
print(json.dumps(scrub(data), separators=(",", ":"))[:500])
'
}
extract_auth_token() {
python3 -c "
import sys, json
try:
d = json.load(sys.stdin)
except Exception:
print(''); sys.exit(0)
print(d.get('auth_token') or d.get('connection', {}).get('auth_token') or '')
" 2>/dev/null
}
# ─── Scoped teardown ───────────────────────────────────────────────────
# Deletes ONLY the org this run created (DELETE /cp/admin/tenants/$SLUG
# with the {"confirm":$SLUG} fat-finger guard). Never a cluster-wide
@@ -190,6 +235,12 @@ for i in $(seq 1 120); do
curl -fsS "$TENANT_URL/health" -m 5 -k >/dev/null 2>&1 && { log " /health ok (attempt $i)"; break; }
sleep 5
done
BUILDINFO=$(curl -fsS "$TENANT_URL/buildinfo" -m 10 2>/dev/null || true)
if [ -n "$BUILDINFO" ]; then
log " tenant buildinfo: $(echo "$BUILDINFO" | head -c 300)"
else
log " tenant buildinfo unavailable"
fi
# ─── 4. Provision the parent + one sibling per runtime under test ──────
# Inject the LLM provider key so each runtime can authenticate at boot.
@@ -214,35 +265,49 @@ log " PARENT_ID=$PARENT_ID"
# WS_IDS[runtime]=id ; WS_TOKENS[runtime]=auth_token (the MCP bearer)
declare -A WS_IDS WS_TOKENS
ALL_WS_IDS="$PARENT_ID"
TOKEN_ERRORS=0
TOKEN_ERROR_SUMMARY=""
for rt in $PV_RUNTIMES; do
R=$(tenant_call POST /workspaces \
-d "{\"name\":\"pv-$rt\",\"runtime\":\"$rt\",\"tier\":2,\"parent_id\":\"$PARENT_ID\",\"secrets\":$SECRETS_JSON}")
WID=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
# auth_token is top-level for container runtimes; external-like nest it
# under connection.auth_token (verified vs staging response shape).
WTOK=$(echo "$R" | python3 -c "
import sys, json
try: d = json.load(sys.stdin)
except Exception: print(''); sys.exit(0)
print(d.get('auth_token') or d.get('connection', {}).get('auth_token') or '')
" 2>/dev/null)
# External-like runtimes may return connection.auth_token on create.
# Managed container runtimes usually return only id/status here, then
# receive their bearer through registry/bootstrap; for this literal MCP
# driver we mint through the production-safe admin token route below.
WTOK=$(echo "$R" | extract_auth_token)
[ -n "$WID" ] || fail "$rt workspace create failed: $(echo "$R" | head -c 300)"
TOKEN_DIAG=""
if [ -z "$WTOK" ]; then
TTOK_RESP=$(tenant_call GET "/admin/workspaces/$WID/test-token" 2>/dev/null || true)
WTOK=$(echo "$TTOK_RESP" | python3 -c "
import sys, json
try: d = json.load(sys.stdin)
except Exception: print(''); sys.exit(0)
print(d.get('auth_token') or '')
" 2>/dev/null)
TTOK_FILE=$(mktemp)
TTOK_CODE=$(tenant_call_capture POST "/admin/workspaces/$WID/tokens" "$TTOK_FILE" 2>/dev/null || echo "curl_error")
TTOK_RESP=$(cat "$TTOK_FILE" 2>/dev/null || true)
WTOK=$(echo "$TTOK_RESP" | extract_auth_token)
TOKEN_DIAG="POST /admin/workspaces/$WID/tokens -> HTTP $TTOK_CODE body: $(echo "$TTOK_RESP" | redact_token_body)"
rm -f "$TTOK_FILE"
fi
[ -n "$WTOK" ] || fail "$rt workspace did not return or mint an auth_token — cannot drive its MCP call (resp: $(echo "$R" | head -c 300))"
WS_IDS[$rt]="$WID"
if [ -z "$WTOK" ]; then
TOKEN_ERRORS=$((TOKEN_ERRORS + 1))
TOKEN_ERROR_SUMMARY="${TOKEN_ERROR_SUMMARY}
[$rt] workspace did not return or mint an auth_token — cannot drive its MCP call (workspace_id=$WID; create_resp: $(echo "$R" | redact_token_body); token_fallbacks: $TOKEN_DIAG)"
log " $rt$WID (token acquisition failed; continuing to classify other runtimes)"
continue
fi
WS_TOKENS[$rt]="$WTOK"
ALL_WS_IDS="$ALL_WS_IDS $WID"
log " $rt$WID"
done
if [ "$TOKEN_ERRORS" -gt 0 ]; then
fail "token acquisition failed for $TOKEN_ERRORS runtime(s):$TOKEN_ERROR_SUMMARY"
fi
if [ "${PV_TOKEN_DIAGNOSTIC_ONLY:-0}" = "1" ]; then
ok "token diagnostic passed for runtimes: $PV_RUNTIMES"
exit 0
fi
# ─── 5. Wait for every sibling online ──────────────────────────────────
log "5/6 waiting for all workspaces status=online (up to ${PROVISION_TIMEOUT_SECS}s — cold boot)..."
WS_DEADLINE=$(( $(date +%s) + PROVISION_TIMEOUT_SECS ))
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Staging E2E diagnostic — classify peer-visibility token acquisition.
#
# This is intentionally narrower than test_peer_visibility_mcp_staging.sh:
# it provisions the same throwaway org, creates managed sibling workspaces,
# and stops immediately after auth_token acquisition. The default runtime set
# compares hermes with claude-code so a failure is easy to classify:
# - hermes fails, claude-code passes -> Hermes/runtime-specific
# - both fail -> shared admin/auth/proxy route
#
# Required env matches test_peer_visibility_mcp_staging.sh:
# MOLECULE_ADMIN_TOKEN
# Optional:
# MOLECULE_CP_URL, E2E_RUN_ID, PV_RUNTIMES, E2E_KEEP_ORG,
# E2E_MINIMAX_API_KEY / E2E_ANTHROPIC_API_KEY / E2E_OPENAI_API_KEY
set -euo pipefail
export PV_RUNTIMES="${PV_RUNTIMES:-hermes claude-code}"
export PV_TOKEN_DIAGNOSTIC_ONLY=1
exec "$(dirname "${BASH_SOURCE[0]}")/test_peer_visibility_mcp_staging.sh"
+201 -68
View File
@@ -25,6 +25,11 @@
# Optional env:
# E2E_RUNTIME hermes (default) | claude-code | langgraph
# E2E_PROVISION_TIMEOUT_SECS default 900 (15 min cold EC2 budget)
# E2E_WORKSPACE_ONLINE_TIMEOUT_SECS default 3600 (60 min — hermes
# cold-boot worst-case + slack). Raised from
# 1800 (#1646) because flaky tenant-provisioning
# latency (not a code regression) causes
# alternating pass/fail on identical SHAs.
# E2E_KEEP_ORG 1 → skip teardown (debugging only)
# E2E_RUN_ID Slug suffix; CI: ${GITHUB_RUN_ID}
# E2E_MODE full (default) | smoke
@@ -32,6 +37,11 @@
# mapped to `smoke` for back-compat with
# any in-flight runner picking up an older
# workflow checkout)
# E2E_AWS_LEAK_CHECK auto (default) | required | off
# required in CI so teardown cannot report
# clean while slug-tagged EC2 remains alive
# E2E_AWS_TERMINATE_LEAKS 1 → terminate slug-tagged leaked EC2 before
# exiting 4
# E2E_INTENTIONAL_FAILURE 1 → poison tenant token mid-run so the
# script fails; the EXIT trap MUST still
# tear down cleanly (and exit 4 on leak).
@@ -51,6 +61,7 @@ CP_URL="${MOLECULE_CP_URL:-https://staging-api.moleculesai.app}"
ADMIN_TOKEN="${MOLECULE_ADMIN_TOKEN:?MOLECULE_ADMIN_TOKEN required — Railway staging CP_ADMIN_API_TOKEN}"
RUNTIME="${E2E_RUNTIME:-hermes}"
PROVISION_TIMEOUT_SECS="${E2E_PROVISION_TIMEOUT_SECS:-900}"
WORKSPACE_ONLINE_TIMEOUT_SECS="${E2E_WORKSPACE_ONLINE_TIMEOUT_SECS:-3600}"
RUN_ID_SUFFIX="${E2E_RUN_ID:-$(date +%H%M%S)-$$}"
MODE="${E2E_MODE:-full}"
# `canary` is a legacy alias for `smoke` retained for back-compat with
@@ -82,8 +93,12 @@ ok() { echo "[$(date +%H:%M:%S)] ✅ $*"; }
# Per-runtime model slug dispatch — see lib/model_slug.sh for the rationale.
# Extracted so unit tests (tests/e2e/test_model_slug.sh) can pin every branch
# without booting the full 11-step lifecycle.
# shellcheck disable=SC1091
# shellcheck source=lib/model_slug.sh
source "$(dirname "$0")/lib/model_slug.sh"
# shellcheck disable=SC1091
# shellcheck source=lib/aws_leak_check.sh
source "$(dirname "$0")/lib/aws_leak_check.sh"
CURL_COMMON=(-sS --fail-with-body --max-time 30)
@@ -119,12 +134,14 @@ cleanup_org() {
# DELETE returns 5xx mid-cascade and the cascade finishes anyway,
# and the case where DELETE legitimately exceeds 120s and we want
# eventual-consistency confirmation.
curl "${CURL_COMMON[@]}" --max-time 120 -X DELETE "$CP_URL/cp/admin/tenants/$SLUG" \
if curl "${CURL_COMMON[@]}" --max-time 120 -X DELETE "$CP_URL/cp/admin/tenants/$SLUG" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"confirm\":\"$SLUG\"}" >/dev/null 2>&1 \
&& ok "Teardown request accepted" \
|| log "Teardown returned non-2xx (may already be gone)"
-d "{\"confirm\":\"$SLUG\"}" >/dev/null 2>&1; then
ok "Teardown request accepted"
else
log "Teardown returned non-2xx (may already be gone)"
fi
local leak_count=1
local elapsed=0
@@ -144,7 +161,15 @@ cleanup_org() {
echo "⚠️ LEAK: org $SLUG still present post-teardown after ${elapsed}s (count=$leak_count)" >&2
exit 4
fi
ok "Teardown clean — no orphan resources for $SLUG (${elapsed}s)"
local aws_leak_rc=0
e2e_verify_no_ec2_leaks_for_slug "$SLUG" || aws_leak_rc=$?
if [ "$aws_leak_rc" != "0" ]; then
case "$aws_leak_rc" in
2) exit 2 ;;
*) exit 4 ;;
esac
fi
ok "Teardown clean — no orphan org or EC2 resources for $SLUG (${elapsed}s)"
# Normalize unexpected upstream exit codes to 1 (generic failure). The
# script's documented contract (header "Exit codes" section) only emits
@@ -331,6 +356,75 @@ tenant_call() {
"$@"
}
sanitize_http_body() {
python3 -c '
import re, sys
s = sys.stdin.read()
s = re.sub(r"(?i)(Authorization:\s*Bearer\s+)[A-Za-z0-9._~+/=-]+", r"\1[redacted]", s)
s = re.sub(r"(?i)(\"(?:auth_token|access_token|refresh_token|token|api_key|secret|password)\"\s*:\s*\")[^\"]+\"", r"\1[redacted]\"", s)
s = re.sub(r"(?i)((?:auth_token|access_token|refresh_token|api_key|secret|password)=)[^&\s]+", r"\1[redacted]", s)
print(s[:4000])
'
}
wait_workspaces_online_routable() {
local label="$1"; shift
local deadline=$(( $(date +%s) + WORKSPACE_ONLINE_TIMEOUT_SECS ))
local wid ws_last_status ws_last_url ws_url_missing_logged ws_failed_logged
local ws_json ws_status ws_url ws_last_err
log "$label"
for wid in "$@"; do
ws_last_status=""
ws_last_url=""
ws_url_missing_logged=0
ws_failed_logged=0
while true; do
if [ "$(date +%s)" -gt "$deadline" ]; then
ws_last_err=$(tenant_call GET "/workspaces/$wid" 2>/dev/null | \
python3 -c "import json,sys; print(json.load(sys.stdin).get('last_sample_error',''))" 2>/dev/null || echo "")
fail "Workspace $wid never reached online with a routable URL within ${WORKSPACE_ONLINE_TIMEOUT_SECS}s (~$((WORKSPACE_ONLINE_TIMEOUT_SECS/60)) min) (last status=$ws_last_status, url=$ws_last_url, err=$ws_last_err)"
fi
ws_json=$(tenant_call GET "/workspaces/$wid" 2>/dev/null || echo '{}')
ws_status=$(echo "$ws_json" | python3 -c "import json,sys; print(json.load(sys.stdin).get('status') or '')" 2>/dev/null)
ws_url=$(echo "$ws_json" | python3 -c "import json,sys; print(json.load(sys.stdin).get('url') or '')" 2>/dev/null)
if [ "$ws_status" != "$ws_last_status" ]; then
log " $wid$ws_status"
ws_last_status="$ws_status"
fi
if [ -n "$ws_url" ] && [ "$ws_url" != "$ws_last_url" ]; then
log " $wid url ready: $ws_url"
ws_last_url="$ws_url"
fi
case "$ws_status" in
online)
if [ -n "$ws_url" ]; then
break
fi
if [ "$ws_url_missing_logged" = "0" ]; then
log " $wid online but URL is not assigned yet — waiting for workspace routing readiness"
ws_url_missing_logged=1
fi
sleep 10
;;
failed)
# Not a hard fail — bootstrap-watcher frequently marks failed at
# 5 min on hermes, then heartbeat recovers to online around 10-13
# min when install.sh finishes. Log once per workspace so the CI
# output isn't spammy.
if [ "$ws_failed_logged" = "0" ]; then
log " $wid transiently failed — waiting for heartbeat recovery (bootstrap-watcher deadline, see cp#245)"
ws_failed_logged=1
fi
sleep 10
;;
*) sleep 10 ;;
esac
done
ok " $wid online and routable"
done
}
# ─── 5. Provision parent workspace ─────────────────────────────────────
# Inject the LLM provider key so the runtime can authenticate at boot.
# Branch by which secret is set so the script supports multiple paths
@@ -383,9 +477,9 @@ elif [ -n "${E2E_ANTHROPIC_API_KEY:-}" ]; then
# is still independent of MOLECULE_STAGING_OPENAI_API_KEY, so an OpenAI
# quota collapse doesn't wedge this path. Pinned to the claude-code
# runtime: hermes/langgraph use OpenAI-shaped envs and won't honour
# ANTHROPIC_API_KEY without further wiring (out of scope for this
# branch; if you need a hermes/Anthropic path, dispatch with
# E2E_RUNTIME=hermes + E2E_OPENAI_API_KEY pointing at a working key).
# ANTHROPIC_API_KEY without further wiring. pick_model_slug maps this
# branch to claude-sonnet-4-6 so the claude-code provider registry
# selects anthropic-api instead of the OAuth-only sonnet alias.
SECRETS_JSON=$(python3 -c "
import json, os
k = os.environ['E2E_ANTHROPIC_API_KEY']
@@ -410,6 +504,7 @@ print(json.dumps({
fi
MODEL_SLUG=$(pick_model_slug "$RUNTIME")
log " MODEL_SLUG=$MODEL_SLUG"
log "5/11 Provisioning parent workspace (runtime=$RUNTIME)..."
PARENT_RESP=$(tenant_call POST /workspaces \
@@ -437,48 +532,16 @@ fi
# deadline fires at 5 min and sets status=failed prematurely; heartbeat
# then transitions failed → online after install.sh finishes. So:
#
# - 20 min deadline (hermes worst-case + slack)
# - ${WORKSPACE_ONLINE_TIMEOUT_SECS}s (~$((WORKSPACE_ONLINE_TIMEOUT_SECS/60)) min)
# deadline (hermes worst-case + slack). Configurable via
# E2E_WORKSPACE_ONLINE_TIMEOUT_SECS (#1646).
# - 'failed' is a TRANSIENT state we must tolerate — log and keep
# polling, only hard-fail at the deadline. Pre-bootstrap-watcher-fix
# (controlplane#245) this was a flake generator: workspace went
# failed→online inside our window but we bailed at the failed read.
log "7/11 Waiting for workspace(s) to reach status=online (up to 30 min — hermes cold boot)..."
WS_DEADLINE=$(( $(date +%s) + 1800 ))
WS_TO_CHECK="$PARENT_ID"
[ -n "$CHILD_ID" ] && WS_TO_CHECK="$WS_TO_CHECK $CHILD_ID"
for wid in $WS_TO_CHECK; do
WS_LAST_STATUS=""
WS_FAILED_LOGGED=0
while true; do
if [ "$(date +%s)" -gt "$WS_DEADLINE" ]; then
WS_LAST_ERR=$(tenant_call GET "/workspaces/$wid" 2>/dev/null | \
python3 -c "import json,sys; print(json.load(sys.stdin).get('last_sample_error',''))" 2>/dev/null || echo "")
fail "Workspace $wid never reached online within 20 min (last status=$WS_LAST_STATUS, err=$WS_LAST_ERR)"
fi
WS_JSON=$(tenant_call GET "/workspaces/$wid" 2>/dev/null || echo '{}')
WS_STATUS=$(echo "$WS_JSON" | python3 -c "import json,sys; print(json.load(sys.stdin).get('status',''))" 2>/dev/null)
if [ "$WS_STATUS" != "$WS_LAST_STATUS" ]; then
log " $wid$WS_STATUS"
WS_LAST_STATUS="$WS_STATUS"
fi
case "$WS_STATUS" in
online) break ;;
failed)
# Not a hard fail — bootstrap-watcher frequently marks failed at
# 5 min on hermes, then heartbeat recovers to online around 10-13
# min when install.sh finishes. Log once per workspace so the CI
# output isn't spammy.
if [ "$WS_FAILED_LOGGED" = "0" ]; then
log " $wid transiently failed — waiting for heartbeat recovery (bootstrap-watcher deadline, see cp#245)"
WS_FAILED_LOGGED=1
fi
sleep 10
;;
*) sleep 10 ;;
esac
done
ok " $wid online"
done
WS_TO_CHECK=("$PARENT_ID")
[ -n "$CHILD_ID" ] && WS_TO_CHECK+=("$CHILD_ID")
wait_workspaces_online_routable "7/11 Waiting for workspace(s) to reach status=online (up to $((WORKSPACE_ONLINE_TIMEOUT_SECS/60)) min — hermes cold boot)..." "${WS_TO_CHECK[@]}"
# ─── 7b. Canvas-terminal diagnose (EIC chain probe) ────────────────────
# This step exists because the canvas-terminal failure of 2026-05-03
@@ -490,7 +553,7 @@ done
# - tenantIngressRules / workspaceIngressRules (CP)
# - eicSSHIngressRule helper (CP)
# - AuthorizeIngress source-group support (CP awsapi)
# - EIC_ENDPOINT_SG_ID Railway env
# - MOLECULE_EIC_ENDPOINT_SG_ID Railway env
# - handleRemoteConnect's send-ssh-public-key/open-tunnel/ssh chain
# surfaces within ~20 min of merge instead of waiting for a user report.
#
@@ -504,7 +567,7 @@ done
# probes docker.Ping + container exec; we still expect ok=true there
# since local-docker is the alternative production path.
log "7b/11 Canvas-terminal EIC diagnose probe..."
for wid in $WS_TO_CHECK; do
for wid in "${WS_TO_CHECK[@]}"; do
DIAG_JSON=$(tenant_call GET "/workspaces/$wid/terminal/diagnose" 2>/dev/null || echo '{}')
DIAG_OK=$(echo "$DIAG_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); print('true' if d.get('ok') else 'false')" 2>/dev/null || echo "false")
if [ "$DIAG_OK" = "true" ]; then
@@ -512,7 +575,7 @@ for wid in $WS_TO_CHECK; do
else
DIAG_FAIL=$(echo "$DIAG_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('first_failure','unknown'))" 2>/dev/null || echo "unknown")
DIAG_DETAIL=$(echo "$DIAG_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); s=[x for x in d.get('steps',[]) if not x.get('ok')]; step=s[0] if s else {}; print(' — '.join(x for x in [step.get('error',''), step.get('detail','')] if x))" 2>/dev/null || echo "")
fail "Workspace $wid terminal diagnose failed at step '$DIAG_FAIL': $DIAG_DETAIL — check tenant SG has tcp/22 from EIC endpoint SG (sg-0785d5c6138220523), EIC_ENDPOINT_SG_ID set in Railway, and EIC endpoint health"
fail "Workspace $wid terminal diagnose failed at step '$DIAG_FAIL': $DIAG_DETAIL — check tenant SG has tcp/22 from the configured EIC endpoint SG, MOLECULE_EIC_ENDPOINT_SG_ID is set in Railway, and EIC endpoint health"
fi
done
@@ -540,7 +603,7 @@ CONFIG_PAYLOAD="${CONFIG_MARKER}
name: synth-canary
runtime: ${RUNTIME}
"
for wid in $WS_TO_CHECK; do
for wid in "${WS_TO_CHECK[@]}"; do
PUT_BODY=$(python3 -c "import json,sys; print(json.dumps({'content': sys.stdin.read()}))" <<< "$CONFIG_PAYLOAD")
# Capture body to a tempfile so curl's -w '%{http_code}' is the only
# thing on stdout. The first version used `-w '\n%{http_code}\n'` and
@@ -573,6 +636,12 @@ for wid in $WS_TO_CHECK; do
ok " $wid config.yaml PUT OK (HTTP $PUT_CODE)"
done
# Saving config.yaml follows the same path as Canvas Config Save & Restart.
# The controlplane can briefly put the workspace back into provisioning and
# clear its route while the runtime restarts, so A2A must wait on the same
# externally routable readiness boundary again.
wait_workspaces_online_routable "7d/11 Waiting for workspace(s) to recover routing after config.yaml PUT..." "${WS_TO_CHECK[@]}"
# ─── 8. A2A round-trip on parent ───────────────────────────────────────
log "8/11 Sending A2A message to parent — expecting agent response..."
# Smoke prompt phrasing — DO NOT trim back to the bare "Reply with exactly: PONG"
@@ -612,10 +681,44 @@ print(json.dumps({
# 90s gives ~3x headroom over observed cold-call P95 (~25-30s).
# Subsequent A2A turns hit the same workspace and are sub-second, so
# this only widens the window for step 8/11 of the canary's first turn.
A2A_RESP=$(tenant_call POST "/workspaces/$PARENT_ID/a2a" \
--max-time 90 \
-H "Content-Type: application/json" \
-d "$A2A_PAYLOAD")
A2A_TMP=$(mktemp -t synth_a2a.XXXXXX)
for A2A_ATTEMPT in $(seq 1 12); do
: >"$A2A_TMP"
set +e
A2A_CODE=$(tenant_call POST "/workspaces/$PARENT_ID/a2a" \
--max-time 90 \
-H "Content-Type: application/json" \
-d "$A2A_PAYLOAD" \
-o "$A2A_TMP" \
-w '%{http_code}' \
2>/dev/null)
A2A_RC=$?
set -e
A2A_CODE=${A2A_CODE:-000}
A2A_RESP=$(cat "$A2A_TMP" 2>/dev/null || echo "")
if [ "$A2A_RC" = "0" ] && [ "$A2A_CODE" -ge 200 ] && [ "$A2A_CODE" -lt 300 ]; then
break
fi
A2A_SAFE_BODY=$(printf '%s' "$A2A_RESP" | sanitize_http_body)
if echo "$A2A_CODE" | grep -Eq '^(502|503|504)$' && echo "$A2A_SAFE_BODY" | grep -Eqi 'Service Unavailable|Bad Gateway|Gateway Timeout|error code: 502|error code: 504|workspace agent unreachable|connection refused|no healthy upstream|workspace agent busy|native_session'; then
log " A2A cold-start probe attempt $A2A_ATTEMPT/12 returned $A2A_CODE: $A2A_SAFE_BODY"
if [ "$A2A_ATTEMPT" -lt 12 ]; then
A2A_SLEEP=10
if echo "$A2A_SAFE_BODY" | grep -Eqi 'workspace agent busy|native_session'; then
A2A_SLEEP=30
fi
sleep "$A2A_SLEEP"
continue
fi
fi
break
done
rm -f "$A2A_TMP"
if [ "$A2A_RC" != "0" ] || [ "$A2A_CODE" -lt 200 ] || [ "$A2A_CODE" -ge 300 ]; then
A2A_SAFE_BODY=$(printf '%s' "$A2A_RESP" | sanitize_http_body)
fail "A2A POST /workspaces/$PARENT_ID/a2a failed after $A2A_ATTEMPT attempt(s) (curl_rc=$A2A_RC, http=$A2A_CODE): $A2A_SAFE_BODY"
fi
AGENT_TEXT=$(echo "$A2A_RESP" | python3 -c "
import json, sys
d = json.load(sys.stdin)
@@ -812,20 +915,50 @@ print(json.dumps({
}
}))
")
set +e
# Raw curl (not tenant_call) because this call carries an extra
# X-Source-Workspace-Id header. Must still send X-Molecule-Org-Id
# or TenantGuard 404s — previously missing, caused section 10 to
# fail rc=22 despite everything upstream being correct (2026-04-21).
DELEG_RESP=$(curl "${CURL_COMMON[@]}" -X POST "$TENANT_URL/workspaces/$CHILD_ID/a2a" \
-H "Authorization: Bearer $EFFECTIVE_TENANT_TOKEN" \
-H "X-Molecule-Org-Id: $ORG_ID" \
-H "X-Source-Workspace-Id: $PARENT_ID" \
-H "Content-Type: application/json" \
-d "$DELEG_PAYLOAD")
DELEG_RC=$?
set -e
[ $DELEG_RC -ne 0 ] && fail "Delegation A2A POST failed (rc=$DELEG_RC)"
DELEG_TMP=$(mktemp -t deleg_a2a.XXXXXX)
for DELEG_ATTEMPT in $(seq 1 12); do
: >"$DELEG_TMP"
set +e
# Raw curl (not tenant_call) because this call carries an extra
# X-Source-Workspace-Id header. Must still send X-Molecule-Org-Id
# or TenantGuard 404s — previously missing, caused section 10 to
# fail rc=22 despite everything upstream being correct (2026-04-21).
DELEG_CODE=$(curl "${CURL_COMMON[@]}" -X POST "$TENANT_URL/workspaces/$CHILD_ID/a2a" \
-H "Authorization: Bearer $EFFECTIVE_TENANT_TOKEN" \
-H "X-Molecule-Org-Id: $ORG_ID" \
-H "X-Source-Workspace-Id: $PARENT_ID" \
-H "Content-Type: application/json" \
-d "$DELEG_PAYLOAD" \
-o "$DELEG_TMP" \
-w '%{http_code}' \
2>/dev/null)
DELEG_RC=$?
set -e
DELEG_CODE=${DELEG_CODE:-000}
DELEG_RESP=$(cat "$DELEG_TMP" 2>/dev/null || echo "")
if [ "$DELEG_RC" = "0" ] && [ "$DELEG_CODE" -ge 200 ] && [ "$DELEG_CODE" -lt 300 ]; then
break
fi
DELEG_SAFE_BODY=$(printf '%s' "$DELEG_RESP" | sanitize_http_body)
if echo "$DELEG_CODE" | grep -Eq '^(502|503|504)$' && echo "$DELEG_SAFE_BODY" | grep -Eqi 'Service Unavailable|Bad Gateway|Gateway Timeout|error code: 502|error code: 504|workspace agent unreachable|connection refused|no healthy upstream|workspace agent busy|native_session'; then
log " Delegation A2A cold-start attempt $DELEG_ATTEMPT/12 returned $DELEG_CODE: $DELEG_SAFE_BODY"
if [ "$DELEG_ATTEMPT" -lt 12 ]; then
DELEG_SLEEP=10
if echo "$DELEG_SAFE_BODY" | grep -Eqi 'workspace agent busy|native_session'; then
DELEG_SLEEP=30
fi
sleep "$DELEG_SLEEP"
continue
fi
fi
break
done
rm -f "$DELEG_TMP"
if [ "$DELEG_RC" != "0" ] || [ "$DELEG_CODE" -lt 200 ] || [ "$DELEG_CODE" -ge 300 ]; then
DELEG_SAFE_BODY=$(printf '%s' "$DELEG_RESP" | sanitize_http_body)
fail "Delegation A2A POST failed after $DELEG_ATTEMPT attempt(s) (curl_rc=$DELEG_RC, http=$DELEG_CODE): $DELEG_SAFE_BODY"
fi
DELEG_TEXT=$(echo "$DELEG_RESP" | python3 -c "
import json, sys
try:
+18
View File
@@ -0,0 +1,18 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def test_staging_e2e_workflows_use_stable_minimax_default() -> None:
"""Keep cron/push E2E on the same MiniMax model as the smoke-tested script."""
workflow_paths = [
".gitea/workflows/e2e-staging-saas.yml",
".gitea/workflows/staging-smoke.yml",
".gitea/workflows/continuous-synth-e2e.yml",
]
for rel in workflow_paths:
text = (ROOT / rel).read_text()
assert "MiniMax-M2.7-highspeed" not in text
assert "MiniMax-M2" in text
+6 -6
View File
@@ -705,7 +705,7 @@ def test_ci_change_detector_docs_and_meta_scripts_do_not_trigger_surfaces():
}
def test_ci_platform_go_pr_steps_are_path_scoped():
def test_ci_platform_go_steps_are_path_scoped_on_all_events():
doc = yaml.safe_load(CI_WORKFLOW.read_text(encoding="utf-8"))
platform = doc["jobs"]["platform-build"]
assert platform.get("needs") == "changes"
@@ -720,11 +720,11 @@ def test_ci_platform_go_pr_steps_are_path_scoped():
assert expensive_steps
for step in expensive_steps:
expr = step.get("if", "")
assert "github.event_name != 'pull_request'" in expr
assert "needs.changes.outputs.platform == 'true'" in expr
assert "github.event_name != 'pull_request'" not in expr
def test_ci_canvas_nextjs_pr_steps_are_path_scoped():
def test_ci_canvas_nextjs_steps_are_path_scoped_on_all_events():
doc = yaml.safe_load(CI_WORKFLOW.read_text(encoding="utf-8"))
canvas = doc["jobs"]["canvas-build"]
assert canvas.get("needs") == "changes"
@@ -739,11 +739,11 @@ def test_ci_canvas_nextjs_pr_steps_are_path_scoped():
assert expensive_steps
for step in expensive_steps:
expr = step.get("if", "")
assert "github.event_name != 'pull_request'" in expr
assert "needs.changes.outputs.canvas == 'true'" in expr
assert "github.event_name != 'pull_request'" not in expr
def test_ci_shellcheck_pr_steps_are_path_scoped():
def test_ci_shellcheck_steps_are_path_scoped_on_all_events():
doc = yaml.safe_load(CI_WORKFLOW.read_text(encoding="utf-8"))
shellcheck = doc["jobs"]["shellcheck"]
assert shellcheck.get("needs") == "changes"
@@ -756,5 +756,5 @@ def test_ci_shellcheck_pr_steps_are_path_scoped():
assert expensive_steps
for step in expensive_steps:
expr = step.get("if", "")
assert "github.event_name != 'pull_request'" in expr
assert "needs.changes.outputs.scripts == 'true'" in expr
assert "github.event_name != 'pull_request'" not in expr
+226
View File
@@ -56,6 +56,21 @@ SCRIPT_PATH = (
)
@pytest.fixture(autouse=True)
def _stub_time_sleep(monkeypatch):
"""Autouse: stub time.sleep across every test.
The watchdog's RECHECK_DELAY_SECS (default 90s) is wired into
run_once() via time.sleep(). Without this stub, integration-style
tests that exercise run_once() would each block for 90s — a
pre-fix `pytest -q` ran in ~0.1s; the unstubbed equivalent took
>4 minutes (task #394 review evidence). Stubbing here keeps the
suite fast and deterministic without requiring every red-path test
to remember the patch.
"""
monkeypatch.setattr("time.sleep", lambda s: None)
@pytest.fixture(scope="module")
def wd_module():
"""Import the script as a module under a known env."""
@@ -809,3 +824,214 @@ def test_require_runtime_env_exits_when_missing(wd_module, monkeypatch):
with pytest.raises(SystemExit) as excinfo:
wd_module._require_runtime_env()
assert excinfo.value.code == 2
# --------------------------------------------------------------------------
# Action-run status filter + HEAD-recheck (task #394, mc#1597..1630)
#
# The existing cancel-cascade filter matched description=='Has been
# cancelled' EXACTLY, but a 7-day DB sweep on 2026-05-20 showed that
# only 76/702 (~11%) of action_run.status=3 (Cancelled) entries carry
# that string — 89% are written as 'Failing after Ns', indistinguishable
# from real action_run.status=2 (Failure) at the commit_status layer.
#
# Gitea 1.22.6 has NO REST endpoint exposing action_run.status, so the
# canonical filter (status=2 only) cannot run from a Gitea Actions
# runner. The next-best signal is the HEAD-recheck: re-fetch HEAD SHA
# (or its combined status) right before filing. If HEAD moved on or
# combined state recovered, the prior "red" was a transient
# cancel-cascade and we skip-file.
#
# References:
# - reference_chronic_red_sweep_cancelled_vs_failed_filter
# - feedback_gitea_status_enum_use_helper_not_raw_int
# - reference_gitea_action_status_enum_corrected_2026_05_19
# - triage evidence 2026-05-21 04:55 (6 cancellation + 1 emission
# artifact across mc#1597,1605,1609,1613,1626,1627,1630)
# --------------------------------------------------------------------------
def test_head_recheck_skips_file_when_head_moved(wd_module, monkeypatch, capsys):
"""When initial tick sees red at SHA_A but HEAD has since moved to
SHA_B (next commit landed mid-tick), the watchdog must NOT file.
Re-evaluation happens on the next cron tick against the new SHA.
REGRESSION CLASS: this guards mc#1597..#1630 — 7 false-positives
filed in 24h because cancel-cascade fired commit_status=failure
rows on SHAs that were already superseded by new merges."""
SHA_A = SHA_RED
SHA_B = SHA_GREEN
failed_ctx = [
{"context": "ci/test", "status": "failure",
"target_url": "/r/runs/100/jobs/0",
"description": "Failing after 12s"},
]
# First branches read returns SHA_A; the second (recheck) returns SHA_B
# → watchdog detects HEAD drift and skip-files.
branches_responses = iter([
(200, _branches_response(SHA_A)),
(200, _branches_response(SHA_B)),
])
def fake_api(method, path, *, body=None, query=None, expect_json=True):
if method == "GET" and path == "/repos/owner/repo/branches/main":
return next(branches_responses)
if method == "GET" and path == f"/repos/owner/repo/commits/{SHA_A}/status":
return (200, _combined_status("failure", failed_ctx))
if method == "POST" and path == "/repos/owner/repo/issues":
raise AssertionError(
"watchdog filed a phantom issue despite HEAD moving away "
"from the red SHA (regression: mc#1597..1630)"
)
if method == "GET" and path == "/repos/owner/repo/issues":
return (200, [])
raise AssertionError(f"unexpected api call: {method} {path}")
# Settling delay is no-op'd by the _stub_time_sleep autouse fixture.
monkeypatch.setattr(wd_module, "api", fake_api)
wd_module.run_once(dry_run=False)
captured = capsys.readouterr()
assert "head drift" in captured.out.lower() or "head moved" in captured.out.lower(), (
f"expected a notice about HEAD drift, got: {captured.out!r}"
)
def test_head_recheck_skips_file_when_recheck_status_recovered(
wd_module, monkeypatch, capsys,
):
"""When initial tick sees red at SHA, but the post-settling recheck
on the SAME SHA shows combined status recovered (e.g. transient
cancel-cascade rolled forward to success on retry), skip-file.
This catches the mid-flight cancel-cascade window — the second
largest false-positive cluster in mc#1597..1630."""
failed_ctx_initial = [
{"context": "ci/test", "status": "failure",
"target_url": "/r/runs/100/jobs/0",
"description": "Failing after 12s"},
]
recovered_ctx = [
{"context": "ci/test", "status": "success",
"target_url": "/r/runs/100/jobs/0",
"description": "Successful in 30s"},
]
# Same SHA across both branch reads; status flips from failure→success
# between the two combined-status reads.
status_responses = iter([
(200, _combined_status("failure", failed_ctx_initial)),
(200, _combined_status("success", recovered_ctx)),
])
def fake_api(method, path, *, body=None, query=None, expect_json=True):
if method == "GET" and path == "/repos/owner/repo/branches/main":
return (200, _branches_response(SHA_RED))
if method == "GET" and path == f"/repos/owner/repo/commits/{SHA_RED}/status":
return next(status_responses)
if method == "POST" and path == "/repos/owner/repo/issues":
raise AssertionError(
"watchdog filed a phantom issue despite combined status "
"recovering on recheck (mid-flight cancel-cascade window)"
)
if method == "GET" and path == "/repos/owner/repo/issues":
return (200, [])
raise AssertionError(f"unexpected api call: {method} {path}")
monkeypatch.setattr(wd_module, "api", fake_api)
wd_module.run_once(dry_run=False)
captured = capsys.readouterr()
assert "recovered" in captured.out.lower() or "settled" in captured.out.lower(), (
f"expected a notice about post-settling recovery, got: {captured.out!r}"
)
def test_head_recheck_files_when_still_red_after_settling(
wd_module, monkeypatch,
):
"""When BOTH the initial detection AND the post-settling recheck
show the same SHA still red, file the issue. This is the genuine-
failure path the watchdog is designed to surface.
Locks the over-filter: a future change that always-skips after
recheck would dismiss real failures."""
failed_ctx = [
{"context": "ci/test", "status": "failure",
"target_url": "/r/runs/100/jobs/0",
"description": "Failing after 12s"},
]
post_filed = {"value": False}
def fake_api(method, path, *, body=None, query=None, expect_json=True):
if method == "GET" and path == "/repos/owner/repo/branches/main":
return (200, _branches_response(SHA_RED))
if method == "GET" and path == f"/repos/owner/repo/commits/{SHA_RED}/status":
return (200, _combined_status("failure", failed_ctx))
if method == "GET" and path == "/repos/owner/repo/issues":
return (200, [])
if method == "GET" and path == "/repos/owner/repo/labels":
return (200, [{"id": 9, "name": "tier:high"}])
if method == "POST" and path == "/repos/owner/repo/issues":
post_filed["value"] = True
return (201, {"number": 999})
if method == "POST" and path == "/repos/owner/repo/issues/999/labels":
return (200, [])
raise AssertionError(f"unexpected api call: {method} {path}")
monkeypatch.setattr(wd_module, "api", fake_api)
wd_module.run_once(dry_run=False)
assert post_filed["value"], (
"genuine-failure path was skip-filed — head-recheck over-filter "
"regression (would suppress all real main-red alarms)"
)
def test_head_recheck_skips_when_initial_was_only_cancel_cascade(
wd_module, monkeypatch,
):
"""Belt-and-braces: combined-status failure caused exclusively by
description='Has been cancelled' entries should still be filtered
by the EXISTING cancel-cascade filter — head-recheck must not
accidentally bypass it. Regression guard for the existing mc#1564
fix."""
failed_ctx = [
{"context": "ci/test", "status": "failure",
"description": "Has been cancelled"},
]
def fake_api(method, path, *, body=None, query=None, expect_json=True):
if method == "GET" and path == "/repos/owner/repo/branches/main":
return (200, _branches_response(SHA_RED))
if method == "GET" and path == f"/repos/owner/repo/commits/{SHA_RED}/status":
return (200, _combined_status("failure", failed_ctx))
if method == "POST" and path == "/repos/owner/repo/issues":
raise AssertionError(
"cancel-cascade-only entry must be filtered before any "
"head-recheck logic runs"
)
if method == "GET" and path == "/repos/owner/repo/issues":
return (200, [])
# No commit-status recheck should happen because is_red() returned False
raise AssertionError(f"unexpected api call: {method} {path}")
monkeypatch.setattr(wd_module, "api", fake_api)
wd_module.run_once(dry_run=False)
# success: no AssertionError raised, no POST
def test_resolve_action_run_status_returns_none_on_no_endpoint(wd_module):
"""The action_run.status REST endpoint does NOT exist in Gitea
1.22.6 (verified empirically 2026-05-20 — /api/v1/.../actions/runs/N
returns HTTP 404 across all probe variants). The resolver must
return None gracefully so callers fall back to the description-
string + head-recheck heuristics.
This pins the extensibility hook: when a future Gitea release (or
an op-host proxy) exposes the endpoint, the resolver implementation
can be swapped in without touching the caller contract."""
# The function exists and is callable
assert hasattr(wd_module, "_resolve_action_run_status")
# A typical target_url shape from real Gitea commit_status rows:
target_url = "/molecule-ai/molecule-core/actions/runs/75020/jobs/0"
# Return None when no endpoint available
out = wd_module._resolve_action_run_status(target_url)
assert out is None, (
"resolver must return None when the action_run.status endpoint "
"isn't reachable — callers depend on the None-fallback path"
)
+40
View File
@@ -442,6 +442,46 @@ def test_reap_preserves_real_push(sr_module, monkeypatch):
assert calls == [] # NO POST
def test_reap_compensates_cancelled_real_push_status(sr_module, monkeypatch):
"""Gitea 1.22.6 maps cancelled push runs to failure statuses.
A real push workflow with description exactly "Has been cancelled"
is cancel-cascade noise, not a defect signal. Status-reaper should
compensate it even though the workflow has a push trigger.
"""
calls = []
def fake_api(method, path, *, body=None, query=None, expect_json=True):
calls.append((method, path, body))
return (201, {})
monkeypatch.setattr(sr_module, "api", fake_api)
workflow_map = {"ci": True}
combined = {
"state": "failure",
"statuses": [
{
"context": "ci / test (push)",
"status": "failure",
"description": "Has been cancelled",
"target_url": "https://example.test/actions/runs/1",
}
],
}
counters = sr_module.reap(workflow_map, combined, SHA, dry_run=False)
assert counters["compensated"] == 1
assert counters["compensated_cancelled_push"] == 1
assert counters["preserved_real_push"] == 0
assert len(calls) == 1
assert calls[0][0] == "POST"
assert calls[0][1] == f"/repos/owner/repo/statuses/{SHA}"
assert calls[0][2]["context"] == "ci / test (push)"
assert calls[0][2]["state"] == "success"
def test_reap_preserves_unknown_workflow(sr_module, monkeypatch, capsys):
"""Workflow not in map → ::notice:: + skip (conservative)."""
monkeypatch.setattr(
+23
View File
@@ -1,3 +1,26 @@
// Package main runs the per-tenant workspace-server.
//
// @title Molecule AI Workspace Server API
// @version 1.0
// @description The per-tenant workspace-server HTTP API. Single source of truth for workspace/schedule/agent/secrets/files/memory CRUD. Hand-written clients (canvas, molecule-mcp-server, molecule-cli, molecule-sdk-python) should be replaced by clients generated from this spec — see RFC #1706.
// @host api.moleculesai.app
// @BasePath /
// @schemes https
//
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization
// @description Bearer token issued by Gitea (org-admin or persona PAT) or by the platform's signup/SSO flow.
//
// @securityDefinitions.apikey OrgSlugAuth
// @in header
// @name X-Molecule-Org-Slug
// @description Tenant routing header — required on every /workspaces/{id}/* request so the platform edge can route to the correct per-tenant workspace-server. Either X-Molecule-Org-Slug (human-readable, e.g. "agents-team") or X-Molecule-Org-Id (UUID) must be sent; slug is preferred for client code.
//
// @securityDefinitions.apikey OrgIdAuth
// @in header
// @name X-Molecule-Org-Id
// @description Tenant routing header (UUID form). Alternative to X-Molecule-Org-Slug. At least one of OrgSlugAuth or OrgIdAuth must be sent alongside BearerAuth.
package main
import (
+521
View File
@@ -0,0 +1,521 @@
{
"schemes": [
"https"
],
"swagger": "2.0",
"info": {
"description": "The per-tenant workspace-server HTTP API. Single source of truth for workspace/schedule/agent/secrets/files/memory CRUD. Hand-written clients (canvas, molecule-mcp-server, molecule-cli, molecule-sdk-python) should be replaced by clients generated from this spec — see RFC #1706.",
"title": "Molecule AI Workspace Server API",
"contact": {},
"version": "1.0"
},
"host": "api.moleculesai.app",
"basePath": "/",
"paths": {
"/workspaces/{id}/schedules": {
"get": {
"security": [
{
"BearerAuth": [],
"OrgSlugAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"schedules"
],
"summary": "List schedules for a workspace",
"parameters": [
{
"type": "string",
"description": "Workspace ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/handlers.ScheduleResponse"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
},
"post": {
"security": [
{
"BearerAuth": [],
"OrgSlugAuth": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"schedules"
],
"summary": "Create a schedule",
"parameters": [
{
"type": "string",
"description": "Workspace ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "Schedule fields",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/handlers.CreateScheduleRequest"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/handlers.CreateScheduleResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/workspaces/{id}/schedules/{scheduleId}": {
"delete": {
"security": [
{
"BearerAuth": [],
"OrgSlugAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"schedules"
],
"summary": "Delete a schedule",
"parameters": [
{
"type": "string",
"description": "Workspace ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Schedule ID",
"name": "scheduleId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handlers.StatusResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
},
"patch": {
"security": [
{
"BearerAuth": [],
"OrgSlugAuth": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"schedules"
],
"summary": "Update a schedule",
"parameters": [
{
"type": "string",
"description": "Workspace ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Schedule ID",
"name": "scheduleId",
"in": "path",
"required": true
},
{
"description": "Partial schedule fields (only provided keys are updated)",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/handlers.UpdateScheduleRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handlers.ScheduleResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/workspaces/{id}/schedules/{scheduleId}/history": {
"get": {
"security": [
{
"BearerAuth": [],
"OrgSlugAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"schedules"
],
"summary": "Get past runs of a schedule",
"parameters": [
{
"type": "string",
"description": "Workspace ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Schedule ID",
"name": "scheduleId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/handlers.HistoryEntry"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/workspaces/{id}/schedules/{scheduleId}/run": {
"post": {
"security": [
{
"BearerAuth": [],
"OrgSlugAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"schedules"
],
"summary": "Fire a schedule manually",
"parameters": [
{
"type": "string",
"description": "Workspace ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Schedule ID",
"name": "scheduleId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handlers.RunNowResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
}
},
"definitions": {
"handlers.CreateScheduleRequest": {
"type": "object",
"required": [
"cron_expr",
"prompt"
],
"properties": {
"cron_expr": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"name": {
"type": "string"
},
"prompt": {
"type": "string"
},
"timezone": {
"type": "string"
}
}
},
"handlers.CreateScheduleResponse": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"next_run_at": {
"type": "string"
},
"status": {
"type": "string"
}
}
},
"handlers.ErrorResponse": {
"type": "object",
"properties": {
"error": {
"type": "string"
}
}
},
"handlers.HistoryEntry": {
"type": "object",
"properties": {
"duration_ms": {
"type": "integer"
},
"error_detail": {
"type": "string"
},
"request": {
"type": "object"
},
"status": {
"type": "string"
},
"timestamp": {
"type": "string"
}
}
},
"handlers.RunNowResponse": {
"type": "object",
"properties": {
"prompt": {
"type": "string"
},
"status": {
"type": "string"
},
"workspace_id": {
"type": "string"
}
}
},
"handlers.ScheduleResponse": {
"type": "object",
"properties": {
"created_at": {
"type": "string"
},
"cron_expr": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"id": {
"type": "string"
},
"last_error": {
"type": "string"
},
"last_run_at": {
"type": "string"
},
"last_status": {
"type": "string"
},
"name": {
"type": "string"
},
"next_run_at": {
"type": "string"
},
"prompt": {
"type": "string"
},
"run_count": {
"type": "integer"
},
"source": {
"description": "'template' (seeded by org/import) | 'runtime' (created via Canvas/API). Issue #24.",
"type": "string"
},
"timezone": {
"type": "string"
},
"updated_at": {
"type": "string"
},
"workspace_id": {
"type": "string"
}
}
},
"handlers.StatusResponse": {
"type": "object",
"properties": {
"status": {
"type": "string"
}
}
},
"handlers.UpdateScheduleRequest": {
"type": "object",
"properties": {
"cron_expr": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"name": {
"type": "string"
},
"prompt": {
"type": "string"
},
"timezone": {
"type": "string"
}
}
}
},
"securityDefinitions": {
"BearerAuth": {
"description": "Bearer token issued by Gitea (org-admin or persona PAT) or by the platform's signup/SSO flow.",
"type": "apiKey",
"name": "Authorization",
"in": "header"
},
"OrgIdAuth": {
"description": "Tenant routing header (UUID form). Alternative to X-Molecule-Org-Slug. At least one of OrgSlugAuth or OrgIdAuth must be sent alongside BearerAuth.",
"type": "apiKey",
"name": "X-Molecule-Org-Id",
"in": "header"
},
"OrgSlugAuth": {
"description": "Tenant routing header — required on every /workspaces/{id}/* request so the platform edge can route to the correct per-tenant workspace-server. Either X-Molecule-Org-Slug (human-readable, e.g. \"agents-team\") or X-Molecule-Org-Id (UUID) must be sent; slug is preferred for client code.",
"type": "apiKey",
"name": "X-Molecule-Org-Slug",
"in": "header"
}
}
}
+349
View File
@@ -0,0 +1,349 @@
basePath: /
definitions:
handlers.CreateScheduleRequest:
properties:
cron_expr:
type: string
enabled:
type: boolean
name:
type: string
prompt:
type: string
timezone:
type: string
required:
- cron_expr
- prompt
type: object
handlers.CreateScheduleResponse:
properties:
id:
type: string
next_run_at:
type: string
status:
type: string
type: object
handlers.ErrorResponse:
properties:
error:
type: string
type: object
handlers.HistoryEntry:
properties:
duration_ms:
type: integer
error_detail:
type: string
request:
type: object
status:
type: string
timestamp:
type: string
type: object
handlers.RunNowResponse:
properties:
prompt:
type: string
status:
type: string
workspace_id:
type: string
type: object
handlers.ScheduleResponse:
properties:
created_at:
type: string
cron_expr:
type: string
enabled:
type: boolean
id:
type: string
last_error:
type: string
last_run_at:
type: string
last_status:
type: string
name:
type: string
next_run_at:
type: string
prompt:
type: string
run_count:
type: integer
source:
description: '''template'' (seeded by org/import) | ''runtime'' (created via
Canvas/API). Issue #24.'
type: string
timezone:
type: string
updated_at:
type: string
workspace_id:
type: string
type: object
handlers.StatusResponse:
properties:
status:
type: string
type: object
handlers.UpdateScheduleRequest:
properties:
cron_expr:
type: string
enabled:
type: boolean
name:
type: string
prompt:
type: string
timezone:
type: string
type: object
host: api.moleculesai.app
info:
contact: {}
description: 'The per-tenant workspace-server HTTP API. Single source of truth for
workspace/schedule/agent/secrets/files/memory CRUD. Hand-written clients (canvas,
molecule-mcp-server, molecule-cli, molecule-sdk-python) should be replaced by
clients generated from this spec — see RFC #1706.'
title: Molecule AI Workspace Server API
version: "1.0"
paths:
/workspaces/{id}/schedules:
get:
parameters:
- description: Workspace ID
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/handlers.ScheduleResponse'
type: array
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
security:
- BearerAuth: []
OrgSlugAuth: []
summary: List schedules for a workspace
tags:
- schedules
post:
consumes:
- application/json
parameters:
- description: Workspace ID
in: path
name: id
required: true
type: string
- description: Schedule fields
in: body
name: body
required: true
schema:
$ref: '#/definitions/handlers.CreateScheduleRequest'
produces:
- application/json
responses:
"201":
description: Created
schema:
$ref: '#/definitions/handlers.CreateScheduleResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
security:
- BearerAuth: []
OrgSlugAuth: []
summary: Create a schedule
tags:
- schedules
/workspaces/{id}/schedules/{scheduleId}:
delete:
parameters:
- description: Workspace ID
in: path
name: id
required: true
type: string
- description: Schedule ID
in: path
name: scheduleId
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handlers.StatusResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
security:
- BearerAuth: []
OrgSlugAuth: []
summary: Delete a schedule
tags:
- schedules
patch:
consumes:
- application/json
parameters:
- description: Workspace ID
in: path
name: id
required: true
type: string
- description: Schedule ID
in: path
name: scheduleId
required: true
type: string
- description: Partial schedule fields (only provided keys are updated)
in: body
name: body
required: true
schema:
$ref: '#/definitions/handlers.UpdateScheduleRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handlers.ScheduleResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
security:
- BearerAuth: []
OrgSlugAuth: []
summary: Update a schedule
tags:
- schedules
/workspaces/{id}/schedules/{scheduleId}/history:
get:
parameters:
- description: Workspace ID
in: path
name: id
required: true
type: string
- description: Schedule ID
in: path
name: scheduleId
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/handlers.HistoryEntry'
type: array
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
security:
- BearerAuth: []
OrgSlugAuth: []
summary: Get past runs of a schedule
tags:
- schedules
/workspaces/{id}/schedules/{scheduleId}/run:
post:
parameters:
- description: Workspace ID
in: path
name: id
required: true
type: string
- description: Schedule ID
in: path
name: scheduleId
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handlers.RunNowResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
security:
- BearerAuth: []
OrgSlugAuth: []
summary: Fire a schedule manually
tags:
- schedules
schemes:
- https
securityDefinitions:
BearerAuth:
description: Bearer token issued by Gitea (org-admin or persona PAT) or by the
platform's signup/SSO flow.
in: header
name: Authorization
type: apiKey
OrgIdAuth:
description: Tenant routing header (UUID form). Alternative to X-Molecule-Org-Slug.
At least one of OrgSlugAuth or OrgIdAuth must be sent alongside BearerAuth.
in: header
name: X-Molecule-Org-Id
type: apiKey
OrgSlugAuth:
description: Tenant routing header — required on every /workspaces/{id}/* request
so the platform edge can route to the correct per-tenant workspace-server. Either
X-Molecule-Org-Slug (human-readable, e.g. "agents-team") or X-Molecule-Org-Id
(UUID) must be sent; slug is preferred for client code.
in: header
name: X-Molecule-Org-Slug
type: apiKey
swagger: "2.0"
@@ -686,11 +686,22 @@ func (h *WorkspaceHandler) resolveAgentURL(ctx context.Context, workspaceID stri
_ = db.CacheURL(ctx, workspaceID, agentURL)
}
// When the platform runs inside Docker, 127.0.0.1:{host_port} is
// unreachable (it's the platform container's own localhost, not the
// Docker host). Rewrite to the container's Docker-bridge hostname.
// When the platform runs inside Docker, a managed workspace's
// 127.0.0.1:{host_port} URL points at the Docker host and must be
// rewritten to the workspace container's Docker-bridge hostname.
// External runtimes are not managed containers; their local test/runtime
// URL is the target and must not be synthesized into ws-<id>:8000.
if strings.HasPrefix(agentURL, "http://127.0.0.1:") && h.provisioner != nil && platformInDocker {
agentURL = provisioner.InternalURL(workspaceID)
var wsRuntime string
if err := db.DB.QueryRowContext(ctx,
`SELECT COALESCE(runtime, 'langgraph') FROM workspaces WHERE id = $1`,
workspaceID,
).Scan(&wsRuntime); err != nil {
log.Printf("ProxyA2A: runtime lookup before Docker URL rewrite failed for %s: %v", workspaceID, err)
}
if !isExternalLikeRuntime(wsRuntime) {
agentURL = provisioner.InternalURL(workspaceID)
}
}
// SSRF defence: reject private/metadata URLs before making outbound call.
if err := isSafeURL(agentURL); err != nil {
@@ -71,35 +71,30 @@ func (h *WorkspaceHandler) handleA2ADispatchError(ctx context.Context, workspace
// with 202 status here was the original cycle 53 bug — callers saw
// proxyErr != nil and logged "delegation failed: proxy a2a error".
if isUpstreamBusyError(err) {
// Capability primitive #5 — see project memory
// `project_runtime_native_pluggable.md`. When the target workspace's
// adapter has declared provides_native_session=True, the SDK
// owns its own queue/session state (claude-agent-sdk's streaming
// session, hermes-agent's in-container event log, etc.). Adding
// the platform's a2a_queue layer on top would double-buffer the
// same in-flight state — and worse, the platform queue's drain
// timing has no relationship to the SDK's actual readiness, so
// the queued request might dispatch while the SDK is STILL busy.
// #1684 / Reno Stars: native_session adapters previously took a
// 503-no-enqueue path here, on the assumption that the SDK owned
// an inbound queue and the platform a2a_queue would double-buffer.
// In practice, the common native_session SDKs (claude-agent-sdk,
// codex app-server, hermes-agent) do NOT have an inbound queue —
// new turns can only be pushed via the same HTTP POST that just
// returned busy. So cron fires (and any A2A retry) bounce 503
// every tick until the SDK voluntarily yields. Reno Stars #1684
// observed 12 consecutive `*/30` cron fires lost over 6h while a
// single native_session held the slot.
//
// For native_session targets, return 503 + Retry-After directly.
// The caller's adapter handles retry on its own schedule, and
// the SDK's own queue absorbs the in-flight request when it does.
// Observability is preserved: logA2AFailure already ran above;
// activity_logs records the busy event; the broadcaster fires.
if runtimeOverrides.HasCapability(workspaceID, "session") {
log.Printf("ProxyA2A: target %s busy and declares native_session — skip enqueue, return 503", workspaceID)
return 0, nil, &proxyA2AError{
Status: http.StatusServiceUnavailable,
Headers: map[string]string{"Retry-After": strconv.Itoa(busyRetryAfterSeconds)},
Response: gin.H{
"error": "workspace agent busy — adapter handles retry (native_session)",
"busy": true,
"retry_after": busyRetryAfterSeconds,
"native_session": true,
},
}
}
// The original concern — "drain timing has no relationship to SDK
// readiness" — turns out to be unfounded: heartbeat→drain is
// gated by `payload.ActiveTasks < maxConcurrent` in
// registry.go:Heartbeat, so drain only fires when the workspace
// itself reports spare capacity. That IS the session-ended
// signal. The native_session SDK reports ActiveTasks=1 while in a
// turn, ActiveTasks=0 when idle, and the next heartbeat after
// idle triggers DrainQueueForWorkspace.
//
// So we collapse the two branches: both native_session and
// non-native callers enqueue here. The native_session SDK's own
// in-flight POST stays unaffected; the queued item drains on the
// next post-idle heartbeat.
idempotencyKey := extractIdempotencyKey(body)
// Honor params.expires_in_seconds when the caller specifies one. Zero
// (the unset default) → expiresAt = nil → infinite TTL preserved by
@@ -1511,6 +1511,35 @@ func TestResolveAgentURL_DockerRewrite(t *testing.T) {
}
}
func TestResolveAgentURL_ExternalRuntimeLoopbackNotRewrittenInDocker(t *testing.T) {
mock := setupTestDB(t)
mr := setupTestRedis(t)
allowLoopbackForTest(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
waitForHandlerAsyncBeforeDBCleanup(t, handler)
handler.provisioner = &stubLocalProv{}
restore := setPlatformInDockerForTest(true)
defer restore()
agentURL := "http://127.0.0.1:55555"
mr.Set("ws:ws-external:url", agentURL)
mock.ExpectQuery("SELECT COALESCE\\(runtime").
WithArgs("ws-external").
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("external"))
url, perr := handler.resolveAgentURL(context.Background(), "ws-external")
if perr != nil {
t.Fatalf("unexpected error: %+v", perr)
}
if url != agentURL {
t.Errorf("external runtime loopback URL must not be rewritten; got %q want %q", url, agentURL)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
// --- dispatchA2A direct unit tests ---
func TestDispatchA2A_BuildRequestError(t *testing.T) {
+317 -17
View File
@@ -67,7 +67,213 @@ func NewActivityHandler(b *events.Broadcaster) *ActivityHandler {
return &ActivityHandler{broadcaster: b}
}
// List handles GET /workspaces/:id/activity?type=&source=&limit=&since_secs=&since_id=
// extractAttachmentsFromRequestBody walks a JSON-RPC a2a inbound body to
// surface attachments (file/image/audio/video) as a flat `attachments[]`
// projection so callers don't have to drill into the request_body shape
// themselves.
//
// Two body shapes are walked in order:
//
// 1. a2a-sdk v1 message-part envelope (peer_agent inbound):
//
// {"jsonrpc":"2.0","method":"message/send","params":{
// "message":{"parts":[
// {"kind":"text", "text":"hi"},
// {"kind":"file", "file":{"uri":"workspace:foo.pdf","mime_type":"application/pdf","name":"foo.pdf"}},
// {"kind":"image","file":{"uri":"workspace:bar.png","mime_type":"image/png","name":"bar.png"}},
// ]}}}
//
// 2. canvas chat_upload_receive flat manifest (canvas_user upload):
//
// {"uri":"platform-pending:<ws>/<file>",
// "name":"pasted.png",
// "size":12345,
// "file_id":"<uuid>",
// "mimeType":"image/png"}
//
// The canvas upload pipe writes a single manifest directly at the
// root of request_body (no JSON-RPC envelope) with camelCase
// `mimeType`. We normalize to snake_case `mime_type` on the way out
// so every downstream adaptor (channel / telegram / codex / hermes)
// sees one wire shape regardless of which inbound shape produced it.
//
// Returns nil (omit-from-JSON) when the body has no attachments — the
// `?include=peer_info` envelope projects this as an array iff non-empty.
//
// Defensive on every step: any missing key / wrong-shape value falls
// through to the next arm or returns nil instead of panicking. The
// activity_logs row could carry literally any JSON in request_body
// (legacy formats, future formats); we only commit to the documented
// shapes and silently skip anything else.
func extractAttachmentsFromRequestBody(raw []byte) []map[string]interface{} {
if len(raw) == 0 {
return nil
}
var body map[string]interface{}
if err := json.Unmarshal(raw, &body); err != nil {
return nil
}
if atts := extractAttachmentsFromMessageParts(body); len(atts) > 0 {
return atts
}
if att := extractAttachmentFromFlatUploadManifest(body); att != nil {
return []map[string]interface{}{att}
}
return nil
}
// extractAttachmentsFromMessageParts handles the a2a-sdk v1 shape:
// body.params.message.parts[]. Walks file/image/audio parts; honors v1
// `kind` and v0 `type` discriminators; accepts nested `.file` sub-object
// or inlined uri/mime_type/name on the part itself.
func extractAttachmentsFromMessageParts(body map[string]interface{}) []map[string]interface{} {
params, ok := body["params"].(map[string]interface{})
if !ok {
return nil
}
message, ok := params["message"].(map[string]interface{})
if !ok {
return nil
}
parts, ok := message["parts"].([]interface{})
if !ok {
return nil
}
out := make([]map[string]interface{}, 0)
for _, p := range parts {
part, ok := p.(map[string]interface{})
if !ok {
continue
}
// a2a-sdk v1 uses "kind"; older v0 callers sent "type". Accept
// both for the discriminator — same defensive read pattern as
// the runtime-side extract_text helper.
kind, _ := part["kind"].(string)
if kind == "" {
kind, _ = part["type"].(string)
}
if kind != "file" && kind != "image" && kind != "audio" {
continue
}
// The file sub-object holds uri/mime_type/name. The a2a-sdk v1
// shape nests under "file"; some legacy payloads inlined the
// fields onto the part itself. Support both.
var fileObj map[string]interface{}
if f, ok := part["file"].(map[string]interface{}); ok {
fileObj = f
} else {
fileObj = part
}
uri, _ := fileObj["uri"].(string)
mimeType, _ := fileObj["mime_type"].(string)
name, _ := fileObj["name"].(string)
// At minimum we need either a uri or a name to be useful.
// Empty-part entries are skipped (they're a malformed inbound
// — surface nothing rather than emit a no-info placeholder).
if uri == "" && name == "" {
continue
}
att := map[string]interface{}{"kind": kind}
if uri != "" {
att["uri"] = uri
}
if mimeType != "" {
att["mime_type"] = mimeType
}
if name != "" {
att["name"] = name
}
out = append(out, att)
}
if len(out) == 0 {
return nil
}
return out
}
// extractAttachmentFromFlatUploadManifest handles the canvas
// chat_upload_receive shape: a single upload manifest at the root of
// request_body with no JSON-RPC envelope. Canvas uses camelCase
// `mimeType`; we normalize to snake_case `mime_type` on emit so the
// wire shape matches the message-parts arm. Kind is derived from the
// mime prefix (image/* → "image", audio/* → "audio", video/* → "video",
// anything else → "file") because the canvas upload row doesn't carry
// an explicit discriminator. Returns nil if neither `uri` nor `file_id`
// is present at the root (i.e. not a flat upload manifest).
func extractAttachmentFromFlatUploadManifest(body map[string]interface{}) map[string]interface{} {
uri, _ := body["uri"].(string)
fileID, _ := body["file_id"].(string)
if uri == "" && fileID == "" {
return nil
}
mimeType, _ := body["mimeType"].(string)
if mimeType == "" {
// Defensive: future canvas versions might emit snake_case directly.
mimeType, _ = body["mime_type"].(string)
}
name, _ := body["name"].(string)
// Apply the same minimum-info rule as the message-parts arm: a
// manifest with neither uri nor name is non-actionable; skip.
if uri == "" && name == "" {
return nil
}
att := map[string]interface{}{"kind": kindFromMimeType(mimeType)}
if uri != "" {
att["uri"] = uri
}
if mimeType != "" {
att["mime_type"] = mimeType
}
if name != "" {
att["name"] = name
}
return att
}
// kindFromMimeType derives the attachment `kind` discriminator from a
// MIME type. Used by the flat-upload-manifest arm where the source row
// has no explicit kind field.
func kindFromMimeType(mime string) string {
switch {
case strings.HasPrefix(mime, "image/"):
return "image"
case strings.HasPrefix(mime, "audio/"):
return "audio"
case strings.HasPrefix(mime, "video/"):
return "video"
default:
return "file"
}
}
// includeFlagSet returns true iff `flag` appears in the comma-separated
// `?include=` query value. Whitespace around entries is tolerated.
// Empty `include` returns false (existing back-compat shape).
//
// The comma-separable form lets future fields ("attachments_only",
// "tool_trace_expanded", etc.) slot in without further URL-param creep.
func includeFlagSet(includeQuery, flag string) bool {
if includeQuery == "" || flag == "" {
return false
}
for _, raw := range strings.Split(includeQuery, ",") {
if strings.TrimSpace(raw) == flag {
return true
}
}
return false
}
// List handles GET /workspaces/:id/activity?type=&source=&limit=&since_secs=&since_id=&include=
//
// The `include` query param is comma-separable; today the only flag is
// `peer_info`, which enriches a2a_receive rows with `peer_name`,
// `peer_role`, `agent_card_url`, and an `attachments[]` projection (see
// extractAttachmentsFromRequestBody). It's additive + opt-in — existing
// callers that don't pass `?include=peer_info` see the unchanged shape.
// Surface for the layered enrichment that lets Claude Code channel
// pushes carry full sender identity instead of bare UUIDs (sibling
// repos: molecule-ai-workspace-runtime + molecule-mcp-claude-channel).
//
// since_secs filters to activity_logs.created_at >= NOW() - INTERVAL '$N seconds'.
// Optional, additive — callers that don't pass it get today's behavior (the
@@ -102,6 +308,8 @@ func (h *ActivityHandler) List(c *gin.Context) {
sinceSecsStr := c.Query("since_secs")
sinceID := c.Query("since_id")
beforeTSStr := c.Query("before_ts") // optional RFC3339 — return rows strictly older than this timestamp
include := c.Query("include") // comma-separated; today's only flag is "peer_info"
includePeerInfo := includeFlagSet(include, "peer_info")
// Validate peer_id as a UUID at the trust boundary so a malformed
// caller (the agent or a downstream MCP tool) can't smuggle SQL
@@ -192,22 +400,60 @@ func (h *ActivityHandler) List(c *gin.Context) {
usingCursor = true
}
// Build query with optional filters
query := `SELECT id, workspace_id, activity_type, source_id, target_id, method,
summary, request_body, response_body, tool_trace, duration_ms, status, error_detail, created_at
FROM activity_logs WHERE workspace_id = $1`
// Build query with optional filters. When ?include=peer_info is set,
// LEFT JOIN workspaces ON activity_logs.source_id = w.id so we can
// surface w.name + w.role on the row. LEFT (not INNER) is required
// for two reasons:
// 1. Canvas rows have source_id IS NULL — those must still appear
// in the result set (with NULL peer_name/peer_role).
// 2. A peer workspace may have been deleted since the row was
// written (no FK constraint on activity_logs.source_id) —
// LEFT JOIN preserves the activity row with NULL peer fields
// rather than silently dropping the row.
//
// agent_card_url is NOT pulled from the workspaces table; it's
// computed server-side from externalPlatformURL + source_id at
// projection time (mirrors molecule-ai-workspace-runtime
// a2a_client._agent_card_url_for which constructs
// {PLATFORM_URL}/registry/discover/{peer_id}).
//
// Column qualification (`activity_logs.<col>`) is added ONLY when
// the JOIN is present — disambiguates `id` / `created_at` which
// exist in both tables. When the JOIN is absent, unqualified
// column references preserve the exact wire-shape existing callers
// + existing test fixtures expect (back-compat).
actCol := ""
if includePeerInfo {
actCol = "activity_logs."
}
selectClause := `SELECT ` + actCol + `id, ` + actCol + `workspace_id, ` + actCol + `activity_type, ` +
actCol + `source_id, ` + actCol + `target_id, ` + actCol + `method, ` +
actCol + `summary, ` + actCol + `request_body, ` + actCol + `response_body, ` +
actCol + `tool_trace, ` + actCol + `duration_ms, ` + actCol + `status, ` +
actCol + `error_detail, ` + actCol + `created_at`
fromClause := ` FROM activity_logs`
if includePeerInfo {
selectClause += `, w.name AS peer_name, w.role AS peer_role`
fromClause += ` LEFT JOIN workspaces w ON w.id = activity_logs.source_id`
}
query := selectClause + fromClause + ` WHERE ` + actCol + `workspace_id = $1`
args := []interface{}{workspaceID}
argIdx := 2
// WHERE/ORDER column refs use the same `actCol` qualifier prefix
// computed above — empty string when no JOIN (back-compat with
// existing wire shape + sqlmock-regex test fixtures), or
// `activity_logs.` when LEFT JOIN'd (disambiguates `id` /
// `created_at` between the two tables).
if activityType != "" {
query += fmt.Sprintf(" AND activity_type = $%d", argIdx)
query += fmt.Sprintf(" AND "+actCol+"activity_type = $%d", argIdx)
args = append(args, activityType)
argIdx++
}
if source == "canvas" {
query += " AND source_id IS NULL"
query += " AND " + actCol + "source_id IS NULL"
} else if source == "agent" {
query += " AND source_id IS NOT NULL"
query += " AND " + actCol + "source_id IS NOT NULL"
} else if source != "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "source must be 'canvas' or 'agent'"})
return
@@ -224,7 +470,7 @@ func (h *ActivityHandler) List(c *gin.Context) {
// and avoids duplicate parameter binding (some drivers reject the
// same arg slot reused, ours is fine but the explicit form is
// clearer to read and matches the rest of the builder.)
query += fmt.Sprintf(" AND (source_id = $%d OR target_id = $%d)", argIdx, argIdx)
query += fmt.Sprintf(" AND ("+actCol+"source_id = $%d OR "+actCol+"target_id = $%d)", argIdx, argIdx)
args = append(args, peerID)
argIdx++
}
@@ -232,7 +478,7 @@ func (h *ActivityHandler) List(c *gin.Context) {
// Strictly older — never replay a row with the exact same
// timestamp, mirrors the `created_at > cursorTime` shape
// `since_id` uses for forward paging.
query += fmt.Sprintf(" AND created_at < $%d", argIdx)
query += fmt.Sprintf(" AND "+actCol+"created_at < $%d", argIdx)
args = append(args, beforeTS)
argIdx++
}
@@ -241,13 +487,13 @@ func (h *ActivityHandler) List(c *gin.Context) {
// interpolated into the SQL string. `make_interval(secs => $N)`
// avoids the lib/pq quirk where INTERVAL '$N seconds' won't
// substitute a placeholder inside the literal.
query += fmt.Sprintf(" AND created_at >= NOW() - make_interval(secs => $%d)", argIdx)
query += fmt.Sprintf(" AND "+actCol+"created_at >= NOW() - make_interval(secs => $%d)", argIdx)
args = append(args, sinceSecs)
argIdx++
}
if usingCursor {
// Strictly after — never replay the cursor row itself.
query += fmt.Sprintf(" AND created_at > $%d", argIdx)
query += fmt.Sprintf(" AND "+actCol+"created_at > $%d", argIdx)
args = append(args, cursorTime)
argIdx++
}
@@ -257,9 +503,9 @@ func (h *ActivityHandler) List(c *gin.Context) {
// since_id) keeps DESC — that's the canvas/UI shape and changing it
// would surprise existing callers.
if usingCursor {
query += fmt.Sprintf(" ORDER BY created_at ASC LIMIT $%d", argIdx)
query += fmt.Sprintf(" ORDER BY "+actCol+"created_at ASC LIMIT $%d", argIdx)
} else {
query += fmt.Sprintf(" ORDER BY created_at DESC LIMIT $%d", argIdx)
query += fmt.Sprintf(" ORDER BY "+actCol+"created_at DESC LIMIT $%d", argIdx)
}
args = append(args, limit)
@@ -272,6 +518,14 @@ func (h *ActivityHandler) List(c *gin.Context) {
}
defer rows.Close()
// agent_card_url base computed once per request so we don't pay the
// header-read cost per row. Only meaningful when includePeerInfo is
// set; the empty string here is harmless when the flag is off.
var platformBase string
if includePeerInfo {
platformBase = externalPlatformURL(c)
}
activities := make([]map[string]interface{}, 0)
for rows.Next() {
var id, wsID, actType, status string
@@ -279,10 +533,23 @@ func (h *ActivityHandler) List(c *gin.Context) {
var reqBody, respBody, toolTrace []byte
var durationMs *int
var createdAt time.Time
// LEFT JOIN'd peer columns — pointer-string so a NULL row
// (canvas message OR deleted peer workspace) decodes as nil
// rather than empty-string. Only scanned when includePeerInfo
// is set (matched against the SELECT clause above).
var peerName, peerRole *string
if err := rows.Scan(&id, &wsID, &actType, &sourceID, &targetID, &method,
&summary, &reqBody, &respBody, &toolTrace, &durationMs, &status, &errorDetail, &createdAt); err != nil {
log.Printf("Activity scan error: %v", err)
var scanErr error
if includePeerInfo {
scanErr = rows.Scan(&id, &wsID, &actType, &sourceID, &targetID, &method,
&summary, &reqBody, &respBody, &toolTrace, &durationMs, &status, &errorDetail, &createdAt,
&peerName, &peerRole)
} else {
scanErr = rows.Scan(&id, &wsID, &actType, &sourceID, &targetID, &method,
&summary, &reqBody, &respBody, &toolTrace, &durationMs, &status, &errorDetail, &createdAt)
}
if scanErr != nil {
log.Printf("Activity scan error: %v", scanErr)
continue
}
@@ -308,6 +575,39 @@ func (h *ActivityHandler) List(c *gin.Context) {
if toolTrace != nil {
entry["tool_trace"] = json.RawMessage(toolTrace)
}
// peer_info enrichment (per ?include=peer_info). Only emit the
// new fields when the flag is set — back-compat for callers
// that don't request it.
if includePeerInfo {
// peer_name / peer_role: emit only when present (canvas
// rows have source_id IS NULL → peer_name is NULL by JOIN;
// also a peer workspace may have been deleted since the
// row was written → same NULL outcome). Omit-when-absent
// matches the Layer 3 adaptor's "spread when present"
// pattern; canvas_user rows legitimately have no peer_*.
if peerName != nil && *peerName != "" {
entry["peer_name"] = *peerName
}
if peerRole != nil && *peerRole != "" {
entry["peer_role"] = *peerRole
}
// agent_card_url: constructed server-side from
// externalPlatformURL + source_id. Mirrors the runtime-
// side helper a2a_client._agent_card_url_for which builds
// {PLATFORM_URL}/registry/discover/{peer_id}. Only set
// when source_id is present + non-empty.
if sourceID != nil && *sourceID != "" && platformBase != "" {
entry["agent_card_url"] = platformBase + "/registry/discover/" + *sourceID
}
// attachments: flatten file/image/audio parts from the
// request_body. nil when none — only project when
// non-empty so the omit-when-absent rule holds.
if atts := extractAttachmentsFromRequestBody(reqBody); len(atts) > 0 {
entry["attachments"] = atts
}
}
activities = append(activities, entry)
}
if err := rows.Err(); err != nil {
@@ -0,0 +1,701 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
)
// Tests for the `?include=peer_info` activity-feed enrichment.
//
// The enrichment is additive + opt-in. When the flag is absent, the
// existing tests (TestActivityList_SourceCanvas, etc.) prove the wire
// shape is unchanged. These tests prove:
// - When the flag IS set, the LEFT JOIN is issued and the SELECT
// adds w.name + w.role.
// - peer_name / peer_role surface from the joined row.
// - agent_card_url is composed server-side from
// externalPlatformURL + source_id and appears for non-canvas rows
// (source_id present).
// - attachments[] is projected from request_body.params.message.parts
// for file/image/audio parts.
// - Canvas rows (source_id NULL) do NOT get peer_name / peer_role /
// agent_card_url, but DO still appear in the result set (LEFT JOIN
// preserves them with NULL peer fields).
// - The `include` query param is comma-separable and only recognizes
// known flags.
// ---------- includeFlagSet helper unit tests ----------
func TestIncludeFlagSet(t *testing.T) {
cases := []struct {
query string
flag string
want bool
}{
{"", "peer_info", false},
{"peer_info", "peer_info", true},
{"peer_info,attachments", "peer_info", true},
{"attachments,peer_info", "peer_info", true},
{"attachments , peer_info ", "peer_info", true},
{"peer_infos", "peer_info", false},
{"peerinfo", "peer_info", false},
{"peer_info", "", false},
{",,", "peer_info", false},
}
for _, tc := range cases {
got := includeFlagSet(tc.query, tc.flag)
if got != tc.want {
t.Errorf("includeFlagSet(%q, %q) = %v, want %v", tc.query, tc.flag, got, tc.want)
}
}
}
// ---------- extractAttachmentsFromRequestBody unit tests ----------
func TestExtractAttachmentsFromRequestBody_Empty(t *testing.T) {
if got := extractAttachmentsFromRequestBody(nil); got != nil {
t.Errorf("nil body: want nil, got %v", got)
}
if got := extractAttachmentsFromRequestBody([]byte("")); got != nil {
t.Errorf("empty body: want nil, got %v", got)
}
if got := extractAttachmentsFromRequestBody([]byte("not json")); got != nil {
t.Errorf("non-json body: want nil, got %v", got)
}
}
func TestExtractAttachmentsFromRequestBody_NoAttachments(t *testing.T) {
// Text-only message: no file/image/audio parts → nil
body := []byte(`{"jsonrpc":"2.0","method":"message/send","params":{"message":{"parts":[{"kind":"text","text":"hi"}]}}}`)
if got := extractAttachmentsFromRequestBody(body); got != nil {
t.Errorf("text-only: want nil, got %v", got)
}
}
func TestExtractAttachmentsFromRequestBody_FileKindV1(t *testing.T) {
// a2a-sdk v1 shape: kind=file, file:{uri,mime_type,name}
body := []byte(`{"jsonrpc":"2.0","method":"message/send","params":{"message":{"parts":[
{"kind":"text","text":"see attached"},
{"kind":"file","file":{"uri":"workspace:foo.pdf","mime_type":"application/pdf","name":"foo.pdf"}}
]}}}`)
atts := extractAttachmentsFromRequestBody(body)
if len(atts) != 1 {
t.Fatalf("want 1 attachment, got %d", len(atts))
}
if atts[0]["kind"] != "file" {
t.Errorf("kind: want file, got %v", atts[0]["kind"])
}
if atts[0]["uri"] != "workspace:foo.pdf" {
t.Errorf("uri mismatch: %v", atts[0]["uri"])
}
if atts[0]["mime_type"] != "application/pdf" {
t.Errorf("mime_type mismatch: %v", atts[0]["mime_type"])
}
if atts[0]["name"] != "foo.pdf" {
t.Errorf("name mismatch: %v", atts[0]["name"])
}
}
func TestExtractAttachmentsFromRequestBody_ImageAndAudio(t *testing.T) {
// Mixed image + audio parts; both surface
body := []byte(`{"jsonrpc":"2.0","method":"message/send","params":{"message":{"parts":[
{"kind":"image","file":{"uri":"workspace:a.png","mime_type":"image/png","name":"a.png"}},
{"kind":"audio","file":{"uri":"workspace:b.mp3","mime_type":"audio/mpeg","name":"b.mp3"}}
]}}}`)
atts := extractAttachmentsFromRequestBody(body)
if len(atts) != 2 {
t.Fatalf("want 2 attachments, got %d", len(atts))
}
if atts[0]["kind"] != "image" || atts[1]["kind"] != "audio" {
t.Errorf("kind order: got %v / %v", atts[0]["kind"], atts[1]["kind"])
}
}
func TestExtractAttachmentsFromRequestBody_LegacyV0TypeDiscriminator(t *testing.T) {
// Legacy v0 shape: type=file (not kind), inlined fields (no nested .file)
body := []byte(`{"jsonrpc":"2.0","method":"message/send","params":{"message":{"parts":[
{"type":"file","uri":"workspace:legacy.txt","mime_type":"text/plain","name":"legacy.txt"}
]}}}`)
atts := extractAttachmentsFromRequestBody(body)
if len(atts) != 1 {
t.Fatalf("want 1 attachment, got %d", len(atts))
}
if atts[0]["kind"] != "file" || atts[0]["uri"] != "workspace:legacy.txt" || atts[0]["name"] != "legacy.txt" {
t.Errorf("v0 part not surfaced: %v", atts[0])
}
}
func TestExtractAttachmentsFromRequestBody_SkipsEmptyParts(t *testing.T) {
// A "file" part with no uri AND no name is malformed — skip rather
// than emit a no-info entry.
body := []byte(`{"jsonrpc":"2.0","method":"message/send","params":{"message":{"parts":[
{"kind":"file","file":{}},
{"kind":"file","file":{"name":"only-name.bin"}}
]}}}`)
atts := extractAttachmentsFromRequestBody(body)
if len(atts) != 1 {
t.Fatalf("want 1 attachment (the named one), got %d", len(atts))
}
if atts[0]["name"] != "only-name.bin" {
t.Errorf("expected only-name.bin, got %v", atts[0])
}
}
func TestExtractAttachmentsFromRequestBody_MalformedShape(t *testing.T) {
// Various malformed shapes return nil (defensive)
for _, b := range []string{
`{}`,
`{"params":{}}`,
`{"params":{"message":{}}}`,
`{"params":{"message":{"parts":"not-a-list"}}}`,
`{"params":{"message":{"parts":[null,42,"string"]}}}`,
} {
if got := extractAttachmentsFromRequestBody([]byte(b)); got != nil {
t.Errorf("body %q: want nil, got %v", b, got)
}
}
}
// ---------- Activity List ?include=peer_info handler tests ----------
func TestActivityList_IncludePeerInfo_IssuesLeftJoin(t *testing.T) {
// When ?include=peer_info is set, the query must:
// 1. SELECT include w.name + w.role aliased as peer_name/peer_role
// 2. FROM contains LEFT JOIN workspaces w ON w.id = activity_logs.source_id
// 3. WHERE uses qualified activity_logs.workspace_id (disambiguates
// from workspaces.id post-JOIN)
//
// Pin all three so a future refactor can't silently drop the JOIN or
// the alias and have the test still pass.
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster)
peerID := "11111111-2222-3333-4444-555555555555"
mock.ExpectQuery(
`SELECT .+w\.name AS peer_name, w\.role AS peer_role FROM activity_logs LEFT JOIN workspaces w ON w\.id = activity_logs\.source_id WHERE activity_logs\.workspace_id = .+`,
).
WithArgs("ws-1", 100).
WillReturnRows(sqlmock.NewRows([]string{
"id", "workspace_id", "activity_type", "source_id", "target_id",
"method", "summary", "request_body", "response_body",
"tool_trace", "duration_ms", "status", "error_detail", "created_at",
"peer_name", "peer_role",
}).
AddRow("act-1", "ws-1", "a2a_receive", peerID, "ws-1",
"message/send", "Agent message: hello",
[]byte(`{"jsonrpc":"2.0","method":"message/send","params":{"message":{"parts":[{"kind":"text","text":"hello"}]}}}`),
nil, nil, nil, "ok", nil, time.Now(),
"Production Manager", "product manager"))
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-1/activity?include=peer_info", nil)
c.Request.Host = "platform.test"
c.Request.Header.Set("X-Forwarded-Proto", "https")
handler.List(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp []map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("parse: %v", err)
}
if len(resp) != 1 {
t.Fatalf("want 1 row, got %d", len(resp))
}
r := resp[0]
if r["peer_name"] != "Production Manager" {
t.Errorf("peer_name: got %v", r["peer_name"])
}
if r["peer_role"] != "product manager" {
t.Errorf("peer_role: got %v", r["peer_role"])
}
wantURL := "https://platform.test/registry/discover/" + peerID
if r["agent_card_url"] != wantURL {
t.Errorf("agent_card_url: got %v, want %v", r["agent_card_url"], wantURL)
}
// Text-only message has no attachments → omit from envelope
if _, present := r["attachments"]; present {
t.Errorf("attachments should be omitted on text-only row; got %v", r["attachments"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
}
}
func TestActivityList_IncludePeerInfo_CanvasRowHasNoPeerFields(t *testing.T) {
// LEFT JOIN preserves canvas rows (source_id NULL) but their
// peer_name/peer_role come back as NULL — must omit from the
// envelope (not emit empty strings or null literals).
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster)
mock.ExpectQuery(
`LEFT JOIN workspaces w ON w\.id = activity_logs\.source_id`,
).
WithArgs("ws-1", 100).
WillReturnRows(sqlmock.NewRows([]string{
"id", "workspace_id", "activity_type", "source_id", "target_id",
"method", "summary", "request_body", "response_body",
"tool_trace", "duration_ms", "status", "error_detail", "created_at",
"peer_name", "peer_role",
}).
// source_id NULL = canvas message; peer columns also NULL.
AddRow("act-canvas", "ws-1", "a2a_receive", nil, "ws-1",
"notify", "User said hi",
[]byte(`{"params":{"message":{"parts":[{"kind":"text","text":"hi"}]}}}`),
nil, nil, nil, "ok", nil, time.Now(),
nil, nil))
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-1/activity?include=peer_info", nil)
handler.List(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp []map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("parse: %v", err)
}
if len(resp) != 1 {
t.Fatalf("want 1 row, got %d", len(resp))
}
r := resp[0]
for _, k := range []string{"peer_name", "peer_role", "agent_card_url"} {
if _, present := r[k]; present {
t.Errorf("%s should be absent on canvas row; got %v", k, r[k])
}
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
}
}
func TestActivityList_IncludePeerInfo_AttachmentsSurfaceFromRequestBody(t *testing.T) {
// A peer_agent message with an inline file attachment must have
// attachments[] populated on the envelope.
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster)
peerID := "11111111-2222-3333-4444-555555555555"
mock.ExpectQuery(`LEFT JOIN workspaces`).
WithArgs("ws-1", 100).
WillReturnRows(sqlmock.NewRows([]string{
"id", "workspace_id", "activity_type", "source_id", "target_id",
"method", "summary", "request_body", "response_body",
"tool_trace", "duration_ms", "status", "error_detail", "created_at",
"peer_name", "peer_role",
}).
AddRow("act-with-file", "ws-1", "a2a_receive", peerID, "ws-1",
"message/send", "Agent message: see attached",
[]byte(`{"jsonrpc":"2.0","method":"message/send","params":{"message":{"parts":[
{"kind":"text","text":"see attached"},
{"kind":"file","file":{"uri":"workspace:foo.pdf","mime_type":"application/pdf","name":"foo.pdf"}}
]}}}`),
nil, nil, nil, "ok", nil, time.Now(),
"Code Reviewer", "code reviewer"))
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-1/activity?include=peer_info", nil)
handler.List(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp []map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("parse: %v", err)
}
r := resp[0]
atts, ok := r["attachments"].([]interface{})
if !ok {
t.Fatalf("attachments missing or wrong type: %T %v", r["attachments"], r["attachments"])
}
if len(atts) != 1 {
t.Fatalf("want 1 attachment, got %d: %v", len(atts), atts)
}
att := atts[0].(map[string]interface{})
if att["kind"] != "file" || att["uri"] != "workspace:foo.pdf" || att["name"] != "foo.pdf" {
t.Errorf("attachment shape: %v", att)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
}
}
func TestActivityList_IncludePeerInfo_Unset_NoJoinNoExtraFields(t *testing.T) {
// Back-compat — when ?include=peer_info is NOT passed, the SELECT
// uses unqualified column refs (no `activity_logs.` prefix) AND no
// JOIN. Existing tests pass this implicitly; this test pins it
// explicitly so a future refactor that accidentally turns the JOIN
// always-on gets caught.
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster)
// Regex pinned: "FROM activity_logs WHERE workspace_id" — no JOIN
// keyword between FROM and WHERE; no `activity_logs.` qualifier on
// workspace_id.
mock.ExpectQuery(`SELECT id, workspace_id,.+ FROM activity_logs WHERE workspace_id = .+`).
WithArgs("ws-1", 100).
WillReturnRows(sqlmock.NewRows([]string{
"id", "workspace_id", "activity_type", "source_id", "target_id",
"method", "summary", "request_body", "response_body",
"tool_trace", "duration_ms", "status", "error_detail", "created_at",
}).
AddRow("act-1", "ws-1", "a2a_receive", "11111111-2222-3333-4444-555555555555", "ws-1",
"message/send", "Hello",
nil, nil, nil, nil, "ok", nil, time.Now()))
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-1/activity", nil)
handler.List(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp []map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("parse: %v", err)
}
if len(resp) != 1 {
t.Fatalf("want 1 row, got %d", len(resp))
}
// Confirm no peer_info enrichment leaks into the default envelope.
for _, k := range []string{"peer_name", "peer_role", "agent_card_url", "attachments"} {
if _, present := resp[0][k]; present {
t.Errorf("%s must NOT appear without ?include=peer_info; got %v", k, resp[0][k])
}
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
}
}
func TestActivityList_IncludePeerInfo_UnknownFlagIgnored(t *testing.T) {
// ?include=bogus must NOT issue the JOIN — only the recognized
// `peer_info` flag triggers enrichment. The unknown flag is silently
// ignored (additive, opt-in convention).
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster)
mock.ExpectQuery(`SELECT id, workspace_id,.+ FROM activity_logs WHERE workspace_id = .+`).
WithArgs("ws-1", 100).
WillReturnRows(sqlmock.NewRows([]string{
"id", "workspace_id", "activity_type", "source_id", "target_id",
"method", "summary", "request_body", "response_body",
"tool_trace", "duration_ms", "status", "error_detail", "created_at",
}))
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-1/activity?include=bogus", nil)
handler.List(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
}
}
// ---------- flat upload manifest (chat_upload_receive) tests ----------
func TestKindFromMimeType(t *testing.T) {
cases := []struct {
mime string
want string
}{
{"image/png", "image"},
{"image/jpeg", "image"},
{"image/", "image"}, // prefix-only is still image
{"audio/mpeg", "audio"},
{"audio/wav", "audio"},
{"video/mp4", "video"},
{"video/webm", "video"},
{"application/pdf", "file"},
{"text/plain", "file"},
{"", "file"},
{"unknown", "file"},
{"image", "file"}, // no slash → not a prefix match
}
for _, tc := range cases {
if got := kindFromMimeType(tc.mime); got != tc.want {
t.Errorf("kindFromMimeType(%q) = %q, want %q", tc.mime, got, tc.want)
}
}
}
func TestExtractAttachmentsFromRequestBody_FlatUpload_Image(t *testing.T) {
// Canvas chat_upload_receive shape: flat manifest at request_body
// root with camelCase mimeType. The empirical example was a PNG
// pasted into the canvas; surfaces here with kind=image,
// mime_type=image/png (snake-case normalized), uri preserved.
body := []byte(`{
"uri":"platform-pending:091a9180-/26111d48-",
"name":"pasted-2026-05-21T23-12-25-0-0.png",
"size":677133,
"file_id":"26111d48-",
"mimeType":"image/png"
}`)
atts := extractAttachmentsFromRequestBody(body)
if len(atts) != 1 {
t.Fatalf("want 1 attachment, got %d: %v", len(atts), atts)
}
att := atts[0]
if att["kind"] != "image" {
t.Errorf("kind: want image, got %v", att["kind"])
}
if att["uri"] != "platform-pending:091a9180-/26111d48-" {
t.Errorf("uri: %v", att["uri"])
}
if att["mime_type"] != "image/png" {
t.Errorf("mime_type normalization (camelCase→snake_case) failed: %v", att["mime_type"])
}
if att["name"] != "pasted-2026-05-21T23-12-25-0-0.png" {
t.Errorf("name: %v", att["name"])
}
// camelCase `mimeType` MUST NOT leak into the projected envelope —
// only snake_case `mime_type` is the wire convention.
if _, present := att["mimeType"]; present {
t.Errorf("camelCase mimeType leaked into envelope: %v", att)
}
if _, present := att["file_id"]; present {
t.Errorf("file_id should not be surfaced on the attachment envelope (it's a canvas-internal id): %v", att)
}
}
func TestExtractAttachmentsFromRequestBody_FlatUpload_Audio(t *testing.T) {
body := []byte(`{"uri":"platform-pending:ws/file","name":"voice.mp3","file_id":"abc","mimeType":"audio/mpeg"}`)
atts := extractAttachmentsFromRequestBody(body)
if len(atts) != 1 || atts[0]["kind"] != "audio" {
t.Fatalf("want audio kind, got %v", atts)
}
if atts[0]["mime_type"] != "audio/mpeg" {
t.Errorf("mime_type: %v", atts[0]["mime_type"])
}
}
func TestExtractAttachmentsFromRequestBody_FlatUpload_Video(t *testing.T) {
body := []byte(`{"uri":"platform-pending:ws/file","name":"clip.mp4","file_id":"abc","mimeType":"video/mp4"}`)
atts := extractAttachmentsFromRequestBody(body)
if len(atts) != 1 || atts[0]["kind"] != "video" {
t.Fatalf("want video kind, got %v", atts)
}
}
func TestExtractAttachmentsFromRequestBody_FlatUpload_GenericFile(t *testing.T) {
// application/pdf has no image/audio/video prefix → kind=file
body := []byte(`{"uri":"platform-pending:ws/file","name":"doc.pdf","file_id":"abc","mimeType":"application/pdf"}`)
atts := extractAttachmentsFromRequestBody(body)
if len(atts) != 1 || atts[0]["kind"] != "file" {
t.Fatalf("want file kind, got %v", atts)
}
}
func TestExtractAttachmentsFromRequestBody_FlatUpload_NoMimeFallsToFile(t *testing.T) {
// No mimeType at all — kind defaults to "file", mime_type omitted.
body := []byte(`{"uri":"platform-pending:ws/file","name":"unknown.bin","file_id":"abc"}`)
atts := extractAttachmentsFromRequestBody(body)
if len(atts) != 1 {
t.Fatalf("want 1 attachment, got %d", len(atts))
}
if atts[0]["kind"] != "file" {
t.Errorf("kind: want file (default), got %v", atts[0]["kind"])
}
if _, present := atts[0]["mime_type"]; present {
t.Errorf("mime_type should be omitted when source has none, got %v", atts[0]["mime_type"])
}
}
func TestExtractAttachmentsFromRequestBody_FlatUpload_SnakeCaseMimeTypeAccepted(t *testing.T) {
// Defensive: a future canvas version (or non-canvas caller) that
// already emits snake_case mime_type should still be parsed.
body := []byte(`{"uri":"u","name":"n.png","mime_type":"image/png"}`)
atts := extractAttachmentsFromRequestBody(body)
if len(atts) != 1 {
t.Fatalf("want 1 attachment, got %d", len(atts))
}
if atts[0]["mime_type"] != "image/png" || atts[0]["kind"] != "image" {
t.Errorf("snake_case mime_type not honored: %v", atts[0])
}
}
func TestExtractAttachmentsFromRequestBody_FlatUpload_FileIDOnlyIsSkipped(t *testing.T) {
// file_id alone (no uri AND no name) is non-actionable — the
// downstream adaptor can't render a discoverable file from just an
// internal canvas id. Skip per the same minimum-info rule the
// message-parts arm applies to empty parts.
body := []byte(`{"file_id":"orphan-uuid","mimeType":"image/png"}`)
if got := extractAttachmentsFromRequestBody(body); got != nil {
t.Errorf("file_id-only manifest must be skipped, got %v", got)
}
}
func TestExtractAttachmentsFromRequestBody_FlatUpload_NameOnlyIsKept(t *testing.T) {
// Symmetric with the message-parts arm: a name without uri is still
// useful (the downstream adaptor can render "user uploaded foo.png").
body := []byte(`{"name":"only-name.bin","file_id":"abc","mimeType":"application/octet-stream"}`)
atts := extractAttachmentsFromRequestBody(body)
if len(atts) != 1 {
t.Fatalf("want 1 attachment, got %d", len(atts))
}
if atts[0]["name"] != "only-name.bin" {
t.Errorf("name not preserved: %v", atts[0])
}
if _, present := atts[0]["uri"]; present {
t.Errorf("uri should be omitted when absent in source, got %v", atts[0]["uri"])
}
}
func TestExtractAttachmentsFromRequestBody_MessagePartsTakesPrecedenceOverFlat(t *testing.T) {
// If a single request_body somehow has BOTH params.message.parts[]
// AND top-level uri/file_id (a pathological inbound), the
// message-parts arm wins — that's the documented inbound shape and
// it's been the only one historically extracted. The flat arm is a
// fallback for shapes that have NO parts.
body := []byte(`{
"uri":"platform-pending:should-not-win",
"file_id":"x",
"mimeType":"image/png",
"params":{"message":{"parts":[
{"kind":"file","file":{"uri":"workspace:should-win.pdf","mime_type":"application/pdf","name":"win.pdf"}}
]}}
}`)
atts := extractAttachmentsFromRequestBody(body)
if len(atts) != 1 {
t.Fatalf("want 1 attachment (from parts[]), got %d: %v", len(atts), atts)
}
if atts[0]["uri"] != "workspace:should-win.pdf" {
t.Errorf("message-parts arm did not take precedence: %v", atts[0])
}
}
func TestActivityList_IncludePeerInfo_ChatUploadReceiveCanvasRow(t *testing.T) {
// Wire-level integration: a canvas chat_upload_receive row (canvas
// user pasted an image) with source_id NULL (canvas message), flat
// upload manifest at request_body root. The `?include=peer_info`
// projection must surface attachments[] populated from the flat-
// upload-manifest arm while peer_name / peer_role / agent_card_url
// remain absent (canvas row has no peer).
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster)
mock.ExpectQuery(`LEFT JOIN workspaces w ON w\.id = activity_logs\.source_id`).
WithArgs("ws-1", 100).
WillReturnRows(sqlmock.NewRows([]string{
"id", "workspace_id", "activity_type", "source_id", "target_id",
"method", "summary", "request_body", "response_body",
"tool_trace", "duration_ms", "status", "error_detail", "created_at",
"peer_name", "peer_role",
}).
// Empirical shape from 2026-05-21 ~23:12Z agents-team canvas paste.
AddRow("act-upload", "ws-1", "chat_upload_receive", nil, "ws-1",
"chat_upload_receive", "Canvas upload: pasted-2026-05-21T23-12-25-0-0.png",
[]byte(`{
"uri":"platform-pending:091a9180-b303-4a20-aefe-3a4a675b8aa4/26111d48-aaaa-bbbb-cccc-dddddddddddd",
"name":"pasted-2026-05-21T23-12-25-0-0.png",
"size":677133,
"file_id":"26111d48-aaaa-bbbb-cccc-dddddddddddd",
"mimeType":"image/png"
}`),
nil, nil, nil, "ok", nil, time.Now(),
nil, nil))
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-1/activity?include=peer_info", nil)
handler.List(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp []map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("parse: %v", err)
}
if len(resp) != 1 {
t.Fatalf("want 1 row, got %d", len(resp))
}
r := resp[0]
// Canvas row → no peer fields.
for _, k := range []string{"peer_name", "peer_role", "agent_card_url"} {
if _, present := r[k]; present {
t.Errorf("%s must NOT appear on canvas upload row; got %v", k, r[k])
}
}
// attachments[] populated from the flat-upload arm.
atts, ok := r["attachments"].([]interface{})
if !ok {
t.Fatalf("attachments missing or wrong type: %T %v", r["attachments"], r["attachments"])
}
if len(atts) != 1 {
t.Fatalf("want 1 attachment from flat manifest, got %d: %v", len(atts), atts)
}
att := atts[0].(map[string]interface{})
if att["kind"] != "image" {
t.Errorf("kind: want image (image/png prefix), got %v", att["kind"])
}
if att["mime_type"] != "image/png" {
t.Errorf("mime_type wire shape: want snake_case image/png, got %v", att["mime_type"])
}
if att["uri"] != "platform-pending:091a9180-b303-4a20-aefe-3a4a675b8aa4/26111d48-aaaa-bbbb-cccc-dddddddddddd" {
t.Errorf("uri preserved verbatim: got %v", att["uri"])
}
if att["name"] != "pasted-2026-05-21T23-12-25-0-0.png" {
t.Errorf("name: %v", att["name"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
}
}
// Sanity test using the existing test broadcaster setup — verifies the
// extractAttachments helper round-trips through json.Marshal cleanly
// (no map ordering issues, no type-coercion surprises).
func TestExtractAttachmentsFromRequestBody_RoundTripsThroughJSON(t *testing.T) {
body := []byte(`{"params":{"message":{"parts":[{"kind":"file","file":{"uri":"workspace:r.bin","mime_type":"application/octet-stream","name":"r.bin"}}]}}}`)
atts := extractAttachmentsFromRequestBody(body)
b, err := json.Marshal(atts)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var decoded []map[string]interface{}
if err := json.Unmarshal(b, &decoded); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(decoded) != 1 || decoded[0]["uri"] != "workspace:r.bin" {
t.Fatalf("round-trip mismatch: %v", decoded)
}
_ = fmt.Sprintf // keep fmt import live if test trimming removes usage
}
@@ -0,0 +1,72 @@
package handlers
import (
"database/sql"
"fmt"
"log"
"net/http"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
"github.com/gin-gonic/gin"
)
// AdminWorkspaceTokenHandler lets tenant admins mint the first workspace
// bearer for managed SaaS workspaces whose runtime receives its token later
// through registry registration.
type AdminWorkspaceTokenHandler struct{}
func NewAdminWorkspaceTokenHandler() *AdminWorkspaceTokenHandler {
return &AdminWorkspaceTokenHandler{}
}
// Create handles POST /admin/workspaces/:id/tokens. The route must be mounted
// behind AdminAuth; the plaintext token is returned exactly once.
func (h *AdminWorkspaceTokenHandler) Create(c *gin.Context) {
workspaceID := c.Param("id")
if !validWorkspaceID(workspaceID) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace id"})
return
}
var existing string
err := db.DB.QueryRowContext(c.Request.Context(),
`SELECT id FROM workspaces WHERE id = $1 AND status <> 'removed'`,
workspaceID).Scan(&existing)
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
return
}
log.Printf("admin workspace tokens: workspace lookup failed for %s: %v", workspaceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "workspace lookup failed"})
return
}
var count int
if err := db.DB.QueryRowContext(c.Request.Context(),
`SELECT COUNT(*) FROM workspace_auth_tokens WHERE workspace_id = $1 AND revoked_at IS NULL`,
workspaceID).Scan(&count); err != nil {
log.Printf("admin workspace tokens: count failed for %s: %v", workspaceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count tokens"})
return
}
if count >= maxTokensPerWorkspace {
c.JSON(http.StatusTooManyRequests, gin.H{"error": fmt.Sprintf("maximum %d active tokens per workspace", maxTokensPerWorkspace)})
return
}
token, err := wsauth.IssueToken(c.Request.Context(), db.DB, workspaceID)
if err != nil {
log.Printf("admin workspace tokens: issue failed for %s: %v", workspaceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create token"})
return
}
log.Printf("admin workspace tokens: issued token for workspace %s", workspaceID)
c.JSON(http.StatusCreated, gin.H{
"auth_token": token,
"workspace_id": workspaceID,
"message": "Save this token now — it cannot be retrieved again.",
})
}
@@ -0,0 +1,102 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
)
func TestAdminWorkspaceTokenHandler_Create_HappyPath(t *testing.T) {
mock, cleanup := withMockDB(t)
defer cleanup()
mock.ExpectQuery(`SELECT id FROM workspaces WHERE id = \$1 AND status <> 'removed'`).
WithArgs(wsUUID1).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(wsUUID1))
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM workspace_auth_tokens`).
WithArgs(wsUUID1).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
mock.ExpectExec(`INSERT INTO workspace_auth_tokens`).
WithArgs(wsUUID1, sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(1, 1))
w := makeReq(t, NewAdminWorkspaceTokenHandler().Create, "POST",
"/admin/workspaces/"+wsUUID1+"/tokens", gin.Params{{Key: "id", Value: wsUUID1}})
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
var body struct {
AuthToken string `json:"auth_token"`
WorkspaceID string `json:"workspace_id"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.AuthToken == "" || body.WorkspaceID != wsUUID1 {
t.Fatalf("unexpected body: %+v", body)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet: %v", err)
}
}
func TestAdminWorkspaceTokenHandler_Create_MissingWorkspace(t *testing.T) {
mock, cleanup := withMockDB(t)
defer cleanup()
mock.ExpectQuery(`SELECT id FROM workspaces WHERE id = \$1 AND status <> 'removed'`).
WithArgs(wsUUID1).
WillReturnRows(sqlmock.NewRows([]string{"id"}))
w := makeReq(t, NewAdminWorkspaceTokenHandler().Create, "POST",
"/admin/workspaces/"+wsUUID1+"/tokens", gin.Params{{Key: "id", Value: wsUUID1}})
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
}
}
func TestAdminWorkspaceTokenHandler_Create_RateLimited(t *testing.T) {
mock, cleanup := withMockDB(t)
defer cleanup()
mock.ExpectQuery(`SELECT id FROM workspaces WHERE id = \$1 AND status <> 'removed'`).
WithArgs(wsUUID1).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(wsUUID1))
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM workspace_auth_tokens`).
WithArgs(wsUUID1).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(maxTokensPerWorkspace))
w := makeReq(t, NewAdminWorkspaceTokenHandler().Create, "POST",
"/admin/workspaces/"+wsUUID1+"/tokens", gin.Params{{Key: "id", Value: wsUUID1}})
if w.Code != http.StatusTooManyRequests {
t.Fatalf("expected 429, got %d: %s", w.Code, w.Body.String())
}
}
func TestAdminWorkspaceTokenHandler_Create_IssueFails(t *testing.T) {
mock, cleanup := withMockDB(t)
defer cleanup()
mock.ExpectQuery(`SELECT id FROM workspaces WHERE id = \$1 AND status <> 'removed'`).
WithArgs(wsUUID1).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(wsUUID1))
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM workspace_auth_tokens`).
WithArgs(wsUUID1).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
mock.ExpectExec(`INSERT INTO workspace_auth_tokens`).
WillReturnError(errors.New("disk full"))
w := makeReq(t, NewAdminWorkspaceTokenHandler().Create, "POST",
"/admin/workspaces/"+wsUUID1+"/tokens", gin.Params{{Key: "id", Value: wsUUID1}})
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
}
}
@@ -216,69 +216,102 @@ curl -fsS -X POST "{{PLATFORM_URL}}/registry/register" \
const externalChannelTemplate = `# Claude Code channel — bridges this workspace's A2A traffic into your
# Claude Code session. No tunnel/public URL needed (polling-based).
#
# Prereq: Bun installed (channel plugins are Bun scripts).
# bun --version # must print a version number
# Prereq: Bun 1.3+ installed (channel plugins are Bun scripts).
# bun --version # must print a version (1.3.x or newer)
#
# 1. Inside Claude Code, install the channel plugin from its GitHub repo.
# The plugin is NOT on Anthropic's default allowlist, so a one-time
# marketplace-add is needed before install:
# 1. Inside Claude Code, install the channel plugin. The plugin lives in
# Molecule's own Gitea marketplace (not Anthropic's default), so a
# one-time marketplace-add is needed before install:
#
# /plugin marketplace add https://git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel.git
# /plugin install molecule@molecule-channel
#
# Then either run /reload-plugins or restart Claude Code so the
# plugin is registered.
# Then /reload-plugins (or restart Claude Code) so the plugin is
# registered.
#
# 2. Create the per-watched-workspace config file:
# 2. Create (or extend) the per-host config file. The canonical SSOT
# shape is MOLECULE_WORKSPACES_JSON — a JSON array of
# {id, token, platform_url} objects. One plugin instance can watch
# many workspaces across many tenants; append more objects to the
# array (separate them with commas, NOT a newline):
mkdir -p ~/.claude/channels/molecule
cat > ~/.claude/channels/molecule/.env <<'EOF'
MOLECULE_PLATFORM_URL={{PLATFORM_URL}}
MOLECULE_WORKSPACE_IDS={{WORKSPACE_ID}}
MOLECULE_WORKSPACE_TOKENS=<paste auth_token from create response>
MOLECULE_WORKSPACES_JSON=[{"id":"{{WORKSPACE_ID}}","token":"<paste auth_token from create response>","platform_url":"{{PLATFORM_URL}}"}]
EOF
chmod 600 ~/.claude/channels/molecule/.env
# 3. Launch Claude Code with the channel enabled. Custom (non-Anthropic-
# allowlisted) channels need the --dangerously-load-development-channels
# flag to opt in — without it, you'll see "not on the approved channels
# allowlist" on startup.
claude --dangerously-load-development-channels \
--channels plugin:molecule@molecule-channel
# (Legacy single-platform shape — MOLECULE_PLATFORM_URL + comma-separated
# MOLECULE_WORKSPACE_IDS + MOLECULE_WORKSPACE_TOKENS — is still supported
# for back-compat but does NOT work across multiple tenant URLs. Use
# MOLECULE_WORKSPACES_JSON above unless you have a specific reason.)
# 3. Launch Claude Code with the channel enabled. The channel spec is the
# VALUE of --dangerously-load-development-channels — NOT a separate
# --channels flag (that flag does not exist in current Claude Code;
# passing it errors with "entries must be tagged: --channels").
claude --dangerously-load-development-channels plugin:molecule@molecule-channel
# You should see on stderr:
# molecule channel: connected — watching 1 workspace(s) at {{PLATFORM_URL}}
# molecule channel: connected — watching N workspace(s) across M platform(s)
# targets: <platform_url>: <workspace_id>
#
# Inbound A2A messages now surface as conversation turns. Claude's
# replies route back via the reply_to_workspace MCP tool — no extra
# wiring on your side.
# Inbound A2A messages now surface as conversation turns (synthetic
# <channel ...> tags). Claude's replies route back via the
# reply_to_workspace / send_message_to_user MCP tools.
#
# Multi-workspace note: when watching more than one workspace, every
# outbound tool call (send_message_to_user, reply_to_workspace,
# delegate_task, list_peers) MUST pass _as_workspace=<id> so the plugin
# knows which token to authenticate with. The host returns -32603 if you
# forget — the synthetic <channel> tag's "watching_as" attribute tells
# you which id to use.
#
# Common errors:
# "plugin not installed" → Step 1 didn't run; run /plugin install
# "plugin not installed" → Step 1 didn't run; run /plugin
# marketplace add + /plugin install
# inside Claude Code, then /reload-plugins.
# "not on approved channels allowlist" → Add --dangerously-load-development-channels
# to the launch command (Step 3).
# "config-missing" → ~/.claude/channels/molecule/.env not
# readable; re-run Step 2 and check chmod.
# "entries must be tagged" → You passed --channels separately.
# Put plugin:molecule@molecule-channel
# directly after
# --dangerously-load-development-channels.
# "not on approved channels allowlist" → Org policy gating. See "managed
# settings" note below.
# "config-missing" → ~/.claude/channels/molecule/.env
# not readable; re-run Step 2 and check
# chmod 600.
#
# Team/Enterprise orgs: the --dangerously-load-development-channels flag is
# blocked by managed settings. Your admin must set channelsEnabled=true and
# add the plugin to allowedChannelPlugins in claude.ai admin settings.
# Team/Enterprise plans: the channel allowlist is gated by org policy
# AND must be written to the local managed-settings.json file on disk
# (not the claude.ai web admin UI — there is no web toggle for this).
# Path per OS:
# macOS: /Library/Application Support/ClaudeCode/managed-settings.json
# Linux: /etc/claude-code/managed-settings.json
# Windows: C:\ProgramData\ClaudeCode\managed-settings.json
# Set channelsEnabled: true and add
# { "plugin": "molecule", "marketplace": "molecule-channel" }
# to allowedChannelPlugins. Restart Claude Code after writing the file.
# A user-level ~/.claude/settings.json does NOT work on Team/Enterprise
# — this is the single most common reason a freshly-installed plugin
# appears to do nothing.
#
# Multi-workspace: comma-separate IDs and tokens (same order). See
# https://git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel for
# pairing flow, push-mode upgrade, and v0.2 roadmap.
# Pro/Max plans skip the channelsEnabled gate but still need the
# allowedChannelPlugins entry in the managed-settings file.
# Need help?
# Documentation: https://doc.moleculesai.app/docs/guides/claude-code-channel-plugin
# Full README: https://git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel
# Common errors:
# • "plugin not installed" — run /plugin marketplace add then
# /plugin install lines above; /reload-plugins or restart.
# • "entries must be tagged: --channels" — the launch flag form
# changed; use --dangerously-load-development-channels plugin:molecule@molecule-channel
# (channel spec is the VALUE, not a separate --channels flag).
# • "not on the approved channels allowlist" — custom channels need
# --dangerously-load-development-channels; team/enterprise orgs
# need admin to set channelsEnabled + allowedChannelPlugins.
# allowedChannelPlugins in /Library/Application Support/ClaudeCode/managed-settings.json
# (macOS) / equivalent on Linux+Windows. NOT a web setting.
# • "Inbound messages not arriving" — stderr should show
# "molecule channel: connected — watching N workspace(s)";
# verify ~/.claude/channels/molecule/.env has PLATFORM_URL + token.
# verify ~/.claude/channels/molecule/.env shape is MOLECULE_WORKSPACES_JSON.
`
// externalUniversalMcpTemplate — runtime-agnostic standalone path.
@@ -670,7 +703,15 @@ def heartbeat(client, url, ws, tok, start):
r.raise_for_status()
def poll_inbound(client, url, ws, tok, since_id):
params = {"since_secs": "30", "limit": "50"}
# include=peer_info opts into Layer 1's row-level projection so each
# polled activity carries peer_name, peer_role, agent_card_url, and
# attachments[] inline (when source_id resolves to a peer / when the
# message included a file). Pre-Layer-1 platforms ignore unknown query
# params and return the bare row shape, so this is back-compat. Use
# the extra fields in your reply logic — e.g. address the sender by
# peer_name rather than UUID, or Read attached files via the workspace:
# URIs in attachments[].
params = {"since_secs": "30", "limit": "50", "include": "peer_info"}
if since_id:
params["since_id"] = since_id
r = client.get(f"{url}/workspaces/{ws}/activity", params=params, headers=hdrs(url, tok))
@@ -737,10 +778,16 @@ python3 ~/.molecule-ai/kimi-{{MCP_SERVER_NAME}}/kimi_bridge.py
# What the script does:
# • Registers the workspace in poll mode (no public URL needed)
# • Heartbeats every 20s to keep STATUS = online on the canvas
# • Polls /workspaces/:id/activity every 5s for new canvas messages
# • Polls /workspaces/:id/activity?include=peer_info every 5s — Layer 1
# enrichment surfaces peer_name / peer_role / agent_card_url /
# attachments[] inline on each polled row when applicable
# • Echo-replies via POST /workspaces/:id/notify
#
# To change the reply logic, edit the send_reply() call inside the loop.
# Each polled item has top-level peer_name / peer_role / agent_card_url
# fields (peer_agent rows) and attachments[] (any kind) when Layer 1 is
# enabled on the platform — use them to disambiguate senders and to Read
# attached files via the workspace: URIs.
# To send a one-off reply from another terminal:
# curl -fsS -X POST "{{PLATFORM_URL}}/workspaces/{{WORKSPACE_ID}}/notify" \
# -H "Authorization: Bearer $(cat ~/.molecule-ai/kimi-{{MCP_SERVER_NAME}}/env | grep TOKEN | cut -d= -f2)" \
@@ -118,3 +118,86 @@ func TestExternalTemplates_NoBrokenMoleculeAIGitHubURLs(t *testing.T) {
}
}
}
// TestExternalChannelTemplate_LaunchFlagShape pins the Claude Code channel
// snippet to the working launch invocation. The channel spec must be the
// VALUE of --dangerously-load-development-channels, NOT a separate
// --channels flag. The two-flag form (`--dangerously-load-development-channels
// --channels plugin:molecule@...`) errors with "entries must be tagged:
// --channels" on current Claude Code builds (2.1.143+) and silently no-ops
// on older ones — either way, new users hit a wall on first launch.
//
// Empirical: hit by a session walking through this exact snippet 2026-05-21;
// the broken form was copy-pasted from this template, ran, errored, and
// confused the operator into believing the plugin install was broken when
// the snippet itself was the bug.
func TestExternalChannelTemplate_LaunchFlagShape(t *testing.T) {
// The broken two-flag form. If this string ever appears in the
// snippet again, the same onboarding pothole returns.
bannedFormBroken := "--dangerously-load-development-channels \\\n --channels plugin:molecule@molecule-channel"
if strings.Contains(externalChannelTemplate, bannedFormBroken) {
t.Errorf("externalChannelTemplate contains the broken two-flag launch form. " +
"Use --dangerously-load-development-channels plugin:molecule@molecule-channel (spec as value, not a separate --channels flag).")
}
// The single-flag form must be present.
requiredFormGood := "--dangerously-load-development-channels plugin:molecule@molecule-channel"
if !strings.Contains(externalChannelTemplate, requiredFormGood) {
t.Errorf("externalChannelTemplate must contain %q so operators see the working launch invocation", requiredFormGood)
}
}
// TestExternalChannelTemplate_CanonicalEnvShape pins the canvas-served
// .env example to the canonical SSOT shape (MOLECULE_WORKSPACES_JSON)
// rather than the legacy single-platform shape. The legacy form
// (MOLECULE_PLATFORM_URL + comma-separated IDs/TOKENS) is still accepted
// by the channel plugin's parseWorkspaceTargets but is single-tenant
// only — it silently fails to onboard users who want to watch multiple
// platforms (e.g. hongming + agents-team from the same plugin instance),
// which is the post-PR#15 expected use case.
func TestExternalChannelTemplate_CanonicalEnvShape(t *testing.T) {
if !strings.Contains(externalChannelTemplate, "MOLECULE_WORKSPACES_JSON=") {
t.Errorf("externalChannelTemplate must use MOLECULE_WORKSPACES_JSON as the canonical .env shape (the post-PR#15 SSOT)")
}
// The JSON example must contain the workspace_id + platform_url placeholders
// so the canvas substitutes them at serve time.
for _, ph := range []string{"{{WORKSPACE_ID}}", "{{PLATFORM_URL}}"} {
if !strings.Contains(externalChannelTemplate, ph) {
t.Errorf("externalChannelTemplate must contain placeholder %q so the canvas substitutes per-workspace values", ph)
}
}
}
// TestPollingTemplates_OptIntoPeerInfo pins the invariant that any template
// which calls /workspaces/:id/activity for inbound delivery requests the
// Layer 1 enrichment via ?include=peer_info. Without this opt-in, the
// platform returns bare activity rows and the operator's bridge / channel
// loses peer_name / peer_role / agent_card_url / attachments[] — they're
// available on the server but not delivered.
//
// Pre-Layer-1 platforms ignore unknown query params (HTTP spec: filters
// not understood are dropped), so this is back-compat across deploys.
//
// The Claude Code channel template doesn't include the poll URL in this
// snippet — its polling lives in the plugin's own server.ts (handled by
// molecule-mcp-claude-channel PR#21). The Kimi template DOES include a
// poll loop in its kimi_bridge.py block, so the invariant applies there.
func TestPollingTemplates_OptIntoPeerInfo(t *testing.T) {
pollingTemplates := map[string]string{
"externalKimiTemplate": externalKimiTemplate,
}
for name, body := range pollingTemplates {
// If the snippet polls /activity, it must opt into peer_info.
// The detection is intentionally loose ("/activity" appears in
// the script) — operators who customize the script keep the
// invariant only if the include hint is in the template.
if !strings.Contains(body, "/activity") {
t.Errorf("%s no longer polls /activity — review whether this test still applies", name)
continue
}
if !strings.Contains(body, `"include": "peer_info"`) && !strings.Contains(body, "include=peer_info") {
t.Errorf("%s polls /activity without ?include=peer_info — operators lose Layer 1 enrichment "+
"(peer_name / peer_role / agent_card_url / attachments[]). Add the param to the poll URL.", name)
}
}
}
@@ -230,7 +230,7 @@ func TestWorkspaceList_WithData(t *testing.T) {
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
// 23 cols — broadcast_enabled + talk_to_user_enabled added after monthly_spend
// 24 cols — compute added after talk_to_user_enabled.
// (migration 20260514). Column order must match scanWorkspaceRow exactly.
columns := []string{
"id", "name", "role", "tier", "status", "agent_card", "url",
@@ -238,13 +238,13 @@ func TestWorkspaceList_WithData(t *testing.T) {
"last_error_rate", "last_sample_error",
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
"budget_limit", "monthly_spend",
"broadcast_enabled", "talk_to_user_enabled",
"broadcast_enabled", "talk_to_user_enabled", "compute",
}
rows := sqlmock.NewRows(columns).
AddRow("ws-1", "Agent One", "worker", 1, "online", []byte(`{"name":"agent1"}`), "http://localhost:8001",
nil, 3, 1, 0.02, "", 7200, "processing", "langgraph", "", 10.0, 20.0, false, nil, int64(0), false, true).
nil, 3, 1, 0.02, "", 7200, "processing", "langgraph", "", 10.0, 20.0, false, nil, int64(0), false, true, []byte(`{}`)).
AddRow("ws-2", "Agent Two", "", 2, "degraded", []byte("null"), "",
nil, 0, 1, 0.6, "timeout", 100, "", "claude-code", "", 50.0, 60.0, true, nil, int64(0), false, true)
nil, 0, 1, 0.6, "timeout", 100, "", "claude-code", "", 50.0, 60.0, true, nil, int64(0), false, true, []byte(`{}`))
mock.ExpectQuery("SELECT w.id, w.name").
WillReturnRows(rows)
@@ -456,7 +456,7 @@ func TestWorkspaceList(t *testing.T) {
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", "/tmp/configs")
// 23 cols: broadcast_enabled + talk_to_user_enabled added after monthly_spend
// 24 cols: compute added after talk_to_user_enabled.
// (migration 20260514). Column order must match scanWorkspaceRow exactly.
columns := []string{
"id", "name", "role", "tier", "status", "agent_card", "url",
@@ -464,13 +464,13 @@ func TestWorkspaceList(t *testing.T) {
"last_error_rate", "last_sample_error",
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
"budget_limit", "monthly_spend",
"broadcast_enabled", "talk_to_user_enabled",
"broadcast_enabled", "talk_to_user_enabled", "compute",
}
rows := sqlmock.NewRows(columns).
AddRow("ws-1", "Agent One", "worker", 1, "online", []byte("null"), "http://localhost:8001",
nil, 0, 1, 0.0, "", 100, "", "claude-code", "", 10.0, 20.0, false, nil, int64(0), false, true).
nil, 0, 1, 0.0, "", 100, "", "claude-code", "", 10.0, 20.0, false, nil, int64(0), false, true, []byte(`{}`)).
AddRow("ws-2", "Agent Two", "manager", 2, "provisioning", []byte("null"), "",
nil, 0, 1, 0.0, "", 0, "", "langgraph", "", 50.0, 60.0, false, nil, int64(0), false, true)
nil, 0, 1, 0.0, "", 0, "", "langgraph", "", 50.0, 60.0, false, nil, int64(0), false, true, []byte(`{}`))
mock.ExpectQuery("SELECT w.id, w.name").
WillReturnRows(rows)
@@ -1184,14 +1184,14 @@ func TestWorkspaceGet_CurrentTask(t *testing.T) {
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
"budget_limit", "monthly_spend",
"broadcast_enabled", "talk_to_user_enabled",
"broadcast_enabled", "talk_to_user_enabled", "compute",
}
mock.ExpectQuery("SELECT w.id, w.name").
WithArgs("dddddddd-0004-0000-0000-000000000000").
WillReturnRows(sqlmock.NewRows(columns).AddRow(
"dddddddd-0004-0000-0000-000000000000", "Task Worker", "worker", 1, "online", []byte("null"), "http://localhost:9000",
nil, 2, 1, 0.0, "", 300, "Analyzing document", "langgraph", "", 10.0, 20.0, false,
nil, int64(0), false, true,
nil, int64(0), false, true, []byte(`{}`),
))
w := httptest.NewRecorder()
@@ -84,6 +84,7 @@ type mcpTool struct {
type MCPHandler struct {
database *sql.DB
broadcaster *events.Broadcaster
a2aProxy func(ctx context.Context, workspaceID string, body []byte, callerID string, logActivity bool) (int, []byte, error)
// memv2 is the v2 memory plugin wiring (RFC #2728). nil-safe:
// every v2 tool calls memoryV2Available() first and returns a
@@ -98,6 +99,14 @@ func NewMCPHandler(database *sql.DB, broadcaster *events.Broadcaster) *MCPHandle
return &MCPHandler{database: database, broadcaster: broadcaster}
}
func (h *MCPHandler) proxyA2ARequest(ctx context.Context, workspaceID string, body []byte, callerID string, logActivity bool) (int, []byte, error) {
if h.a2aProxy != nil {
return h.a2aProxy(ctx, workspaceID, body, callerID, logActivity)
}
wh := NewWorkspaceHandler(h.broadcaster, nil, "", "")
return wh.ProxyA2ARequest(ctx, workspaceID, body, callerID, logActivity)
}
// ─────────────────────────────────────────────────────────────────────────────
// Tool definitions (mirrors workspace/a2a_mcp_server.py TOOLS list)
// ─────────────────────────────────────────────────────────────────────────────
@@ -53,6 +53,15 @@ func mcpPost(t *testing.T, h *MCPHandler, workspaceID string, body interface{})
return w
}
func expectCanCommunicateSiblings(mock sqlmock.Sqlmock, callerID, targetID, parentID string) {
mock.ExpectQuery(`SELECT id, parent_id FROM workspaces WHERE id = \$1`).
WithArgs(callerID).
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow(callerID, parentID))
mock.ExpectQuery(`SELECT id, parent_id FROM workspaces WHERE id = \$1`).
WithArgs(targetID).
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow(targetID, parentID))
}
// ─────────────────────────────────────────────────────────────────────────────
// initialize
// ─────────────────────────────────────────────────────────────────────────────
@@ -178,6 +187,98 @@ func TestMCPHandler_ToolsList_ContainsExpectedTools(t *testing.T) {
}
}
func TestMCPHandler_DelegateTask_RoutesThroughPlatformA2AProxy(t *testing.T) {
h, mock := newMCPHandler(t)
callerID := "11111111-1111-1111-1111-111111111111"
targetID := "22222222-2222-2222-2222-222222222222"
parentID := "33333333-3333-3333-3333-333333333333"
expectCanCommunicateSiblings(mock, callerID, targetID, parentID)
mock.ExpectExec(`(?s)INSERT INTO activity_logs.*'delegation'.*'delegate'`).
WithArgs(callerID, callerID, targetID, "Delegating to "+targetID, sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec(`UPDATE activity_logs`).
WithArgs("dispatched", "", callerID, sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
var gotTarget, gotCaller string
h.a2aProxy = func(ctx context.Context, workspaceID string, body []byte, callerID string, logActivity bool) (int, []byte, error) {
gotTarget = workspaceID
gotCaller = callerID
if !logActivity {
t.Fatal("delegate_task should log through platform A2A proxy")
}
if !strings.Contains(string(body), "do work") {
t.Fatalf("A2A body missing task text: %s", string(body))
}
return 200, []byte(`{"result":{"message":{"parts":[{"text":"done"}]}}}`), nil
}
out, err := h.toolDelegateTask(context.Background(), callerID, map[string]interface{}{
"workspace_id": targetID,
"task": "do work",
}, mcpCallTimeout)
if err != nil {
t.Fatalf("delegate_task returned error: %v", err)
}
if out != "done" {
t.Fatalf("delegate_task response = %q, want done", out)
}
if gotTarget != targetID || gotCaller != callerID {
t.Fatalf("proxy called with target=%q caller=%q, want target=%q caller=%q", gotTarget, gotCaller, targetID, callerID)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
}
}
func TestMCPHandler_DelegateTaskAsync_RoutesThroughPlatformA2AProxy(t *testing.T) {
h, mock := newMCPHandler(t)
callerID := "11111111-1111-1111-1111-111111111111"
targetID := "22222222-2222-2222-2222-222222222222"
parentID := "33333333-3333-3333-3333-333333333333"
expectCanCommunicateSiblings(mock, callerID, targetID, parentID)
mock.ExpectExec(`(?s)INSERT INTO activity_logs.*'delegation'.*'delegate'`).
WithArgs(callerID, callerID, targetID, "Delegating to "+targetID, sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec(`UPDATE activity_logs`).
WithArgs("dispatched", "", callerID, sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
called := make(chan struct{}, 1)
h.a2aProxy = func(ctx context.Context, workspaceID string, body []byte, proxyCallerID string, logActivity bool) (int, []byte, error) {
if workspaceID != targetID || proxyCallerID != callerID {
t.Fatalf("unexpected proxy route target=%q caller=%q", workspaceID, proxyCallerID)
}
if !strings.Contains(string(body), "async work") {
t.Fatalf("A2A body missing task text: %s", string(body))
}
called <- struct{}{}
return 200, []byte(`{"result":{"message":{"parts":[{"text":"accepted"}]}}}`), nil
}
out, err := h.toolDelegateTaskAsync(context.Background(), callerID, map[string]interface{}{
"workspace_id": targetID,
"task": "async work",
})
if err != nil {
t.Fatalf("delegate_task_async returned error: %v", err)
}
if !strings.Contains(out, `"status":"dispatched"`) {
t.Fatalf("delegate_task_async response = %s", out)
}
waitGlobalAsyncForTest()
select {
case <-called:
default:
t.Fatal("async delegate did not call platform A2A proxy")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// notifications/initialized
// ─────────────────────────────────────────────────────────────────────────────
+17 -120
View File
@@ -7,24 +7,19 @@ package handlers
// and A2A response parsing helpers.
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"time"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/registry"
"github.com/google/uuid"
)
// insertMCPDelegationRow writes a delegation activity row so the canvas
// Agent Comms tab can show the task text for MCP-initiated delegations.
// Mirrors insertDelegationRow (delegation.go) for the MCP tool path.
@@ -190,15 +185,6 @@ func (h *MCPHandler) toolDelegateTask(ctx context.Context, callerID string, args
// Non-fatal: still make the A2A call even if activity log write fails.
}
agentURL, err := mcpResolveURL(ctx, h.database, targetID)
if err != nil {
return "", err
}
// SSRF defence: reject private/metadata URLs before making outbound call.
if err := isSafeURL(agentURL); err != nil {
return "", fmt.Errorf("invalid workspace URL: %w", err)
}
a2aBody, err := json.Marshal(map[string]interface{}{
"jsonrpc": "2.0",
"id": uuid.New().String(),
@@ -218,36 +204,17 @@ func (h *MCPHandler) toolDelegateTask(ctx context.Context, callerID string, args
reqCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
httpReq, err := http.NewRequestWithContext(reqCtx, "POST", agentURL+"/a2a", bytes.NewReader(a2aBody))
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
// X-Workspace-ID identifies this caller to the A2A proxy. The /workspaces/:id/a2a
// endpoint is intentionally outside WorkspaceAuth (agents do not hold bearer tokens
// to peer workspaces). Access control is enforced by CanCommunicate above, which
// already validated callerID → targetID before this request is constructed.
// callerID was authenticated by WorkspaceAuth on the MCP bridge entry point,
// so this header reflects a verified caller identity, not a spoofable value.
httpReq.Header.Set("X-Workspace-ID", callerID)
resp, err := http.DefaultClient.Do(httpReq)
status, body, err := h.proxyA2ARequest(reqCtx, targetID, a2aBody, callerID, true)
if err != nil {
updateMCPDelegationStatus(ctx, h.database, callerID, delegationID, "failed", err.Error())
return "", fmt.Errorf("A2A call failed: %w", err)
return "", fmt.Errorf("A2A proxy failed: %w", err)
}
if status < 200 || status >= 300 {
updateMCPDelegationStatus(ctx, h.database, callerID, delegationID, "failed", fmt.Sprintf("A2A proxy returned status %d", status))
return "", fmt.Errorf("A2A proxy returned status %d", status)
}
defer func() { _ = resp.Body.Close() }()
// A 200/500 from the peer still means the call was dispatched — only
// network errors are truly "failed". Status 'dispatched' is correct for
// any HTTP response (peer's A2A layer handles the actual processing).
updateMCPDelegationStatus(ctx, h.database, callerID, delegationID, "dispatched", "")
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}
return extractA2AText(body), nil
}
@@ -278,24 +245,13 @@ func (h *MCPHandler) toolDelegateTaskAsync(ctx context.Context, callerID string,
// Fire and forget in a detached goroutine. Use a background context so
// the call is not cancelled when the HTTP request completes.
// RFC internal#524 Layer 1: globalGoAsync — the detached call reads
// db.DB (mcpResolveURL + updateMCPDelegationStatus) and must be
// drained by drainTestAsync before any t.Cleanup-driven db.DB swap.
// RFC internal#524 Layer 1: globalGoAsync — the detached call reads db.DB
// through the platform A2A proxy and must be drained by drainTestAsync
// before any t.Cleanup-driven db.DB swap.
globalGoAsync(func() {
bgCtx, cancel := context.WithTimeout(context.Background(), mcpAsyncCallTimeout)
defer cancel()
agentURL, err := mcpResolveURL(bgCtx, h.database, targetID)
if err != nil {
log.Printf("MCPHandler.delegate_task_async: resolve URL for %s: %v", targetID, err)
return
}
// SSRF defence: reject private/metadata URLs before making outbound call.
if err := isSafeURL(agentURL); err != nil {
log.Printf("MCPHandler.delegate_task_async: unsafe URL for %s: %v", targetID, err)
return
}
a2aBody, _ := json.Marshal(map[string]interface{}{
"jsonrpc": "2.0",
"id": delegationID,
@@ -309,22 +265,15 @@ func (h *MCPHandler) toolDelegateTaskAsync(ctx context.Context, callerID string,
},
})
httpReq, err := http.NewRequestWithContext(bgCtx, "POST", agentURL+"/a2a", bytes.NewReader(a2aBody))
if err != nil {
log.Printf("MCPHandler.delegate_task_async: create request: %v", err)
status, _, err := h.proxyA2ARequest(bgCtx, targetID, a2aBody, callerID, true)
if err != nil || status < 200 || status >= 300 {
if err != nil {
log.Printf("MCPHandler.delegate_task_async: A2A proxy to %s: %v", targetID, err)
} else {
log.Printf("MCPHandler.delegate_task_async: A2A proxy to %s returned status %d", targetID, status)
}
return
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("X-Workspace-ID", callerID)
resp, err := http.DefaultClient.Do(httpReq)
if err != nil {
log.Printf("MCPHandler.delegate_task_async: A2A call to %s: %v", targetID, err)
return
}
defer func() { _ = resp.Body.Close() }()
// Drain response so the connection can be reused.
_, _ = io.Copy(io.Discard, resp.Body)
})
return fmt.Sprintf(`{"task_id":%q,"status":"dispatched","target_id":%q}`, delegationID, targetID), nil
@@ -405,7 +354,6 @@ func (h *MCPHandler) toolSendMessageToUser(ctx context.Context, workspaceID stri
return "Message sent.", nil
}
func (h *MCPHandler) toolCommitMemory(ctx context.Context, workspaceID string, args map[string]interface{}) (string, error) {
// PR-6 (RFC #2728) compat shim: when the v2 plugin is wired
// (MEMORY_PLUGIN_URL set), translate legacy scope→namespace and
@@ -534,56 +482,6 @@ func (h *MCPHandler) toolRecallMemory(ctx context.Context, workspaceID string, a
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
// mcpResolveURL returns a routable URL for a workspace's A2A server.
//
// Resolution order:
// 1. Docker-internal URL cache (set by provisioner; correct when platform is in Docker)
// 2. Redis URL cache
// 3. DB `url` column fallback, with 127.0.0.1→Docker bridge rewrite when in Docker
//
// SECURITY (F1083 / #1130): all three paths run the returned URL through
// validateAgentURL to block SSRF targets (private IPs, loopback, cloud metadata).
func mcpResolveURL(ctx context.Context, database *sql.DB, workspaceID string) (string, error) {
if platformInDocker {
if url, err := db.GetCachedInternalURL(ctx, workspaceID); err == nil && url != "" {
if err := validateAgentURL(url); err != nil {
return "", fmt.Errorf("workspace %s: forbidden URL from internal cache: %w", workspaceID, err)
}
return url, nil
}
}
if url, err := db.GetCachedURL(ctx, workspaceID); err == nil && url != "" {
if platformInDocker && strings.HasPrefix(url, "http://127.0.0.1:") {
return provisioner.InternalURL(workspaceID), nil
}
if err := validateAgentURL(url); err != nil {
return "", fmt.Errorf("workspace %s: forbidden URL from Redis cache: %w", workspaceID, err)
}
return url, nil
}
var urlStr sql.NullString
var status string
if err := database.QueryRowContext(ctx,
`SELECT url, status FROM workspaces WHERE id = $1`, workspaceID,
).Scan(&urlStr, &status); err != nil {
if err == sql.ErrNoRows {
return "", fmt.Errorf("workspace %s not found", workspaceID)
}
return "", fmt.Errorf("workspace lookup failed: %w", err)
}
if !urlStr.Valid || urlStr.String == "" {
return "", fmt.Errorf("workspace %s has no URL (status: %s)", workspaceID, status)
}
if platformInDocker && strings.HasPrefix(urlStr.String, "http://127.0.0.1:") {
return provisioner.InternalURL(workspaceID), nil
}
if err := validateAgentURL(urlStr.String); err != nil {
return "", fmt.Errorf("workspace %s: forbidden URL from DB: %w", workspaceID, err)
}
return urlStr.String, nil
}
// extractA2AText extracts human-readable text from an A2A JSON-RPC response body.
// Falls back to the raw JSON when no text part can be found.
func extractA2AText(body []byte) string {
@@ -632,4 +530,3 @@ func extractA2AText(body []byte) string {
b, _ := json.Marshal(result)
return string(b)
}
@@ -6,18 +6,26 @@ import (
"testing"
)
// TestHandleA2ADispatchError_NativeSession_SkipsEnqueue validates capability
// primitive #5: when the target workspace has declared
// provides_native_session=True, a busy-shaped dispatch error MUST short-
// circuit straight to 503 + Retry-After. The platform's a2a_queue is
// skipped because the SDK owns its own queue/session state — double-
// buffering would cause spurious dispatches when the SDK is still busy.
// TestHandleA2ADispatchError_NativeSession_NowEnqueues validates the #1684
// fix: native_session adapters used to short-circuit to 503-no-queue here,
// on the assumption that the SDK owned an inbound queue. In practice the
// common native_session SDKs (claude-agent-sdk, codex app-server, hermes)
// don't — new turns arrive only via the same HTTP POST that returns busy.
// So cron fires bounced 503 every tick until the SDK voluntarily yielded;
// Reno Stars #1684 observed 12 consecutive `*/30` cron fires lost over 6h.
//
// Pin via sqlmock: we deliberately do NOT expect any INSERT INTO a2a_queue.
// If a future refactor re-introduces enqueueing under native_session,
// sqlmock fails the test on the unexpected query.
func TestHandleA2ADispatchError_NativeSession_SkipsEnqueue(t *testing.T) {
setupTestDB(t)
// Post-fix: native_session and non-native both enqueue. Drain timing is
// gated by registry.go:Heartbeat (`payload.ActiveTasks < maxConcurrent`)
// so the queued item only dispatches when the SDK reports spare capacity
// — i.e. the next heartbeat after the in-flight turn returns.
//
// This test pins the new behavior: native_session capability DOES NOT
// bypass EnqueueA2A. We expect the INSERT INTO a2a_queue query to fire,
// here arranged to fail so we can observe the legacy 503 fallback (and
// thereby confirm the INSERT was attempted; sqlmock fails the test if
// the expected query never runs).
func TestHandleA2ADispatchError_NativeSession_NowEnqueues(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
@@ -25,10 +33,15 @@ func TestHandleA2ADispatchError_NativeSession_SkipsEnqueue(t *testing.T) {
runtimeOverrides.SetCapabilities("ws-native", map[string]bool{"session": true})
defer runtimeOverrides.Reset()
// DeadlineExceeded triggers isUpstreamBusyError. Without the native
// gate, this would fire EnqueueA2A → INSERT INTO a2a_queue. With
// the gate, it short-circuits to 503. We expect ZERO queue queries;
// sqlmock's ExpectationsWereMet implicitly enforces that on teardown.
// We now EXPECT the INSERT to fire even with native_session=true. Make
// it fail so the handler falls through to the legacy 503 path — that
// lets us assert (1) enqueue was attempted, (2) the response on
// queue-failure does NOT carry native_session=true marker (that field
// was removed alongside the gate).
mock.ExpectQuery(`INSERT INTO a2a_queue`).
WithArgs("ws-native", nil, PriorityTask, "{}", "message/send", nil).
WillReturnError(errTestQueueUnavailable)
_, _, perr := handler.handleA2ADispatchError(
context.Background(), "ws-native", "", []byte("{}"), "message/send",
context.DeadlineExceeded, 1, false,
@@ -37,28 +50,27 @@ func TestHandleA2ADispatchError_NativeSession_SkipsEnqueue(t *testing.T) {
t.Fatal("expected proxy error, got nil")
}
if perr.Status != http.StatusServiceUnavailable {
t.Errorf("got status %d, want 503 (native_session bypasses queue but still 503s)", perr.Status)
t.Errorf("got status %d, want 503 (enqueue failed → legacy 503 fallback)", perr.Status)
}
if perr.Headers["Retry-After"] == "" {
t.Error("expected Retry-After header on native-session 503")
t.Error("expected Retry-After header on busy-503")
}
// Pin the marker so callers' adapters can distinguish this from a
// queue-failure 503: the body has native_session=true.
if got, _ := perr.Response["native_session"].(bool); !got {
t.Errorf("expected native_session=true in response body; got %+v", perr.Response)
// The native_session marker was removed from the response body — the
// platform queues both kinds now, callers no longer distinguish. Pin
// its absence so a future revert is caught.
if got, ok := perr.Response["native_session"].(bool); ok && got {
t.Errorf("native_session marker should be gone after #1684 fix; got %+v", perr.Response)
}
// And busy=true stays so existing busy-handling code paths still trigger.
if got, _ := perr.Response["busy"].(bool); !got {
t.Errorf("expected busy=true in response body; got %+v", perr.Response)
t.Errorf("expected busy=true; got %+v", perr.Response)
}
}
// TestHandleA2ADispatchError_NoNativeSession_StillEnqueues is the negative
// pin: a workspace WITHOUT the capability flag falls through to the
// existing EnqueueA2A path (and 503 if that fails). Same shape as
// TestHandleA2ADispatchError_ContextDeadline; we duplicate it here so
// the native_session gate change is bracketed by both positive and
// negative tests in the same file.
// TestHandleA2ADispatchError_NoNativeSession_StillEnqueues — non-native
// behavior is unchanged: enqueue is attempted, fail-fallback to 503. This
// negative pin guards against accidentally reverting the unification by
// re-introducing a `if HasCapability(...)` gate that would short-circuit
// the enqueue path.
func TestHandleA2ADispatchError_NoNativeSession_StillEnqueues(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
@@ -79,13 +91,11 @@ func TestHandleA2ADispatchError_NoNativeSession_StillEnqueues(t *testing.T) {
if perr == nil {
t.Fatal("expected proxy error, got nil")
}
// Queue insert failed → falls through to legacy 503 (without
// native_session marker).
if perr.Status != http.StatusServiceUnavailable {
t.Errorf("got status %d, want 503", perr.Status)
}
if got, _ := perr.Response["native_session"].(bool); got {
t.Errorf("non-native workspace should NOT carry native_session=true in response; got %+v", perr.Response)
t.Errorf("non-native workspace should NOT carry native_session=true; got %+v", perr.Response)
}
}
@@ -112,7 +112,7 @@ func (h *RegistryHandler) SetQueueDrainFunc(f QueueDrainFunc) {
// Go's net.ParseIP.To4() before Contains() runs, so the IPv4 rules above
// catch those without a separate entry.
//
// F1083/#1130 (SSRF on mcpResolveURL / a2a_proxy resolveAgentURL): in
// F1083/#1130 (SSRF on direct A2A URL resolution): in
// addition to blocking IP literals, DNS names are now resolved and each
// returned IP is checked against the blocklist. This closes the gap where
// an attacker could register agent.example.com pointing to 169.254.169.254.
+113 -21
View File
@@ -15,13 +15,46 @@ import (
"github.com/Molecule-AI/molecule-monorepo/platform/internal/scheduler"
)
// ErrorResponse is returned for 4xx/5xx errors. (OpenAPI doc shape — used by swaggo.)
type ErrorResponse struct {
Error string `json:"error"`
}
// StatusResponse is returned by mutating endpoints that only echo a status verb.
type StatusResponse struct {
Status string `json:"status"`
}
// CreateScheduleResponse is returned by POST /workspaces/{id}/schedules.
type CreateScheduleResponse struct {
ID string `json:"id"`
Status string `json:"status"`
NextRunAt time.Time `json:"next_run_at"`
}
// RunNowResponse is returned by POST /workspaces/{id}/schedules/{scheduleId}/run.
type RunNowResponse struct {
Status string `json:"status"`
WorkspaceID string `json:"workspace_id"`
Prompt string `json:"prompt"`
}
// HistoryEntry is one row of /workspaces/{id}/schedules/{scheduleId}/history.
type HistoryEntry struct {
Timestamp time.Time `json:"timestamp"`
DurationMs *int `json:"duration_ms"`
Status *string `json:"status"`
ErrorDetail string `json:"error_detail"`
Request json.RawMessage `json:"request" swaggertype:"object"`
}
type ScheduleHandler struct{}
func NewScheduleHandler() *ScheduleHandler {
return &ScheduleHandler{}
}
type scheduleResponse struct {
type ScheduleResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
@@ -40,6 +73,15 @@ type scheduleResponse struct {
}
// List returns all schedules for a workspace.
//
// @Summary List schedules for a workspace
// @Tags schedules
// @Produce json
// @Param id path string true "Workspace ID"
// @Success 200 {array} ScheduleResponse
// @Failure 500 {object} ErrorResponse
// @Router /workspaces/{id}/schedules [get]
// @Security BearerAuth && OrgSlugAuth
func (h *ScheduleHandler) List(c *gin.Context) {
workspaceID := c.Param("id")
ctx := c.Request.Context()
@@ -58,9 +100,9 @@ func (h *ScheduleHandler) List(c *gin.Context) {
}
defer rows.Close()
schedules := make([]scheduleResponse, 0)
schedules := make([]ScheduleResponse, 0)
for rows.Next() {
var s scheduleResponse
var s ScheduleResponse
if err := rows.Scan(
&s.ID, &s.WorkspaceID, &s.Name, &s.CronExpr, &s.Timezone,
&s.Prompt, &s.Enabled, &s.LastRunAt, &s.NextRunAt, &s.RunCount,
@@ -78,7 +120,7 @@ func (h *ScheduleHandler) List(c *gin.Context) {
c.JSON(http.StatusOK, schedules)
}
type createScheduleRequest struct {
type CreateScheduleRequest struct {
Name string `json:"name"`
CronExpr string `json:"cron_expr" binding:"required"`
Timezone string `json:"timezone"`
@@ -87,11 +129,23 @@ type createScheduleRequest struct {
}
// Create adds a new schedule for a workspace.
//
// @Summary Create a schedule
// @Tags schedules
// @Accept json
// @Produce json
// @Param id path string true "Workspace ID"
// @Param body body CreateScheduleRequest true "Schedule fields"
// @Success 201 {object} CreateScheduleResponse
// @Failure 400 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /workspaces/{id}/schedules [post]
// @Security BearerAuth && OrgSlugAuth
func (h *ScheduleHandler) Create(c *gin.Context) {
workspaceID := c.Param("id")
ctx := c.Request.Context()
var body createScheduleRequest
var body CreateScheduleRequest
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "cron_expr and prompt are required"})
return
@@ -145,7 +199,7 @@ func (h *ScheduleHandler) Create(c *gin.Context) {
})
}
type updateScheduleRequest struct {
type UpdateScheduleRequest struct {
Name *string `json:"name"`
CronExpr *string `json:"cron_expr"`
Timezone *string `json:"timezone"`
@@ -155,12 +209,26 @@ type updateScheduleRequest struct {
// Update modifies a schedule. Uses a fixed UPDATE with COALESCE so only
// provided fields are changed — no dynamic SQL construction.
//
// @Summary Update a schedule
// @Tags schedules
// @Accept json
// @Produce json
// @Param id path string true "Workspace ID"
// @Param scheduleId path string true "Schedule ID"
// @Param body body UpdateScheduleRequest true "Partial schedule fields (only provided keys are updated)"
// @Success 200 {object} ScheduleResponse
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /workspaces/{id}/schedules/{scheduleId} [patch]
// @Security BearerAuth && OrgSlugAuth
func (h *ScheduleHandler) Update(c *gin.Context) {
scheduleID := c.Param("scheduleId")
workspaceID := c.Param("id") // #113: bind to owning workspace to prevent IDOR
ctx := c.Request.Context()
var body updateScheduleRequest
var body UpdateScheduleRequest
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"})
return
@@ -230,6 +298,17 @@ func (h *ScheduleHandler) Update(c *gin.Context) {
}
// Delete removes a schedule.
//
// @Summary Delete a schedule
// @Tags schedules
// @Produce json
// @Param id path string true "Workspace ID"
// @Param scheduleId path string true "Schedule ID"
// @Success 200 {object} StatusResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /workspaces/{id}/schedules/{scheduleId} [delete]
// @Security BearerAuth && OrgSlugAuth
func (h *ScheduleHandler) Delete(c *gin.Context) {
scheduleID := c.Param("scheduleId")
workspaceID := c.Param("id") // #113: bind to owning workspace to prevent IDOR
@@ -252,6 +331,17 @@ func (h *ScheduleHandler) Delete(c *gin.Context) {
}
// RunNow manually fires a schedule immediately.
//
// @Summary Fire a schedule manually
// @Tags schedules
// @Produce json
// @Param id path string true "Workspace ID"
// @Param scheduleId path string true "Schedule ID"
// @Success 200 {object} RunNowResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /workspaces/{id}/schedules/{scheduleId}/run [post]
// @Security BearerAuth && OrgSlugAuth
func (h *ScheduleHandler) RunNow(c *gin.Context) {
scheduleID := c.Param("scheduleId")
workspaceID := c.Param("id")
@@ -282,6 +372,16 @@ func (h *ScheduleHandler) RunNow(c *gin.Context) {
}
// History returns recent runs for a schedule from activity_logs.
//
// @Summary Get past runs of a schedule
// @Tags schedules
// @Produce json
// @Param id path string true "Workspace ID"
// @Param scheduleId path string true "Schedule ID"
// @Success 200 {array} HistoryEntry
// @Failure 500 {object} ErrorResponse
// @Router /workspaces/{id}/schedules/{scheduleId}/history [get]
// @Security BearerAuth && OrgSlugAuth
func (h *ScheduleHandler) History(c *gin.Context) {
scheduleID := c.Param("scheduleId")
workspaceID := c.Param("id")
@@ -307,17 +407,9 @@ func (h *ScheduleHandler) History(c *gin.Context) {
}
defer rows.Close()
type historyEntry struct {
Timestamp time.Time `json:"timestamp"`
DurationMs *int `json:"duration_ms"`
Status *string `json:"status"`
ErrorDetail string `json:"error_detail"`
Request json.RawMessage `json:"request"`
}
entries := make([]historyEntry, 0)
entries := make([]HistoryEntry, 0)
for rows.Next() {
var e historyEntry
var e HistoryEntry
var reqStr string
if err := rows.Scan(&e.Timestamp, &e.DurationMs, &e.Status, &e.ErrorDetail, &reqStr); err != nil {
continue
@@ -329,11 +421,11 @@ func (h *ScheduleHandler) History(c *gin.Context) {
c.JSON(http.StatusOK, entries)
}
// scheduleHealthResponse is the read-only health view of a schedule.
// ScheduleHealthResponse is the read-only health view of a schedule.
// It deliberately omits prompt and cron_expr so sensitive task content is
// never exposed to peer workspaces — only execution-state fields needed to
// detect silent cron failures are returned (issue #249).
type scheduleHealthResponse struct {
type ScheduleHealthResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
@@ -402,9 +494,9 @@ func (h *ScheduleHandler) Health(c *gin.Context) {
}
defer rows.Close()
schedules := make([]scheduleHealthResponse, 0)
schedules := make([]ScheduleHealthResponse, 0)
for rows.Next() {
var s scheduleHealthResponse
var s ScheduleHealthResponse
if err := rows.Scan(
&s.ID, &s.Name, &s.Enabled, &s.LastRunAt, &s.NextRunAt,
&s.RunCount, &s.LastStatus, &s.LastError,
@@ -234,7 +234,7 @@ func TestScheduleHealth_SelfCall_Allowed(t *testing.T) {
t.Fatalf("expected 200 for self-call, got %d: %s", w.Code, w.Body.String())
}
var resp []scheduleHealthResponse
var resp []ScheduleHealthResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
@@ -284,7 +284,7 @@ func TestScheduleHealth_CanCommunicatePeer_LegacyNoToken(t *testing.T) {
t.Fatalf("expected 200 for peer with no tokens, got %d: %s", w.Code, w.Body.String())
}
var resp []scheduleHealthResponse
var resp []ScheduleHealthResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
@@ -348,6 +348,10 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace access"})
return
}
if err := validateWorkspaceCompute(payload.Compute); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Begin a transaction so the workspace row and any initial secrets are
// committed atomically. A secret-encrypt or DB error rolls back the
@@ -435,6 +439,24 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
payload.Name = persistedName
}
if !workspaceComputeIsZero(payload.Compute) {
computeJSON, encErr := workspaceComputeJSON(payload.Compute)
if encErr != nil {
tx.Rollback() //nolint:errcheck
log.Printf("Create workspace %s: failed to encode compute config: %v", id, encErr)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode compute config"})
return
}
if _, dbErr := tx.ExecContext(ctx,
`UPDATE workspaces SET compute = $2::jsonb, updated_at = now() WHERE id = $1`,
id, computeJSON); dbErr != nil {
tx.Rollback() //nolint:errcheck
log.Printf("Create workspace %s: failed to persist compute config: %v", id, dbErr)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save compute config"})
return
}
}
// Persist initial secrets from the create payload (inside same transaction).
// nil/empty map is a no-op. Any failure rolls back the workspace insert
// so we never have a workspace row without its intended secrets.
@@ -679,6 +701,7 @@ func scanWorkspaceRow(rows interface {
Scan(dest ...interface{}) error
}) (map[string]interface{}, error) {
var id, name, role, status, url, sampleError, currentTask, runtime, workspaceDir string
var computeRaw []byte
var tier, activeTasks, maxConcurrentTasks, uptimeSeconds int
var errorRate, x, y float64
var collapsed, broadcastEnabled, talkToUserEnabled bool
@@ -690,7 +713,7 @@ func scanWorkspaceRow(rows interface {
err := rows.Scan(&id, &name, &role, &tier, &status, &agentCard, &url,
&parentID, &activeTasks, &maxConcurrentTasks, &errorRate, &sampleError, &uptimeSeconds,
&currentTask, &runtime, &workspaceDir, &x, &y, &collapsed,
&budgetLimit, &monthlySpend, &broadcastEnabled, &talkToUserEnabled)
&budgetLimit, &monthlySpend, &broadcastEnabled, &talkToUserEnabled, &computeRaw)
if err != nil {
return nil, err
}
@@ -717,6 +740,11 @@ func scanWorkspaceRow(rows interface {
"broadcast_enabled": broadcastEnabled,
"talk_to_user_enabled": talkToUserEnabled,
}
if len(computeRaw) > 0 && string(computeRaw) != "null" {
ws["compute"] = json.RawMessage(computeRaw)
} else {
ws["compute"] = json.RawMessage(`{}`)
}
// budget_limit: nil when no limit set, int64 otherwise
if budgetLimit.Valid {
@@ -752,7 +780,8 @@ const workspaceListQuery = `
COALESCE(w.workspace_dir, ''),
COALESCE(cl.x, 0), COALESCE(cl.y, 0), COALESCE(cl.collapsed, false),
w.budget_limit, COALESCE(w.monthly_spend, 0),
w.broadcast_enabled, w.talk_to_user_enabled
w.broadcast_enabled, w.talk_to_user_enabled,
COALESCE(w.compute, '{}'::jsonb)
FROM workspaces w
LEFT JOIN canvas_layouts cl ON cl.workspace_id = w.id
WHERE w.status != 'removed'
@@ -813,7 +842,8 @@ func (h *WorkspaceHandler) Get(c *gin.Context) {
COALESCE(w.workspace_dir, ''),
COALESCE(cl.x, 0), COALESCE(cl.y, 0), COALESCE(cl.collapsed, false),
w.budget_limit, COALESCE(w.monthly_spend, 0),
w.broadcast_enabled, w.talk_to_user_enabled
w.broadcast_enabled, w.talk_to_user_enabled,
COALESCE(w.compute, '{}'::jsonb)
FROM workspaces w
LEFT JOIN canvas_layouts cl ON cl.workspace_id = w.id
WHERE w.id = $1
@@ -33,7 +33,7 @@ var wsColumns = []string{
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
"budget_limit", "monthly_spend",
"broadcast_enabled", "talk_to_user_enabled",
"broadcast_enabled", "talk_to_user_enabled", "compute",
}
// ==================== GET — financial fields stripped from open endpoint ====================
@@ -56,7 +56,8 @@ func TestWorkspaceBudget_Get_NilLimit(t *testing.T) {
nil, // budget_limit NULL
0, // monthly_spend 0
false, // broadcast_enabled
true)) // talk_to_user_enabled
true, // talk_to_user_enabled
[]byte(`{}`)))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -100,7 +101,8 @@ func TestWorkspaceBudget_Get_WithLimit(t *testing.T) {
0.0, 0.0, false,
int64(500), // budget_limit = $5.00 in DB
int64(123), // monthly_spend = $1.23 in DB
false, true)) // broadcast_enabled, talk_to_user_enabled
false, true, // broadcast_enabled, talk_to_user_enabled
[]byte(`{}`)))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -145,18 +147,18 @@ func TestWorkspaceBudget_Create_WithLimit(t *testing.T) {
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO workspaces").
WithArgs(
sqlmock.AnyArg(), // id
"Budgeted Agent", // name
nil, // role
3, // tier (default, workspace.go create-handler)
"langgraph", // runtime
sqlmock.AnyArg(), // awareness_namespace
(*string)(nil), // parent_id
nil, // workspace_dir
"none", // workspace_access
&budgetVal, // budget_limit ($10)
sqlmock.AnyArg(), // id
"Budgeted Agent", // name
nil, // role
3, // tier (default, workspace.go create-handler)
"langgraph", // runtime
sqlmock.AnyArg(), // awareness_namespace
(*string)(nil), // parent_id
nil, // workspace_dir
"none", // workspace_access
&budgetVal, // budget_limit ($10)
models.DefaultMaxConcurrentTasks, // max_concurrent_tasks default
"push", // delivery_mode default (#2339)
"push", // delivery_mode default (#2339)
).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
@@ -0,0 +1,208 @@
package handlers
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
"github.com/gin-gonic/gin"
)
const (
workspaceComputeDiskFloorGB = 30
workspaceComputeDiskCeilingGB = 500
)
type workspaceDisplayResponse struct {
Available bool `json:"available"`
Reason string `json:"reason,omitempty"`
Mode string `json:"mode,omitempty"`
Protocol string `json:"protocol,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
Status string `json:"status,omitempty"`
}
var workspaceComputeInstanceAllowlist = map[string]struct{}{
"t3.medium": {},
"t3.large": {},
"t3.xlarge": {},
"t3.2xlarge": {},
"m6i.large": {},
"m6i.xlarge": {},
"c6i.xlarge": {},
}
func validateWorkspaceCompute(compute models.WorkspaceCompute) error {
if compute.InstanceType != "" {
if _, ok := workspaceComputeInstanceAllowlist[compute.InstanceType]; !ok {
return fmt.Errorf("unsupported compute.instance_type")
}
}
if compute.Volume.RootGB != 0 {
if compute.Volume.RootGB < workspaceComputeDiskFloorGB || compute.Volume.RootGB > workspaceComputeDiskCeilingGB {
return fmt.Errorf("compute.volume.root_gb must be between %d and %d", workspaceComputeDiskFloorGB, workspaceComputeDiskCeilingGB)
}
}
switch compute.Display.Mode {
case "", "none", "desktop-control", "gpu-desktop-control":
default:
return fmt.Errorf("unsupported compute.display.mode")
}
switch compute.Display.Protocol {
case "", "dcv":
default:
return fmt.Errorf("unsupported compute.display.protocol")
}
if compute.Display.Width < 0 || compute.Display.Height < 0 {
return fmt.Errorf("compute.display width/height must be non-negative")
}
return nil
}
func validateWorkspaceDisplayConfig(display models.WorkspaceComputeDisplay) error {
switch display.Mode {
case "", "none", "desktop-control", "gpu-desktop-control":
default:
return fmt.Errorf("unsupported compute.display.mode")
}
switch display.Protocol {
case "", "dcv":
default:
return fmt.Errorf("unsupported compute.display.protocol")
}
if display.Width < 0 || display.Height < 0 {
return fmt.Errorf("compute.display width/height must be non-negative")
}
return nil
}
func workspaceComputeIsZero(compute models.WorkspaceCompute) bool {
return compute.InstanceType == "" &&
compute.Volume.RootGB == 0 &&
compute.Display.Mode == "" &&
compute.Display.Width == 0 &&
compute.Display.Height == 0 &&
compute.Display.Protocol == ""
}
func workspaceComputeJSON(compute models.WorkspaceCompute) (string, error) {
if workspaceComputeIsZero(compute) {
return "{}", nil
}
out := map[string]interface{}{}
if compute.InstanceType != "" {
out["instance_type"] = compute.InstanceType
}
if compute.Volume.RootGB != 0 {
out["volume"] = map[string]interface{}{"root_gb": compute.Volume.RootGB}
}
display := map[string]interface{}{}
if compute.Display.Mode != "" {
display["mode"] = compute.Display.Mode
}
if compute.Display.Width != 0 {
display["width"] = compute.Display.Width
}
if compute.Display.Height != 0 {
display["height"] = compute.Display.Height
}
if compute.Display.Protocol != "" {
display["protocol"] = compute.Display.Protocol
}
if len(display) > 0 {
out["display"] = display
}
b, err := json.Marshal(out)
if err != nil {
return "", err
}
return string(b), nil
}
func withStoredCompute(ctx context.Context, workspaceID string, payload models.CreateWorkspacePayload) models.CreateWorkspacePayload {
if !workspaceComputeIsZero(payload.Compute) || db.DB == nil {
return payload
}
var raw string
err := db.DB.QueryRowContext(ctx,
`SELECT COALESCE(compute, '{}'::jsonb) FROM workspaces WHERE id = $1`,
workspaceID,
).Scan(&raw)
if err != nil {
if err != sql.ErrNoRows {
log.Printf("withStoredCompute: load compute for %s failed: %v", workspaceID, err)
}
return payload
}
if raw == "" || raw == "{}" {
return payload
}
var compute models.WorkspaceCompute
if err := json.Unmarshal([]byte(raw), &compute); err != nil {
log.Printf("withStoredCompute: invalid compute JSON for %s: %v", workspaceID, err)
return payload
}
if err := validateWorkspaceCompute(compute); err != nil {
log.Printf("withStoredCompute: stored compute for %s failed validation: %v", workspaceID, err)
return payload
}
payload.Compute = compute
return payload
}
// Display handles GET /workspaces/:id/display.
//
// Phase 1 only exposes the product contract and the non-display unavailable
// state. Future desktop-control work will replace the display-enabled branch
// with short-lived proxied DCV session details.
func (h *WorkspaceHandler) Display(c *gin.Context) {
workspaceID := c.Param("id")
var raw string
err := db.DB.QueryRowContext(c.Request.Context(),
`SELECT COALESCE(compute, '{}'::jsonb) FROM workspaces WHERE id = $1`,
workspaceID,
).Scan(&raw)
if err != nil {
if err == sql.ErrNoRows {
c.JSON(404, gin.H{"error": "workspace not found"})
return
}
log.Printf("Display: load compute for %s failed: %v", workspaceID, err)
c.JSON(500, gin.H{"error": "failed to load display config"})
return
}
var compute models.WorkspaceCompute
if raw != "" && raw != "{}" {
if err := json.Unmarshal([]byte(raw), &compute); err != nil {
log.Printf("Display: invalid compute JSON for %s: %v", workspaceID, err)
c.JSON(500, gin.H{"error": "invalid display config"})
return
}
if err := validateWorkspaceDisplayConfig(compute.Display); err != nil {
log.Printf("Display: invalid stored compute for %s: %v", workspaceID, err)
c.JSON(500, gin.H{"error": "invalid display config"})
return
}
}
if compute.Display.Mode == "" || compute.Display.Mode == "none" {
c.JSON(200, workspaceDisplayResponse{
Available: false,
Reason: "display_not_enabled",
})
return
}
c.JSON(200, workspaceDisplayResponse{
Available: false,
Reason: "display_session_unavailable",
Mode: compute.Display.Mode,
Protocol: compute.Display.Protocol,
Width: compute.Display.Width,
Height: compute.Display.Height,
Status: "not_configured",
})
}
@@ -0,0 +1,318 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
"github.com/gin-gonic/gin"
)
func TestValidateWorkspaceCompute_AcceptsPhase1SizingAndDisplayNone(t *testing.T) {
compute := models.WorkspaceCompute{
InstanceType: "m6i.xlarge",
Volume: models.WorkspaceComputeVolume{RootGB: 100},
Display: models.WorkspaceComputeDisplay{Mode: "none"},
}
if err := validateWorkspaceCompute(compute); err != nil {
t.Fatalf("validateWorkspaceCompute returned error for valid compute: %v", err)
}
}
func TestValidateWorkspaceCompute_RejectsUnknownInstanceType(t *testing.T) {
compute := models.WorkspaceCompute{InstanceType: "p4d.24xlarge"}
if err := validateWorkspaceCompute(compute); err == nil {
t.Fatal("validateWorkspaceCompute accepted unsupported instance type")
}
}
func TestValidateWorkspaceCompute_RejectsOutOfRangeRootVolume(t *testing.T) {
for _, rootGB := range []int{29, 501} {
compute := models.WorkspaceCompute{Volume: models.WorkspaceComputeVolume{RootGB: rootGB}}
if err := validateWorkspaceCompute(compute); err == nil {
t.Fatalf("validateWorkspaceCompute accepted root_gb=%d", rootGB)
}
}
}
func TestWorkspaceComputeJSON_OmitsEmptyNestedSections(t *testing.T) {
got, err := workspaceComputeJSON(models.WorkspaceCompute{
InstanceType: "m6i.xlarge",
Volume: models.WorkspaceComputeVolume{RootGB: 100},
})
if err != nil {
t.Fatalf("workspaceComputeJSON returned error: %v", err)
}
if strings.Contains(got, `"display"`) {
t.Fatalf("workspaceComputeJSON included empty display section: %s", got)
}
if got != `{"instance_type":"m6i.xlarge","volume":{"root_gb":100}}` {
t.Fatalf("workspaceComputeJSON = %s", got)
}
}
func TestWorkspaceCreate_WithCompute_PersistsComputeJSON(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO workspaces").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`UPDATE workspaces SET compute = \$2::jsonb`).
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
mock.ExpectExec("INSERT INTO canvas_layouts").
WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{
"name":"Sized Agent",
"external":true,
"runtime":"external",
"compute":{
"instance_type":"m6i.xlarge",
"volume":{"root_gb":100},
"display":{"mode":"none"}
}
}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceCreate_WithInvalidCompute_ReturnsBadRequest(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{
"name":"Oversized Agent",
"compute":{"instance_type":"p4d.24xlarge"}
}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestBuildProvisionerConfig_CopiesComputeSizingFromPayload(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectQuery(`SELECT COALESCE\(workspace_dir`).
WithArgs("ws-compute").
WillReturnRows(sqlmock.NewRows([]string{"workspace_dir", "workspace_access"}).AddRow("", "none"))
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
cfg := handler.buildProvisionerConfig(
context.Background(),
"ws-compute",
"",
nil,
models.CreateWorkspacePayload{
Tier: 4,
Runtime: "claude-code",
Compute: models.WorkspaceCompute{
InstanceType: "m6i.xlarge",
Volume: models.WorkspaceComputeVolume{RootGB: 100},
},
},
nil,
t.TempDir(),
"workspace:ws-compute",
)
if cfg.InstanceType != "m6i.xlarge" {
t.Errorf("cfg.InstanceType = %q, want m6i.xlarge", cfg.InstanceType)
}
if cfg.DiskGB != 100 {
t.Errorf("cfg.DiskGB = %d, want 100", cfg.DiskGB)
}
}
func TestWithStoredCompute_LoadsComputeForRestartPayloads(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-restart-compute").
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"instance_type":"m6i.xlarge","volume":{"root_gb":100}}`))
payload := models.CreateWorkspacePayload{Name: "Restart Me", Tier: 4, Runtime: "claude-code"}
got := withStoredCompute(context.Background(), "ws-restart-compute", payload)
if got.Compute.InstanceType != "m6i.xlarge" {
t.Errorf("stored compute instance_type = %q, want m6i.xlarge", got.Compute.InstanceType)
}
if got.Compute.Volume.RootGB != 100 {
t.Errorf("stored compute root_gb = %d, want 100", got.Compute.Volume.RootGB)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplay_NonDisplayWorkspaceReturnsUnavailable(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-no-display").
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{}`))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-no-display"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-no-display/display", nil)
handler.Display(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse display response: %v", err)
}
if resp["available"] != false {
t.Fatalf("available = %v, want false", resp["available"])
}
if resp["reason"] != "display_not_enabled" {
t.Fatalf("reason = %v, want display_not_enabled", resp["reason"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplay_DisplayConfiguredReturnsSessionUnavailableContract(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-display").
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"dcv","width":1920,"height":1080}}`))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-display/display", nil)
handler.Display(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse display response: %v", err)
}
if resp["available"] != false {
t.Fatalf("available = %v, want false", resp["available"])
}
if resp["reason"] != "display_session_unavailable" {
t.Fatalf("reason = %v, want display_session_unavailable", resp["reason"])
}
if resp["status"] != "not_configured" {
t.Fatalf("status = %v, want not_configured", resp["status"])
}
if resp["mode"] != "desktop-control" || resp["protocol"] != "dcv" {
t.Fatalf("mode/protocol = %v/%v, want desktop-control/dcv", resp["mode"], resp["protocol"])
}
if resp["width"] != float64(1920) || resp["height"] != float64(1080) {
t.Fatalf("width/height = %v/%v, want 1920/1080", resp["width"], resp["height"])
}
if _, ok := resp["url"]; ok {
t.Fatalf("display response exposed url before session infra exists: %v", resp["url"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplay_IgnoresUnrelatedStoredComputeSizingDrift(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-display-sizing-drift").
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"instance_type":"old.large","display":{"mode":"desktop-control","protocol":"dcv","width":1920,"height":1080}}`))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-display-sizing-drift"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-display-sizing-drift/display", nil)
handler.Display(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse display response: %v", err)
}
if resp["reason"] != "display_session_unavailable" {
t.Fatalf("reason = %v, want display_session_unavailable", resp["reason"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplay_InvalidStoredDisplayConfigReturnsServerError(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-invalid-display").
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"vnc"}}`))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-invalid-display"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-invalid-display/display", nil)
handler.Display(c)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected status 500, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse display response: %v", err)
}
if resp["error"] != "invalid display config" {
t.Fatalf("error = %v, want invalid display config", resp["error"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
@@ -0,0 +1,360 @@
package handlers
import (
"context"
"crypto/subtle"
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"time"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
"github.com/gin-gonic/gin"
)
const (
displayControlDefaultTTLSeconds = 300
displayControlMinTTLSeconds = 30
displayControlMaxTTLSeconds = 3600
)
type workspaceDisplayControlResponse struct {
Controller string `json:"controller"`
ControlledBy string `json:"controlled_by,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
}
type workspaceDisplayControlNoneResponse struct {
Controller string `json:"controller"`
}
type acquireDisplayControlRequest struct {
Controller string `json:"controller"`
TTLSeconds int `json:"ttl_seconds"`
}
type releaseDisplayControlRequest struct {
Force bool `json:"force"`
}
// DisplayControl handles GET /workspaces/:id/display/control.
func (h *WorkspaceHandler) DisplayControl(c *gin.Context) {
lock, found, err := h.loadActiveDisplayControl(c, c.Param("id"))
if err != nil {
log.Printf("DisplayControl: load lock for %s failed: %v", c.Param("id"), err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load display control"})
return
}
if !found {
c.JSON(http.StatusOK, workspaceDisplayControlNoneResponse{Controller: "none"})
return
}
c.JSON(http.StatusOK, lock)
}
// AcquireDisplayControl handles POST /workspaces/:id/display/control/acquire.
func (h *WorkspaceHandler) AcquireDisplayControl(c *gin.Context) {
var req acquireDisplayControlRequest
if c.Request.Body != nil && c.Request.ContentLength != 0 {
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid display control request"})
return
}
}
if req.Controller == "" {
req.Controller = "user"
}
if req.Controller != "user" {
c.JSON(http.StatusBadRequest, gin.H{"error": "browser callers may only acquire user display control"})
return
}
if req.TTLSeconds == 0 {
req.TTLSeconds = displayControlDefaultTTLSeconds
}
if req.TTLSeconds < displayControlMinTTLSeconds || req.TTLSeconds > displayControlMaxTTLSeconds {
c.JSON(http.StatusBadRequest, gin.H{"error": "ttl_seconds must be between 30 and 3600"})
return
}
if ok := h.displayControlEnabled(c, c.Param("id")); !ok {
return
}
controlledBy, ok := displayControlActor(c)
if !ok {
c.JSON(http.StatusForbidden, gin.H{"error": "display control requires admin-token or org-token auth"})
return
}
workspaceID := c.Param("id")
startedAt := time.Now()
emitDisplayControlEvent(c.Request.Context(), "display.control.acquire.started", workspaceID, map[string]any{
"controller": req.Controller,
"controlled_by": controlledBy,
"ttl_seconds": req.TTLSeconds,
})
var lock workspaceDisplayControlResponse
err := db.DB.QueryRowContext(c.Request.Context(), `
INSERT INTO workspace_display_control_locks
(workspace_id, controller, controlled_by, expires_at)
VALUES
($1, $2, $3, now() + ($4 * interval '1 second'))
ON CONFLICT (workspace_id) DO UPDATE
SET controller = EXCLUDED.controller,
controlled_by = EXCLUDED.controlled_by,
expires_at = EXCLUDED.expires_at,
updated_at = now()
WHERE workspace_display_control_locks.expires_at <= now()
OR workspace_display_control_locks.controlled_by = EXCLUDED.controlled_by
RETURNING controller, controlled_by, expires_at`,
workspaceID, req.Controller, controlledBy, req.TTLSeconds,
).Scan(&lock.Controller, &lock.ControlledBy, &lock.ExpiresAt)
if err == nil {
emitDisplayControlEvent(c.Request.Context(), "display.control.acquire.completed", workspaceID, map[string]any{
"controller": lock.Controller,
"controlled_by": lock.ControlledBy,
"ttl_seconds": req.TTLSeconds,
"duration_ms": time.Since(startedAt).Milliseconds(),
})
c.JSON(http.StatusOK, lock)
return
}
if err == sql.ErrNoRows {
current, found, loadErr := h.loadActiveDisplayControl(c, workspaceID)
if loadErr != nil {
log.Printf("AcquireDisplayControl: load active lock for %s failed: %v", workspaceID, loadErr)
emitDisplayControlEvent(c.Request.Context(), "display.control.acquire.failed", workspaceID, map[string]any{
"controlled_by": controlledBy,
"duration_ms": time.Since(startedAt).Milliseconds(),
"error": loadErr.Error(),
})
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load display control"})
return
}
emitDisplayControlEvent(c.Request.Context(), "display.control.acquire.failed", workspaceID, map[string]any{
"controlled_by": controlledBy,
"duration_ms": time.Since(startedAt).Milliseconds(),
"error": "display control already held",
})
if !found {
c.JSON(http.StatusConflict, gin.H{
"error": "display control already held",
"current": workspaceDisplayControlNoneResponse{Controller: "none"},
})
return
}
c.JSON(http.StatusConflict, gin.H{
"error": "display control already held",
"current": current,
})
return
}
log.Printf("AcquireDisplayControl: acquire lock for %s failed: %v", workspaceID, err)
emitDisplayControlEvent(c.Request.Context(), "display.control.acquire.failed", workspaceID, map[string]any{
"controlled_by": controlledBy,
"duration_ms": time.Since(startedAt).Milliseconds(),
"error": err.Error(),
})
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to acquire display control"})
}
// ReleaseDisplayControl handles POST /workspaces/:id/display/control/release.
func (h *WorkspaceHandler) ReleaseDisplayControl(c *gin.Context) {
var req releaseDisplayControlRequest
if c.Request.Body != nil && c.Request.ContentLength != 0 {
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid display control release request"})
return
}
}
if req.Force {
if !displayControlIsAdminToken(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "force release requires admin-token auth"})
return
}
}
controlledBy, ok := displayControlActor(c)
if !ok {
c.JSON(http.StatusForbidden, gin.H{"error": "display control requires admin-token or org-token auth"})
return
}
workspaceID := c.Param("id")
startedAt := time.Now()
emitDisplayControlEvent(c.Request.Context(), "display.control.release.started", workspaceID, map[string]any{
"controlled_by": controlledBy,
"force": req.Force,
})
query := `DELETE FROM workspace_display_control_locks WHERE workspace_id = $1 AND controlled_by = $2`
args := []interface{}{workspaceID, controlledBy}
if req.Force {
query = `DELETE FROM workspace_display_control_locks WHERE workspace_id = $1`
args = []interface{}{workspaceID}
}
result, err := db.DB.ExecContext(c.Request.Context(), query, args...)
if err != nil {
log.Printf("ReleaseDisplayControl: release lock for %s failed: %v", workspaceID, err)
emitDisplayControlEvent(c.Request.Context(), "display.control.release.failed", workspaceID, map[string]any{
"controlled_by": controlledBy,
"duration_ms": time.Since(startedAt).Milliseconds(),
"error": err.Error(),
"force": req.Force,
})
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to release display control"})
return
}
rowsAffected, err := result.RowsAffected()
if err != nil {
log.Printf("ReleaseDisplayControl: rows affected for %s failed: %v", workspaceID, err)
emitDisplayControlEvent(c.Request.Context(), "display.control.release.failed", workspaceID, map[string]any{
"controlled_by": controlledBy,
"duration_ms": time.Since(startedAt).Milliseconds(),
"error": err.Error(),
"force": req.Force,
})
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to release display control"})
return
}
if rowsAffected == 0 {
current, found, loadErr := h.loadActiveDisplayControl(c, workspaceID)
if loadErr != nil {
log.Printf("ReleaseDisplayControl: load active lock for %s failed: %v", workspaceID, loadErr)
emitDisplayControlEvent(c.Request.Context(), "display.control.release.failed", workspaceID, map[string]any{
"controlled_by": controlledBy,
"duration_ms": time.Since(startedAt).Milliseconds(),
"error": loadErr.Error(),
"force": req.Force,
})
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load display control"})
return
}
if !found {
emitDisplayControlEvent(c.Request.Context(), "display.control.release.completed", workspaceID, map[string]any{
"controlled_by": controlledBy,
"duration_ms": time.Since(startedAt).Milliseconds(),
"force": req.Force,
"rows_affected": rowsAffected,
})
c.JSON(http.StatusOK, workspaceDisplayControlNoneResponse{Controller: "none"})
return
}
emitDisplayControlEvent(c.Request.Context(), "display.control.release.failed", workspaceID, map[string]any{
"controlled_by": controlledBy,
"duration_ms": time.Since(startedAt).Milliseconds(),
"error": "display control held by another caller",
"force": req.Force,
})
c.JSON(http.StatusConflict, gin.H{
"error": "display control held by another caller",
"current": current,
})
return
}
emitDisplayControlEvent(c.Request.Context(), "display.control.release.completed", workspaceID, map[string]any{
"controlled_by": controlledBy,
"duration_ms": time.Since(startedAt).Milliseconds(),
"force": req.Force,
"rows_affected": rowsAffected,
})
c.JSON(http.StatusOK, workspaceDisplayControlNoneResponse{Controller: "none"})
}
func (h *WorkspaceHandler) loadActiveDisplayControl(c *gin.Context, workspaceID string) (workspaceDisplayControlResponse, bool, error) {
var lock workspaceDisplayControlResponse
err := db.DB.QueryRowContext(c.Request.Context(),
`SELECT controller, controlled_by, expires_at FROM workspace_display_control_locks WHERE workspace_id = $1 AND expires_at > now()`,
workspaceID,
).Scan(&lock.Controller, &lock.ControlledBy, &lock.ExpiresAt)
if err == nil {
return lock, true, nil
}
if err == sql.ErrNoRows {
return workspaceDisplayControlResponse{}, false, nil
}
return workspaceDisplayControlResponse{}, false, err
}
func (h *WorkspaceHandler) displayControlEnabled(c *gin.Context, workspaceID string) bool {
var raw string
err := db.DB.QueryRowContext(c.Request.Context(),
`SELECT COALESCE(compute, '{}'::jsonb) FROM workspaces WHERE id = $1`,
workspaceID,
).Scan(&raw)
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
return false
}
log.Printf("displayControlEnabled: load compute for %s failed: %v", workspaceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load display config"})
return false
}
compute, err := parseWorkspaceDisplayCompute(workspaceID, raw)
if err != nil {
log.Printf("displayControlEnabled: invalid display config for %s: %v", workspaceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid display config"})
return false
}
if compute.Display.Mode == "" || compute.Display.Mode == "none" {
c.JSON(http.StatusBadRequest, gin.H{"error": "display not enabled"})
return false
}
return true
}
func parseWorkspaceDisplayCompute(workspaceID, raw string) (models.WorkspaceCompute, error) {
var compute models.WorkspaceCompute
if raw == "" || raw == "{}" {
return compute, nil
}
if err := json.Unmarshal([]byte(raw), &compute); err != nil {
return models.WorkspaceCompute{}, fmt.Errorf("invalid compute JSON for %s: %w", workspaceID, err)
}
if err := validateWorkspaceDisplayConfig(compute.Display); err != nil {
return models.WorkspaceCompute{}, err
}
return compute, nil
}
func displayControlActor(c *gin.Context) (string, bool) {
if v, ok := c.Get("org_token_prefix"); ok {
if s, ok := v.(string); ok && s != "" {
return actorOrgTokenPrefix + s, true
}
}
if displayControlIsAdminToken(c) {
return actorAdminToken, true
}
// Browser session auth is intentionally observe-only until AdminAuth
// exposes a stable per-user or per-session identity in gin.Context.
return "", false
}
func displayControlIsAdminToken(c *gin.Context) bool {
adminSecret := os.Getenv("ADMIN_TOKEN")
if adminSecret == "" {
return false
}
tok := wsauth.BearerTokenFromHeader(c.GetHeader("Authorization"))
return subtle.ConstantTimeCompare([]byte(tok), []byte(adminSecret)) == 1
}
func emitDisplayControlEvent(ctx context.Context, eventType string, workspaceID string, payload map[string]any) {
if payload == nil {
payload = map[string]any{}
}
payloadJSON, err := json.Marshal(payload)
if err != nil {
log.Printf("emitDisplayControlEvent: marshal %s payload failed: %v", eventType, err)
return
}
if _, err := db.DB.ExecContext(ctx, `
INSERT INTO structure_events (event_type, workspace_id, payload, created_at)
VALUES ($1, $2, $3::jsonb, now())
`, eventType, workspaceID, string(payloadJSON)); err != nil {
log.Printf("emitDisplayControlEvent: insert %s failed: %v", eventType, err)
}
}
@@ -0,0 +1,321 @@
package handlers
import (
"bytes"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
)
func attachDisplayControlAdminToken(t *testing.T, c *gin.Context) {
t.Helper()
t.Setenv("ADMIN_TOKEN", "test-admin-secret")
c.Request.Header.Set("Authorization", "Bearer test-admin-secret")
}
func TestWorkspaceDisplayControl_NoActiveLockReturnsNone(t *testing.T) {
mock := setupTestDB(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectQuery(`SELECT controller, controlled_by, expires_at FROM workspace_display_control_locks WHERE workspace_id = \$1 AND expires_at > now\(\)`).
WithArgs("ws-display").
WillReturnError(sql.ErrNoRows)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-display/display/control", nil)
handler.DisplayControl(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["controller"] != "none" {
t.Fatalf("controller = %v, want none", resp["controller"])
}
if _, ok := resp["expires_at"]; ok {
t.Fatalf("none response included expires_at: %#v", resp)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplayControlAcquire_ClaimsUnlockedDisplay(t *testing.T) {
mock := setupTestDB(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
expiresAt := time.Date(2026, 5, 23, 18, 30, 0, 0, time.UTC)
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-display").
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"dcv","width":1920,"height":1080}}`))
mock.ExpectQuery(`INSERT INTO workspace_display_control_locks`).
WithArgs("ws-display", "user", "admin-token", 300).
WillReturnRows(sqlmock.NewRows([]string{"controller", "controlled_by", "expires_at"}).
AddRow("user", "admin-token", expiresAt))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
c.Request = httptest.NewRequest("POST", "/workspaces/ws-display/display/control/acquire", bytes.NewBufferString(`{"controller":"user","ttl_seconds":300}`))
c.Request.Header.Set("Content-Type", "application/json")
attachDisplayControlAdminToken(t, c)
handler.AcquireDisplayControl(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["controller"] != "user" || resp["controlled_by"] != "admin-token" {
t.Fatalf("lock response = %#v, want user/admin-token", resp)
}
if resp["expires_at"] == "" {
t.Fatalf("expires_at missing in response: %#v", resp)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplayControlAcquire_ActiveLockReturnsConflict(t *testing.T) {
mock := setupTestDB(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
expiresAt := time.Date(2026, 5, 23, 18, 30, 0, 0, time.UTC)
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-display").
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"dcv","width":1920,"height":1080}}`))
mock.ExpectQuery(`INSERT INTO workspace_display_control_locks`).
WithArgs("ws-display", "user", "admin-token", 300).
WillReturnError(sql.ErrNoRows)
mock.ExpectQuery(`SELECT controller, controlled_by, expires_at FROM workspace_display_control_locks WHERE workspace_id = \$1 AND expires_at > now\(\)`).
WithArgs("ws-display").
WillReturnRows(sqlmock.NewRows([]string{"controller", "controlled_by", "expires_at"}).
AddRow("agent", "sidecar", expiresAt))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
c.Request = httptest.NewRequest("POST", "/workspaces/ws-display/display/control/acquire", bytes.NewBufferString(`{"controller":"user","ttl_seconds":300}`))
c.Request.Header.Set("Content-Type", "application/json")
attachDisplayControlAdminToken(t, c)
handler.AcquireDisplayControl(c)
if w.Code != http.StatusConflict {
t.Fatalf("expected status 409, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["error"] != "display control already held" {
t.Fatalf("error = %v, want display control already held", resp["error"])
}
current, ok := resp["current"].(map[string]interface{})
if !ok || current["controller"] != "agent" || current["controlled_by"] != "sidecar" {
t.Fatalf("current lock = %#v, want agent/sidecar", resp["current"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplayControlAcquire_RejectsDisplayDisabledWorkspace(t *testing.T) {
mock := setupTestDB(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-no-display").
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{}`))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-no-display"}}
c.Request = httptest.NewRequest("POST", "/workspaces/ws-no-display/display/control/acquire", bytes.NewBufferString(`{"controller":"user","ttl_seconds":300}`))
c.Request.Header.Set("Content-Type", "application/json")
attachDisplayControlAdminToken(t, c)
handler.AcquireDisplayControl(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["error"] != "display not enabled" {
t.Fatalf("error = %v, want display not enabled", resp["error"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplayControlAcquire_RejectsCoarseSessionActor(t *testing.T) {
mock := setupTestDB(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-display").
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"dcv","width":1920,"height":1080}}`))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
c.Request = httptest.NewRequest("POST", "/workspaces/ws-display/display/control/acquire", bytes.NewBufferString(`{"controller":"user","ttl_seconds":300}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request.Header.Set("Cookie", "molecule_session=present")
handler.AcquireDisplayControl(c)
if w.Code != http.StatusForbidden {
t.Fatalf("expected status 403, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["error"] != "display control requires admin-token or org-token auth" {
t.Fatalf("error = %v, want display control requires admin-token or org-token auth", resp["error"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplayControlRelease_RemovesCallerLock(t *testing.T) {
mock := setupTestDB(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectExec(`DELETE FROM workspace_display_control_locks WHERE workspace_id = \$1 AND controlled_by = \$2`).
WithArgs("ws-display", "admin-token").
WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
c.Request = httptest.NewRequest("POST", "/workspaces/ws-display/display/control/release", nil)
attachDisplayControlAdminToken(t, c)
handler.ReleaseDisplayControl(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["controller"] != "none" {
t.Fatalf("controller = %v, want none", resp["controller"])
}
if _, ok := resp["expires_at"]; ok {
t.Fatalf("none response included expires_at: %#v", resp)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplayControlRelease_ConflictWhenCallerDoesNotOwnLock(t *testing.T) {
mock := setupTestDB(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
expiresAt := time.Date(2026, 5, 23, 18, 30, 0, 0, time.UTC)
mock.ExpectExec(`DELETE FROM workspace_display_control_locks WHERE workspace_id = \$1 AND controlled_by = \$2`).
WithArgs("ws-display", "admin-token").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectQuery(`SELECT controller, controlled_by, expires_at FROM workspace_display_control_locks WHERE workspace_id = \$1 AND expires_at > now\(\)`).
WithArgs("ws-display").
WillReturnRows(sqlmock.NewRows([]string{"controller", "controlled_by", "expires_at"}).
AddRow("user", "org-token:abcd1234", expiresAt))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
c.Request = httptest.NewRequest("POST", "/workspaces/ws-display/display/control/release", nil)
attachDisplayControlAdminToken(t, c)
handler.ReleaseDisplayControl(c)
if w.Code != http.StatusConflict {
t.Fatalf("expected status 409, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["error"] != "display control held by another caller" {
t.Fatalf("error = %v, want display control held by another caller", resp["error"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplayControlRelease_RejectsOrgTokenForceRelease(t *testing.T) {
setupTestDB(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
c.Set("org_token_prefix", "abcd1234")
c.Request = httptest.NewRequest("POST", "/workspaces/ws-display/display/control/release", bytes.NewBufferString(`{"force":true}`))
c.Request.Header.Set("Content-Type", "application/json")
handler.ReleaseDisplayControl(c)
if w.Code != http.StatusForbidden {
t.Fatalf("expected status 403, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["error"] != "force release requires admin-token auth" {
t.Fatalf("error = %v, want force release requires admin-token auth", resp["error"])
}
}
func TestWorkspaceDisplayControlAcquire_RejectsAgentImpersonation(t *testing.T) {
setupTestDB(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
c.Request = httptest.NewRequest("POST", "/workspaces/ws-display/display/control/acquire", bytes.NewBufferString(`{"controller":"agent","ttl_seconds":300}`))
c.Request.Header.Set("Content-Type", "application/json")
attachDisplayControlAdminToken(t, c)
handler.AcquireDisplayControl(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["error"] != "browser callers may only acquire user display control" {
t.Fatalf("error = %v, want browser callers may only acquire user display control", resp["error"])
}
}
@@ -296,6 +296,8 @@ func (h *WorkspaceHandler) buildProvisionerConfig(
WorkspaceAccess: workspaceAccess,
Tier: payload.Tier,
Runtime: payload.Runtime,
InstanceType: payload.Compute.InstanceType,
DiskGB: int32(payload.Compute.Volume.RootGB),
EnvVars: envVars,
PlatformURL: h.platformURL,
AwarenessURL: os.Getenv("AWARENESS_URL"),
@@ -1009,4 +1011,3 @@ func (h *WorkspaceHandler) provisionWorkspaceCP(workspaceID, templatePath string
log.Printf("CPProvisioner: workspace %s started as machine %s via control plane", workspaceID, machineID)
}
@@ -164,7 +164,7 @@ func (h *WorkspaceHandler) maybeRestartAfterFileWrite(workspaceID string) {
// isRestarting reports whether a restart cycle is currently in flight for
// the workspace. Callers that have their own "container looks dead" probe
// MUST consult this before triggering a restart, because during the
// 20-30s EC2-pending window the workspace's url='' and IsRunning()=false
// 20-30s EC2-pending window the workspace's url= and IsRunning()=false
// looks identical to a dead container — and any restart-triggering probe
// (maybeMarkContainerDead from canvas /delegations poll, or the trailing
// restart-context probe at the end of runRestartCycle) will set
@@ -337,7 +337,7 @@ func (h *WorkspaceHandler) Restart(c *gin.Context) {
}
var configFiles map[string][]byte
payload := models.CreateWorkspacePayload{Name: wsName, Tier: tier, Runtime: containerRuntime}
payload := withStoredCompute(ctx, id, models.CreateWorkspacePayload{Name: wsName, Tier: tier, Runtime: containerRuntime})
log.Printf("Restart: workspace %s (%s) runtime=%q", wsName, id, containerRuntime)
// #12: ?reset=true (or body.Reset) discards the claude-sessions volume
@@ -791,7 +791,7 @@ func (h *WorkspaceHandler) runRestartCycle(workspaceID string) {
})
// Runtime from DB — no more config file parsing
payload := models.CreateWorkspacePayload{Name: wsName, Tier: tier, Runtime: dbRuntime}
payload := withStoredCompute(ctx, workspaceID, models.CreateWorkspacePayload{Name: wsName, Tier: tier, Runtime: dbRuntime})
// Snapshot restart-context data before the new session overwrites
// last_heartbeat_at. Issue #19 Layer 1.
@@ -948,7 +948,7 @@ func (h *WorkspaceHandler) Resume(c *gin.Context) {
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceProvisioning), ws.id, map[string]interface{}{
"name": ws.name, "tier": ws.tier, "runtime": ws.runtime,
})
payload := models.CreateWorkspacePayload{Name: ws.name, Tier: ws.tier, Runtime: ws.runtime}
payload := withStoredCompute(ctx, ws.id, models.CreateWorkspacePayload{Name: ws.name, Tier: ws.tier, Runtime: ws.runtime})
// Resume is provision-only (workspace is paused, no live container
// to stop). provisionWorkspaceAuto handles backend routing and the
// no-backend mark-failed fallback identically to Create. Pre-
@@ -29,7 +29,7 @@ func TestWorkspaceGet_Success(t *testing.T) {
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
"budget_limit", "monthly_spend",
"broadcast_enabled", "talk_to_user_enabled",
"broadcast_enabled", "talk_to_user_enabled", "compute",
}
mock.ExpectQuery("SELECT w.id, w.name").
WithArgs("cccccccc-0001-0000-0000-000000000000").
@@ -37,7 +37,7 @@ func TestWorkspaceGet_Success(t *testing.T) {
AddRow("cccccccc-0001-0000-0000-000000000000", "My Agent", "worker", 1, "online", []byte(`{"name":"test"}`),
"http://localhost:8001", nil, 2, 1, 0.05, "", 3600, "working", "langgraph",
"", 10.0, 20.0, false,
nil, 0, false, true))
nil, 0, false, true, []byte(`{}`)))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -119,7 +119,7 @@ func TestWorkspaceGet_RemovedReturns410(t *testing.T) {
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
"budget_limit", "monthly_spend",
"broadcast_enabled", "talk_to_user_enabled",
"broadcast_enabled", "talk_to_user_enabled", "compute",
}
mock.ExpectQuery("SELECT w.id, w.name").
WithArgs(id).
@@ -127,7 +127,7 @@ func TestWorkspaceGet_RemovedReturns410(t *testing.T) {
AddRow(id, "Old Agent", "worker", 1, string(models.StatusRemoved), []byte(`null`),
"", nil, 0, 1, 0.0, "", 0, "", "langgraph",
"", 0.0, 0.0, false,
nil, 0, false, true))
nil, 0, false, true, []byte(`{}`)))
mock.ExpectQuery(`SELECT updated_at FROM workspaces`).
WithArgs(id).
WillReturnRows(sqlmock.NewRows([]string{"updated_at"}).AddRow(removedAt))
@@ -183,7 +183,7 @@ func TestWorkspaceGet_RemovedReturns410WithNullRemovedAtOnTimestampFetchFailure(
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
"budget_limit", "monthly_spend",
"broadcast_enabled", "talk_to_user_enabled",
"broadcast_enabled", "talk_to_user_enabled", "compute",
}
mock.ExpectQuery("SELECT w.id, w.name").
WithArgs(id).
@@ -191,7 +191,7 @@ func TestWorkspaceGet_RemovedReturns410WithNullRemovedAtOnTimestampFetchFailure(
AddRow(id, "Vanished", "worker", 1, string(models.StatusRemoved), []byte(`null`),
"", nil, 0, 1, 0.0, "", 0, "", "langgraph",
"", 0.0, 0.0, false,
nil, 0, false, true))
nil, 0, false, true, []byte(`{}`)))
// Simulate the row vanishing between the two queries.
mock.ExpectQuery(`SELECT updated_at FROM workspaces`).
WithArgs(id).
@@ -246,7 +246,7 @@ func TestWorkspaceGet_RemovedWithIncludeQueryReturns200(t *testing.T) {
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
"budget_limit", "monthly_spend",
"broadcast_enabled", "talk_to_user_enabled",
"broadcast_enabled", "talk_to_user_enabled", "compute",
}
mock.ExpectQuery("SELECT w.id, w.name").
WithArgs(id).
@@ -254,7 +254,7 @@ func TestWorkspaceGet_RemovedWithIncludeQueryReturns200(t *testing.T) {
AddRow(id, "Audit Agent", "worker", 1, string(models.StatusRemoved), []byte(`null`),
"", nil, 0, 1, 0.0, "", 0, "", "langgraph",
"", 0.0, 0.0, false,
nil, 0, false, true))
nil, 0, false, true, []byte(`{}`)))
// last_outbound_at follow-up query (existing path)
mock.ExpectQuery(`SELECT last_outbound_at FROM workspaces`).
WithArgs(id).
@@ -718,7 +718,7 @@ func TestWorkspaceList_Empty(t *testing.T) {
"parent_id", "active_tasks", "last_error_rate", "last_sample_error",
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
"budget_limit", "monthly_spend",
"broadcast_enabled", "talk_to_user_enabled",
"broadcast_enabled", "talk_to_user_enabled", "compute",
}))
w := httptest.NewRecorder()
@@ -1422,7 +1422,7 @@ func TestWorkspaceGet_FinancialFieldsStripped(t *testing.T) {
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
"budget_limit", "monthly_spend",
"broadcast_enabled", "talk_to_user_enabled",
"broadcast_enabled", "talk_to_user_enabled", "compute",
}
// Populate with non-zero financial values to confirm they are stripped.
mock.ExpectQuery("SELECT w.id, w.name").
@@ -1431,7 +1431,7 @@ func TestWorkspaceGet_FinancialFieldsStripped(t *testing.T) {
AddRow("cccccccc-0010-0000-0000-000000000000", "Finance Test", "worker", 1, "online", []byte(`{}`),
"http://localhost:9001", nil, 0, 1, 0.0, "", 0, "", "langgraph",
"", 0.0, 0.0, false,
int64(50000), int64(12500), false, true)) // budget_limit=500 USD, spend=125 USD
int64(50000), int64(12500), false, true, []byte(`{}`))) // budget_limit=500 USD, spend=125 USD
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -1479,7 +1479,7 @@ func TestWorkspaceGet_SensitiveFieldsStripped(t *testing.T) {
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
"budget_limit", "monthly_spend",
"broadcast_enabled", "talk_to_user_enabled",
"broadcast_enabled", "talk_to_user_enabled", "compute",
}
mock.ExpectQuery("SELECT w.id, w.name").
WithArgs("cccccccc-0955-0000-0000-000000000000").
@@ -1492,7 +1492,7 @@ func TestWorkspaceGet_SensitiveFieldsStripped(t *testing.T) {
"langgraph",
"/home/user/secret-projects/client-work",
0.0, 0.0, false,
nil, 0, false, true))
nil, 0, false, true, []byte(`{}`)))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -3,6 +3,7 @@ package pgplugin
import (
"encoding/json"
"errors"
"log"
"net/http"
"strings"
@@ -246,7 +247,9 @@ func (h *Handler) forget(w http.ResponseWriter, r *http.Request, id string) {
func writeJSON(w http.ResponseWriter, status int, body interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
if err := json.NewEncoder(w).Encode(body); err != nil {
log.Printf("pgplugin: JSON encode error: %v", err)
}
}
func writeError(w http.ResponseWriter, status int, code contract.ErrorCode, message string, details map[string]interface{}) {
+37 -16
View File
@@ -35,16 +35,16 @@ type Workspace struct {
// DeliveryMode: "push" (synchronous to URL — default) or "poll" (logged
// to activity_logs, agent reads via GET /activity?since_id=). See
// migration 045 + RFC #2339.
DeliveryMode string `json:"delivery_mode" db:"delivery_mode"`
DeliveryMode string `json:"delivery_mode" db:"delivery_mode"`
// BroadcastEnabled: when true the workspace may call POST /broadcast to
// deliver a message to all non-removed agent workspaces in the org.
// Default false — only privileged orchestrators should hold this ability.
BroadcastEnabled bool `json:"broadcast_enabled" db:"broadcast_enabled"`
BroadcastEnabled bool `json:"broadcast_enabled" db:"broadcast_enabled"`
// TalkToUserEnabled: when false the workspace's send_message_to_user calls
// and POST /notify requests are rejected with HTTP 403 so the agent is
// forced to route updates through a parent workspace. Default true
// (preserves existing behaviour for all workspaces).
TalkToUserEnabled bool `json:"talk_to_user_enabled" db:"talk_to_user_enabled"`
TalkToUserEnabled bool `json:"talk_to_user_enabled" db:"talk_to_user_enabled"`
// Canvas layout fields (from JOIN)
X float64 `json:"x"`
Y float64 `json:"y"`
@@ -71,12 +71,12 @@ type RegisterPayload struct {
// enforces the conditional requirement based on the resolved
// delivery mode (payload value, falling back to the row's existing
// value, falling back to "push").
URL string `json:"url"`
AgentCard json.RawMessage `json:"agent_card" binding:"required"`
URL string `json:"url"`
AgentCard json.RawMessage `json:"agent_card" binding:"required"`
// DeliveryMode is optional. Empty string means "keep the existing
// value on the workspace row, or default to push for new rows".
// When set, must be one of DeliveryModePush / DeliveryModePoll.
DeliveryMode string `json:"delivery_mode,omitempty"`
DeliveryMode string `json:"delivery_mode,omitempty"`
}
type HeartbeatPayload struct {
@@ -154,19 +154,36 @@ type MemorySeed struct {
Scope string `json:"scope" yaml:"scope"` // LOCAL, TEAM, GLOBAL
}
type WorkspaceComputeVolume struct {
RootGB int `json:"root_gb,omitempty"`
}
type WorkspaceComputeDisplay struct {
Mode string `json:"mode,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
Protocol string `json:"protocol,omitempty"`
}
type WorkspaceCompute struct {
InstanceType string `json:"instance_type,omitempty"`
Volume WorkspaceComputeVolume `json:"volume,omitempty"`
Display WorkspaceComputeDisplay `json:"display,omitempty"`
}
type CreateWorkspacePayload struct {
Name string `json:"name" binding:"required"`
Role string `json:"role"`
Template string `json:"template"` // workspace-configs-templates folder name
Tier int `json:"tier"`
Model string `json:"model"`
Runtime string `json:"runtime"` // "langgraph" (default), "claude-code", etc.
External bool `json:"external"` // true = no Docker container, just a registered URL
URL string `json:"url"` // for external workspaces: the A2A endpoint URL (push mode only — omit for poll)
Name string `json:"name" binding:"required"`
Role string `json:"role"`
Template string `json:"template"` // workspace-configs-templates folder name
Tier int `json:"tier"`
Model string `json:"model"`
Runtime string `json:"runtime"` // "langgraph" (default), "claude-code", etc.
External bool `json:"external"` // true = no Docker container, just a registered URL
URL string `json:"url"` // for external workspaces: the A2A endpoint URL (push mode only — omit for poll)
// DeliveryMode: "push" (default) sends inbound A2A to URL synchronously;
// "poll" records inbound to activity_logs for the agent to consume via
// GET /activity?since_id=. Poll mode does not require a URL. See #2339.
DeliveryMode string `json:"delivery_mode,omitempty"`
DeliveryMode string `json:"delivery_mode,omitempty"`
WorkspaceDir string `json:"workspace_dir"` // host path to mount as /workspace (empty = isolated volume)
WorkspaceAccess string `json:"workspace_access"` // "none" (default), "read_only", or "read_write" — see #65
ParentID *string `json:"parent_id"`
@@ -180,7 +197,11 @@ type CreateWorkspacePayload struct {
// MaxConcurrentTasks caps parallel A2A + cron dispatch. 0 means use
// DefaultMaxConcurrentTasks. Leaders typically set 3.
MaxConcurrentTasks int `json:"max_concurrent_tasks"`
Canvas struct {
// Compute is the product-facing per-workspace EC2 shape/display
// contract. Phase 1 uses instance_type + volume.root_gb and persists
// display for future desktop-control workspaces.
Compute WorkspaceCompute `json:"compute,omitempty"`
Canvas struct {
X float64 `json:"x"`
Y float64 `json:"y"`
} `json:"canvas"`
@@ -152,12 +152,14 @@ func (p *CPProvisioner) adminAuthHeaders(req *http.Request) {
}
type cpProvisionRequest struct {
OrgID string `json:"org_id"`
WorkspaceID string `json:"workspace_id"`
Runtime string `json:"runtime"`
Tier int `json:"tier"`
PlatformURL string `json:"platform_url"`
Env map[string]string `json:"env"`
OrgID string `json:"org_id"`
WorkspaceID string `json:"workspace_id"`
Runtime string `json:"runtime"`
Tier int `json:"tier"`
InstanceType string `json:"instance_type,omitempty"`
DiskGB int32 `json:"disk_gb,omitempty"`
PlatformURL string `json:"platform_url"`
Env map[string]string `json:"env"`
// ConfigFiles are template + generated config files to write into the
// EC2 instance's /configs directory. OFFSEC-010: collected by
// collectCPConfigFiles which rejects symlinks and non-regular files
@@ -206,13 +208,15 @@ func (p *CPProvisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string,
}
req := cpProvisionRequest{
OrgID: p.orgID,
WorkspaceID: cfg.WorkspaceID,
Runtime: cfg.Runtime,
Tier: cfg.Tier,
PlatformURL: cfg.PlatformURL,
Env: env,
ConfigFiles: configFiles,
OrgID: p.orgID,
WorkspaceID: cfg.WorkspaceID,
Runtime: cfg.Runtime,
Tier: cfg.Tier,
InstanceType: cfg.InstanceType,
DiskGB: cfg.DiskGB,
PlatformURL: cfg.PlatformURL,
Env: env,
ConfigFiles: configFiles,
}
body, err := json.Marshal(req)
@@ -191,6 +191,12 @@ func TestStart_HappyPath(t *testing.T) {
if body.WorkspaceID != "ws-1" || body.Runtime != "python" {
t.Errorf("body mismatch: %+v", body)
}
if body.InstanceType != "m6i.xlarge" {
t.Errorf("instance_type = %q, want m6i.xlarge", body.InstanceType)
}
if body.DiskGB != 100 {
t.Errorf("disk_gb = %d, want 100", body.DiskGB)
}
w.WriteHeader(http.StatusCreated)
_, _ = io.WriteString(w, `{"instance_id":"i-abc123","state":"pending"}`)
}))
@@ -205,6 +211,7 @@ func TestStart_HappyPath(t *testing.T) {
id, err := p.Start(context.Background(), WorkspaceConfig{
WorkspaceID: "ws-1", Runtime: "python", Tier: 1, PlatformURL: "http://tenant",
InstanceType: "m6i.xlarge", DiskGB: 100,
})
if err != nil {
t.Fatalf("Start: %v", err)
@@ -362,7 +369,7 @@ func TestStart_CollectsConfigFiles(t *testing.T) {
p := &CPProvisioner{baseURL: srv.URL, orgID: "org-1", httpClient: srv.Client()}
_, err := p.Start(context.Background(), WorkspaceConfig{
WorkspaceID: "ws-1",
Runtime: "python",
Runtime: "python",
Tier: 1,
PlatformURL: "http://tenant",
TemplatePath: tmpl,
@@ -424,7 +431,7 @@ func TestStart_SymlinkTemplatePathError(t *testing.T) {
p := &CPProvisioner{baseURL: "http://unused", orgID: "org-1", httpClient: &http.Client{Timeout: time.Second}}
_, err := p.Start(context.Background(), WorkspaceConfig{
WorkspaceID: "ws-1",
Runtime: "python",
Runtime: "python",
TemplatePath: symlink, // symlink root → OFFSEC-010 guard should fire
})
if err == nil {
@@ -98,6 +98,8 @@ type WorkspaceConfig struct {
WorkspacePath string // Host path to bind-mount as /workspace (if empty, uses Docker named volume)
Tier int
Runtime string // "langgraph" (default) or "claude-code", "codex", "ollama", "custom"
InstanceType string // Optional CP EC2 instance type override (SaaS only)
DiskGB int32 // Optional CP root volume size override in GiB (SaaS only)
EnvVars map[string]string // Additional env vars (API keys, etc.)
PlatformURL string
AwarenessURL string
@@ -1605,4 +1607,3 @@ func parseOCIPlatform(s string) *ocispec.Platform {
}
return &ocispec.Platform{OS: parts[0], Architecture: parts[1]}
}
@@ -0,0 +1,59 @@
# T4 privilege contract — generated from
# molecule-ai/molecule-core workspace-server/internal/provisioner/t4_privilege_contract.go
# RFC: molecule-ai/internal#456
# Do NOT edit this file by hand; regenerate via `go run ./cmd/t4-contract-dump > t4_capabilities.yaml`.
version: 1
agent_uid: 1000
capabilities:
- name: "agent_home_writable"
description: "/agent-home is writable by the agent (Files API split per task #128). The Files API redesign uses /agent-home as the user-writable root; the agent must be able to create files there without sudo."
severity: hard
source: "task #128 Files API redesign; memory reference_post_suspension_pipeline"
probe: "TF=/agent-home/.t4-cap-write-probe-${MOLECULE_T4_PROBE_ID:-$$}; echo ok > \"$TF\" && [ \"$(cat \"$TF\")\" = \"ok\" ] && rm -f \"$TF\""
- name: "agent_uid_1000"
description: "The container's primary process (the runtime, post-gosu) runs as uid 1000, not root. T4 grants full machine access via privileged + host PID + Docker socket — the WORKLOAD inside that privileged container must still be unprivileged to prevent every untrusted code execution from being trivially root-on-host."
severity: hard
source: "RFC internal#456 §2.1.2; memory feedback_hermes_listpeers_401_token_root600_unreadable_by_uid1000"
probe: "[ \"$(id -u)\" = \"1000\" ]"
- name: "auth_token_agent_owned"
description: "/configs/.auth_token is owned by uid 1000 (== AgentUID) so the a2a_mcp_server can read its bearer. In SaaS mode molecule-runtime itself writes the token via save_token() — the ownership equals the runtime's exec uid. If the runtime ever runs as root, this fails and list_peers 401s (the Hermes class bug)."
severity: hard
source: "RFC internal#456 §10; memory feedback_hermes_listpeers_401_token_root600_unreadable_by_uid1000"
probe: "[ -e /configs/.auth_token ] && [ \"$(stat -c '%u' /configs/.auth_token)\" = \"1000\" ]"
- name: "docker_socket_reachable"
description: "/var/run/docker.sock is bind-mounted and host Docker is reachable from the T4 container. The probe enters the host mount+PID namespaces before running docker info so it validates the same host-control path production agents use, instead of depending on the template image's Docker CLI/socket group details."
severity: hard
source: "provisioner.go applyHostConfig T4 branch (case 4)"
probe: "sudo -n nsenter --target 1 --mount --pid -- docker info >/dev/null 2>&1"
- name: "host_fs_write_readback"
description: "Host filesystem is mounted at /host and the agent can write+read+remove a file there via sudo. Proves real host reach (not just a PID-1 namespace trick on an isolated init)."
severity: hard
source: "RFC internal#456 §11"
probe: "MARKER=\"t4cap-$(date +%s)-$RANDOM\"; PROBE_FILE=\"/host/tmp/.t4-cap-probe-${MOLECULE_T4_PROBE_ID:-$$}\"; sudo -n sh -c \"echo $MARKER > $PROBE_FILE\" && [ \"$(sudo -n cat $PROBE_FILE)\" = \"$MARKER\" ] && sudo -n rm -f $PROBE_FILE"
- name: "host_root_reach_via_nsenter"
description: "The uid-1000 agent can attain host root via `sudo -n nsenter --target 1 --mount --pid -- id -u` returning 0. This is the T4 escalation leg: full machine access means the agent CAN escalate to host root deliberately, even though it does not run as root by default."
severity: hard
source: "RFC internal#456 §11; memory reference_per_template_privilege_contract_class_audit_2026_05_16"
probe: "[ \"$(sudo -n nsenter --target 1 --mount --pid -- id -u)\" = \"0\" ]"
- name: "list_peers_http_200"
description: "The platform list_peers HTTP endpoint (served by the in-container a2a_mcp_server) returns HTTP 200 when called from uid 1000 with the bearer from /configs/.auth_token. This proves the WHOLE token-ownership chain end-to-end: token written under correct uid → reader uid matches → bearer non-empty → platform accepts. A self-contained empirical test for the Hermes class bug."
severity: hard
source: "memory reference_openclaw_fresh_provision_nonfunctional_anthropic_default_unroutable; memory reference_openclaw_mcp_peer_wiring_rootcause"
probe: "BEARER=$(cat /configs/.auth_token 2>/dev/null || echo \"\"); [ -n \"$BEARER\" ] || exit 1; PORT=$(cat /configs/.platform_port 2>/dev/null || echo \"8080\"); STATUS=$(curl -sS -o /dev/null -w '%{http_code}' -H \"Authorization: Bearer $BEARER\" \"http://127.0.0.1:${PORT}/list_peers\"); [ \"$STATUS\" = \"200\" ]"
- name: "network_egress_https"
description: "Generic HTTPS egress works. T4 is unconstrained network; the canonical test target is the Molecule-owned Gitea middleman over its public name. CI must not depend on GitHub or other mirrors for this probe. Any reachable HTTPS endpoint satisfies it — the YAML carries the recommended targets but accepts any 200/301/302."
severity: hard
source: "task #174 brief"
probe: "for U in $MOLECULE_T4_EGRESS_TARGETS; do C=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 8 \"$U\"); case \"$C\" in 2*|3*) exit 0;; esac; done; exit 1"
required_egress:
- "https://git.moleculesai.app/api/v1/version"
- name: "pid_host_visible"
description: "Host PID namespace is shared (--pid=host). The container can see host process 1 (systemd or pid-1 on the EC2 instance). Required for nsenter into host mount/pid namespaces."
severity: hard
source: "provisioner.go applyHostConfig T4 branch (case 4): hostCfg.PidMode = 'host'"
probe: "[ \"$(sudo -n nsenter --target 1 --mount --pid -- id -u)\" = \"0\" ]"
- name: "privileged_flag_observable"
description: "Container is started with --privileged. Observable from inside via /proc/self/status CapEff containing CAP_SYS_ADMIN. Defense-in-depth for the provisioner emission side."
severity: advisory
source: "provisioner.go applyHostConfig T4 branch (case 4)"
probe: "grep -q '^CapEff:.*ffffffffff' /proc/self/status"
@@ -120,8 +120,8 @@ func T4PrivilegeContract() []T4Capability {
},
{
Name: "docker_socket_reachable",
Description: "/var/run/docker.sock is bind-mounted into the container so the agent can manage other containers (T4 use case: agent-as-orchestrator). Proven by 'docker version' returning a server section, which requires the daemon to answer over the socket.",
Probe: `sudo -n docker version --format '{{.Server.Version}}' >/dev/null 2>&1`,
Description: "/var/run/docker.sock is bind-mounted and host Docker is reachable from the T4 container. The probe enters the host mount+PID namespaces before running docker info so it validates the same host-control path production agents use, instead of depending on the template image's Docker CLI/socket group details.",
Probe: `sudo -n nsenter --target 1 --mount --pid -- docker info >/dev/null 2>&1`,
Severity: SeverityHard,
Source: "provisioner.go applyHostConfig T4 branch (case 4)",
},
@@ -145,7 +145,7 @@ func T4PrivilegeContract() []T4Capability {
},
{
Name: "network_egress_https",
Description: "Generic HTTPS egress works. T4 is unconstrained network; the canonical test target is the Gitea instance over its public name, which any fork user can also resolve. Any reachable HTTPS endpoint satisfies it — the YAML carries the recommended targets but accepts any 200/301/302.",
Description: "Generic HTTPS egress works. T4 is unconstrained network; the canonical test target is the Molecule-owned Gitea middleman over its public name. CI must not depend on GitHub or other mirrors for this probe. Any reachable HTTPS endpoint satisfies it — the YAML carries the recommended targets but accepts any 200/301/302.",
Probe: `for U in $MOLECULE_T4_EGRESS_TARGETS; do ` +
` C=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 8 "$U"); ` +
` case "$C" in 2*|3*) exit 0;; esac; ` +
@@ -153,10 +153,9 @@ func T4PrivilegeContract() []T4Capability {
Severity: SeverityHard,
Source: "task #174 brief",
RequiredEgress: []string{
// Public, no auth, returns a small JSON.
// Molecule-owned, public, no auth, returns a small JSON.
// Adopters override via MOLECULE_T4_EGRESS_TARGETS.
"https://api.github.com/zen",
"https://www.google.com/generate_204",
"https://git.moleculesai.app/api/v1/version",
},
},
{
@@ -169,7 +168,7 @@ func T4PrivilegeContract() []T4Capability {
{
Name: "pid_host_visible",
Description: "Host PID namespace is shared (--pid=host). The container can see host process 1 (systemd or pid-1 on the EC2 instance). Required for nsenter into host mount/pid namespaces.",
Probe: `[ -d /proc/1/root ] && [ "$(sudo -n readlink /proc/1/ns/pid)" = "$(sudo -n readlink /proc/self/ns/pid)" ]`,
Probe: `[ "$(sudo -n nsenter --target 1 --mount --pid -- id -u)" = "0" ]`,
Severity: SeverityHard,
Source: "provisioner.go applyHostConfig T4 branch (case 4): hostCfg.PidMode = 'host'",
},
@@ -1,6 +1,7 @@
package provisioner
import (
"os"
"strings"
"testing"
)
@@ -77,6 +78,19 @@ func TestT4PrivilegeContract_CoreCapabilitiesPresent(t *testing.T) {
}
}
func TestT4PrivilegeContract_DefaultEgressUsesMoleculeOwnedEndpoint(t *testing.T) {
for _, c := range T4PrivilegeContract() {
for _, target := range c.RequiredEgress {
if strings.Contains(target, "github.com") {
t.Errorf("capability %q default egress target must not depend on GitHub mirror/API: %s", c.Name, target)
}
if strings.Contains(target, "google.com") {
t.Errorf("capability %q default egress target must not depend on external Google endpoint: %s", c.Name, target)
}
}
}
}
// TestT4PrivilegeContract_HardCapabilitiesMajority sanity-checks that
// the contract is not silently advisory-only. If someone marks
// everything as "advisory" the gate becomes a no-op without anyone
@@ -142,6 +156,17 @@ func TestAsYAML_EscapesEmbeddedQuotes(t *testing.T) {
}
}
func TestGeneratedT4CapabilitiesYAMLMatchesSSOT(t *testing.T) {
got, err := os.ReadFile("t4_capabilities.yaml")
if err != nil {
t.Fatalf("read generated t4_capabilities.yaml: %v", err)
}
want := AsYAML(T4PrivilegeContract())
if string(got) != want {
t.Fatal("generated t4_capabilities.yaml drifted from T4PrivilegeContract; regenerate with `go run ./cmd/t4-contract-dump > internal/provisioner/t4_capabilities.yaml`")
}
}
// TestAgentUIDConsistency ties the contract to the existing
// provisioner-side AgentUID const. The probe for "agent_uid_1000"
// hard-codes `id -u == 1000`; if AgentUID ever changes (no one
@@ -178,6 +178,13 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
// the tenant AWS credentials. Admin-gated because console output
// can include user-data snippets we treat as semi-sensitive.
wsAdmin.GET("/workspaces/:id/console", wh.Console)
// Display sessions will eventually return short-lived proxied DCV
// URLs, so keep the endpoint admin-gated from the first unavailable
// state rather than widening it later.
wsAdmin.GET("/workspaces/:id/display", wh.Display)
wsAdmin.GET("/workspaces/:id/display/control", wh.DisplayControl)
wsAdmin.POST("/workspaces/:id/display/control/acquire", wh.AcquireDisplayControl)
wsAdmin.POST("/workspaces/:id/display/control/release", wh.ReleaseDisplayControl)
// Admin memory backup/restore (#1051) — bulk export/import of agent
// memories for safe Docker rebuilds. Matches workspaces by name on import.
@@ -397,6 +404,8 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
wsAuth.GET("/tokens", tokh.List)
wsAuth.POST("/tokens", tokh.Create)
wsAuth.DELETE("/tokens/:tokenId", tokh.Revoke)
adminTokH := handlers.NewAdminWorkspaceTokenHandler()
r.POST("/admin/workspaces/:id/tokens", middleware.AdminAuth(db.DB), adminTokH.Create)
// Memory
memh := handlers.NewMemoryHandler()
@@ -0,0 +1,61 @@
package router
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/handlers"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/middleware"
"github.com/gin-gonic/gin"
)
func buildWorkspaceDisplayEngine(t *testing.T) *gin.Engine {
t.Helper()
gin.SetMode(gin.TestMode)
r := gin.New()
wh := handlers.NewWorkspaceHandler(nil, nil, "http://localhost:8080", t.TempDir())
r.GET("/workspaces/:id/display", middleware.AdminAuth(db.DB), wh.Display)
r.POST("/workspaces/:id/display/control/acquire", middleware.AdminAuth(db.DB), wh.AcquireDisplayControl)
return r
}
func TestWorkspaceDisplayRoute_RequiresAdminAuth(t *testing.T) {
t.Setenv("ADMIN_TOKEN", "test-admin-secret-not-presented-by-caller")
mock := setupRouterTestDB(t)
mock.ExpectQuery("SELECT COUNT.*FROM workspace_auth_tokens").
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
r := buildWorkspaceDisplayEngine(t)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/workspaces/ws-display/display", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401 for unauthenticated request, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock unmet: %v", err)
}
}
func TestWorkspaceDisplayControlRoute_RequiresAdminAuth(t *testing.T) {
t.Setenv("ADMIN_TOKEN", "test-admin-secret-not-presented-by-caller")
mock := setupRouterTestDB(t)
mock.ExpectQuery("SELECT COUNT.*FROM workspace_auth_tokens").
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
r := buildWorkspaceDisplayEngine(t)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/workspaces/ws-display/display/control/acquire", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401 for unauthenticated request, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock unmet: %v", err)
}
}
@@ -425,6 +425,7 @@ func (s *Scheduler) fireSchedule(ctx context.Context, sched scheduleRow) {
lastStatus := "ok"
lastError := ""
resultKind := ""
if proxyErr != nil {
lastStatus = "error"
lastError = fmt.Sprintf("%v", proxyErr)
@@ -433,8 +434,26 @@ func (s *Scheduler) fireSchedule(ctx context.Context, sched scheduleRow) {
lastStatus = "error"
lastError = fmt.Sprintf("HTTP %d", statusCode)
log.Printf("Scheduler: '%s' non-2xx: %d", sched.Name, statusCode)
} else if a2aErr := a2aErrorFromBody(respBody); a2aErr != "" {
lastStatus = "error"
lastError = fmt.Sprintf("A2A adapter error: %s", a2aErr)
log.Printf("Scheduler: '%s' A2A adapter error (HTTP %d): %s", sched.Name, statusCode, a2aErr)
} else {
log.Printf("Scheduler: '%s' completed (HTTP %d)", sched.Name, statusCode)
// HTTP 200 — inspect response body for SDK-layer errors.
// The claude-code-sdk adapter returns HTTP 200 even when the inner
// LLM call throws (e.g. Max-plan rate-limit, quota exhaustion, SDK
// internal errors). Without this check those failures surface as
// "completed (HTTP 200)" in last_status while the agent chat shows
// errors — a silent failure that hides schedule outages.
// See: #1696.
resultKind = detectResultKind(respBody)
if resultKind != "" && resultKind != "ok" {
lastStatus = resultKind
lastError = fmt.Sprintf("SDK error: result_kind=%s", resultKind)
log.Printf("Scheduler: '%s' SDK error detected — result_kind=%s", sched.Name, resultKind)
} else {
log.Printf("Scheduler: '%s' completed (HTTP %d)", sched.Name, statusCode)
}
}
// #795: detect phantom-producing schedules — cron fires successfully
@@ -479,6 +498,54 @@ func (s *Scheduler) fireSchedule(ctx context.Context, sched scheduleRow) {
resetCancel()
}
// #1696: track consecutive SDK errors. When the adapter returns HTTP 200
// but the response body signals a non-ok result_kind (rate_limited,
// sdk_error, quota_exhausted), we increment a counter. After 3 consecutive
// SDK errors we auto-disable the schedule and log it — the schedule is
// suffering a persistent LLM-layer failure and firing it again will keep
// producing the same errors while burning tokens.
//
// Only apply when the current lastStatus is a non-ok resultKind (not when
// we already have 'error' from proxyErr or non-2xx HTTP status — those have
// their own failure semantics). Also skip when lastStatus is 'stale' (the
// empty-response escalation path takes priority).
var consecSDK int
if resultKind != "" && resultKind != "ok" {
sdkCtx, sdkCancel := context.WithTimeout(context.Background(), dbQueryTimeout)
if err := db.DB.QueryRowContext(sdkCtx, `
UPDATE workspace_schedules
SET consecutive_sdk_errors = consecutive_sdk_errors + 1,
updated_at = now()
WHERE id = $1
RETURNING consecutive_sdk_errors`, sched.ID).Scan(&consecSDK); err != nil {
log.Printf("Scheduler: '%s' SDK-error bump failed: %v", sched.Name, err)
}
sdkCancel()
if consecSDK >= 3 {
log.Printf("Scheduler: '%s' AUTO-DISABLING after %d consecutive SDK errors (workspace %s)",
sched.Name, consecSDK, short(sched.WorkspaceID, 12))
autoDisableCtx, autoDisableCancel := context.WithTimeout(context.Background(), dbQueryTimeout)
_, _ = db.DB.ExecContext(autoDisableCtx, `
UPDATE workspace_schedules SET enabled = false, updated_at = now() WHERE id = $1 AND enabled = true`,
sched.ID)
autoDisableCancel()
}
} else {
// Non-SDK-error run — reset the counter.
// Guard: only reset when lastStatus is a clean ok (not 'stale', not
// 'error', not resultKind). An 'ok' resultKind means the SDK is fine
// and we should clear the streak.
if lastStatus == "ok" {
resetCtx, resetCancel := context.WithTimeout(context.Background(), dbQueryTimeout)
_, _ = db.DB.ExecContext(resetCtx, `
UPDATE workspace_schedules
SET consecutive_sdk_errors = 0,
updated_at = now()
WHERE id = $1`, sched.ID)
resetCancel()
}
}
nextRun, nextErr := ComputeNextRun(sched.CronExpr, sched.Timezone, time.Now())
var nextRunPtr *time.Time
if nextErr == nil {
@@ -759,6 +826,73 @@ func (s *Scheduler) sweepPhantomBusy(ctx context.Context) {
}
}
// detectResultKind inspects an A2A response body for SDK-layer error signals
// that are invisible at the HTTP level. The claude-code-sdk adapter returns
// HTTP 200 even when the inner LLM call throws (Max-plan rate-limit, quota
// exhaustion, SDK internal errors) — the error surfaces only in the response
// body under result.kind or result.result_kind.
//
// Returns an empty string when the response is clean (result_kind is "ok" or
// absent). Returns the result_kind value when it is a non-ok signal, so callers
// can propagate it as the schedule's last_status.
//
// Known non-ok kinds:
// - "rate_limited" — LLM API rate-limit hit (Max-plan, etc.)
// - "quota_exhausted" — quota / budget exhausted
// - "sdk_error" — SDK threw an internal error
//
// See #1696.
func detectResultKind(body []byte) string {
if len(body) == 0 {
return ""
}
var top map[string]json.RawMessage
if err := json.Unmarshal(body, &top); err != nil {
return ""
}
// Check result.kind first (canonical JSON-RPC shape).
if rawResult, ok := top["result"]; ok {
var result map[string]json.RawMessage
if err := json.Unmarshal(rawResult, &result); err == nil {
// result.kind (canonical JSON-RPC error envelope field).
if rawKind, ok := result["kind"]; ok {
var k string
if json.Unmarshal(rawKind, &k) == nil && k != "" && k != "ok" {
return k
}
}
// result.result_kind (legacy / alternative field name).
if rawKind, ok := result["result_kind"]; ok {
var k string
if json.Unmarshal(rawKind, &k) == nil && k != "" && k != "ok" {
return k
}
}
}
}
// Top-level error: non-ok HTTP 200 with a structured error in the body.
if rawErr, ok := top["error"]; ok {
var errMsg string
if err := json.Unmarshal(rawErr, &errMsg); err == nil && errMsg != "" {
// Distinguish SDK errors from other errors. SDK-layer errors from the
// Claude Code runtime include specific markers.
lower := strings.ToLower(errMsg)
// Check more specific patterns first (max-plan quota > general rate).
if strings.Contains(lower, "max-plan") || strings.Contains(lower, "quota") || strings.Contains(lower, "budget") {
return "quota_exhausted"
}
if strings.Contains(lower, "rate limit") || strings.Contains(lower, "rate_limit") {
return "rate_limited"
}
if strings.Contains(lower, "claude code returned an error") || strings.Contains(lower, "sdk error") ||
strings.Contains(lower, "api key") || strings.Contains(lower, "authentication") {
return "sdk_error"
}
}
}
return ""
}
// isEmptyResponse checks if an A2A response body indicates the agent
// produced no meaningful output. Catches "(no response generated)" from
// the workspace runtime + genuinely empty/null responses. Used by the
@@ -808,6 +942,32 @@ func isEmptyResponse(body []byte) bool {
return false
}
// a2aErrorFromBody extracts an A2A/JSON-RPC error message from a 2xx
// response body. The adapter SDK may return HTTP 200 with an error
// payload when it throws internally; this prevents the scheduler from
// falsely recording last_status='ok'.
// Issue #1696.
func a2aErrorFromBody(body []byte) string {
if len(body) == 0 {
return ""
}
var resp map[string]interface{}
if json.Unmarshal(body, &resp) != nil {
return ""
}
// JSON-RPC style: {"error":{"code":-32603,"message":"..."}}
if errObj, ok := resp["error"].(map[string]interface{}); ok {
if msg, ok := errObj["message"].(string); ok {
return msg
}
}
// Plain style: {"error":"..."}
if errStr, ok := resp["error"].(string); ok {
return errStr
}
return ""
}
// truncation moved to internal/textutil.TruncateBytes (#2962 SSOT).
// The original #2026 fix lives in textutil's package docs as canonical
// prior art. Ellipsis was previously "..." (3 ASCII bytes); the SSOT
@@ -3,6 +3,7 @@ package scheduler
import (
"context"
"database/sql"
"encoding/json"
"testing"
"time"
"unicode/utf8"
@@ -256,6 +257,58 @@ func (p *successProxy) ProxyA2ARequest(
return 200, []byte(`{"ok":true}`), nil
}
// ── adapterErrorProxy ─────────────────────────────────────────────────────────
// adapterErrorProxy is a test double whose ProxyA2ARequest returns HTTP 200
// with a JSON-RPC error body, simulating an adapter SDK that throws internally
// but still completes the HTTP round-trip. Issue #1696.
type adapterErrorProxy struct{}
func (p *adapterErrorProxy) ProxyA2ARequest(
_ context.Context, _ string, _ []byte, _ string, _ bool,
) (int, []byte, error) {
return 200, []byte(`{"jsonrpc":"2.0","id":"cron-test-123","error":{"code":-32603,"message":"adapter SDK internal error"}}`), nil
}
// ── TestFireSchedule_AdapterSDKError (#1696) ──────────────────────────────────
//
// When the adapter SDK throws internally and returns HTTP 200 with an error
// payload, fireSchedule must record last_status='error', not 'ok'.
func TestFireSchedule_AdapterSDKError(t *testing.T) {
mock := setupTestDB(t)
sched := scheduleRow{
ID: "55555555-dead-beef-0000-000000000005",
WorkspaceID: "66666666-dead-beef-0000-000000000006",
Name: "adapter-err-job",
CronExpr: "0 * * * *",
Timezone: "UTC",
Prompt: "do something",
}
// active_tasks check → 0 (workspace is idle; proceed to fire)
mock.ExpectQuery(`SELECT COALESCE`).
WillReturnRows(sqlmock.NewRows([]string{"coalesce"}).AddRow(0))
// Post-fire UPDATE must record last_status='error' with the adapter error message.
mock.ExpectExec(`UPDATE workspace_schedules`).
WithArgs(sched.ID, sqlmock.AnyArg(), "error", "A2A adapter error: adapter SDK internal error").
WillReturnResult(sqlmock.NewResult(0, 1))
// activity_logs INSERT must carry status='error' and the error detail.
mock.ExpectExec(`INSERT INTO activity_logs`).
WithArgs(sched.WorkspaceID, sqlmock.AnyArg(), sqlmock.AnyArg(), "error", "A2A adapter error: adapter SDK internal error").
WillReturnResult(sqlmock.NewResult(0, 1))
s := New(&adapterErrorProxy{}, nil)
s.fireSchedule(context.Background(), sched)
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet DB expectations — adapter error not recorded correctly: %v", err)
}
}
// ── TestFireSchedule_ComputeNextRunError (#722 Bug 1) ─────────────────────────
//
// When ComputeNextRun fails (bad cron expression), fireSchedule must NOT write
@@ -285,6 +338,12 @@ func TestFireSchedule_ComputeNextRunError(t *testing.T) {
WithArgs(sched.ID).
WillReturnResult(sqlmock.NewResult(0, 1))
// #1696 consecutive_sdk_errors reset — successProxy has no result_kind,
// so detectResultKind returns "" and lastStatus="ok" → reset.
mock.ExpectExec(`UPDATE workspace_schedules`).
WithArgs(sched.ID).
WillReturnResult(sqlmock.NewResult(0, 1))
// UPDATE must fire — COALESCE($2, next_run_at) keeps existing value when $2 is nil.
// AnyArg for $2 because it will be nil (ComputeNextRun failed).
mock.ExpectExec(`UPDATE workspace_schedules`).
@@ -540,7 +599,14 @@ func TestFireSchedule_NormalSuccess_AdvancesNextRunAt(t *testing.T) {
WithArgs(sched.ID).
WillReturnResult(sqlmock.NewResult(0, 1))
// 3. Normal UPDATE after successful proxy call.
// 3. #1696 consecutive_sdk_errors reset — successProxy response has no
// result_kind in the body, so detectResultKind returns "" and lastStatus
// is "ok" → we hit the SDK-error counter reset branch.
mock.ExpectExec(`UPDATE workspace_schedules`).
WithArgs(sched.ID).
WillReturnResult(sqlmock.NewResult(0, 1))
// 4. Normal UPDATE after successful proxy call.
// Args: $1=sched.ID, $2=nextRunPtr (computed time), $3=lastStatus, $4=lastError
mock.ExpectExec(`UPDATE workspace_schedules`).
WithArgs(sched.ID, sqlmock.AnyArg(), "ok", "").
@@ -603,6 +669,281 @@ func TestRecordSkipped_AdvancesNextRunAt(t *testing.T) {
}
// trigger CI
// ── TestDetectResultKind ───────────────────────────────────────────────────────
// TestDetectResultKind covers the SDK error detection path: HTTP 200 responses
// with non-ok result_kind in the body must be recognised and returned as the
// kind string, not silently treated as ok.
func TestDetectResultKind(t *testing.T) {
// The test exercises detectResultKind directly so we don't need a full
// fireSchedule mock for this unit-test level.
tests := []struct {
name string
body string
wantKind string
}{
{
name: "clean ok response — empty body",
body: `{}`,
wantKind: "",
},
{
name: "clean ok response — result.kind absent",
body: `{"result":{"parts":[{"text":"hello"}]}}`,
wantKind: "",
},
{
name: "clean ok response — result.kind=ok",
body: `{"result":{"kind":"ok","parts":[{"text":"hello"}]}}`,
wantKind: "",
},
{
name: "clean ok response — result.result_kind=ok",
body: `{"result":{"result_kind":"ok","parts":[{"text":"hello"}]}}`,
wantKind: "",
},
{
name: "SDK error — result.kind=rate_limited",
body: `{"result":{"kind":"rate_limited","parts":[{"text":"error"}]}}`,
wantKind: "rate_limited",
},
{
name: "SDK error — result.kind=quota_exhausted",
body: `{"result":{"kind":"quota_exhausted"}}`,
wantKind: "quota_exhausted",
},
{
name: "SDK error — result.kind=sdk_error",
body: `{"result":{"kind":"sdk_error"}}`,
wantKind: "sdk_error",
},
{
name: "SDK error — result.result_kind=rate_limited",
body: `{"result":{"result_kind":"rate_limited"}}`,
wantKind: "rate_limited",
},
{
name: "SDK error — error string with rate limit",
body: `{"result":{"parts":[]},"error":"An error occurred: rate limit exceeded"}`,
wantKind: "rate_limited",
},
{
name: "SDK error — error string with max-plan",
body: `{"error":"Max-plan rate limit reached"}`,
wantKind: "quota_exhausted",
},
{
name: "SDK error — error string with quota",
body: `{"error":"quota exhausted for model"}`,
wantKind: "quota_exhausted",
},
{
name: "SDK error — error string with sdk error",
body: `{"error":"Claude Code returned an error result: success"}`,
wantKind: "sdk_error",
},
{
name: "SDK error — error string with api key",
body: `{"error":"invalid API key"}`,
wantKind: "sdk_error",
},
{
name: "unknown error string — not an SDK error",
body: `{"error":"something went wrong"}`,
wantKind: "",
},
{
name: "empty response body",
body: ``,
wantKind: "",
},
{
name: "malformed JSON",
body: `not valid json`,
wantKind: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := detectResultKind([]byte(tc.body))
if got != tc.wantKind {
t.Errorf("detectResultKind(%q) = %q, want %q", tc.body, got, tc.wantKind)
}
})
}
}
// ── TestFireSchedule_SDKError_RateLimited (#1696) ───────────────────────────────
//
// When ProxyA2ARequest returns HTTP 200 but the response body contains a
// non-ok result_kind, fireSchedule must:
// 1. Set last_status to the result_kind (not 'ok').
// 2. Set last_error to describe the SDK error.
// 3. Increment consecutive_sdk_errors.
// 4. NOT auto-disable on first occurrence (threshold is 3).
//
// This test uses an sdkErrorProxy that returns a rate-limited body and asserts
// the first run is recorded as 'rate_limited' with consecutive_sdk_errors=1
// and enabled=true.
func TestFireSchedule_SDKError_RateLimited(t *testing.T) {
mock := setupTestDB(t)
sched := scheduleRow{
ID: "sdk1-test-sched-0001",
WorkspaceID: "sdk1-test-workspace1",
Name: "rate-limited-job",
CronExpr: "0 * * * *",
Timezone: "UTC",
Prompt: "do work",
}
// 1. active_tasks check → workspace idle
mock.ExpectQuery(`SELECT COALESCE`).
WillReturnRows(sqlmock.NewRows([]string{"coalesce"}).AddRow(0))
// 2. #1696 consecutive_sdk_errors bump — RETURNING gives us count=1.
// Use ExpectQuery (not Exec) because QueryRowContext + RETURNING
// produces a result set consumed via .Scan().
mock.ExpectQuery(`UPDATE workspace_schedules`).
WithArgs(sched.ID).
WillReturnRows(sqlmock.NewRows([]string{"consecutive_sdk_errors"}).AddRow(1))
// 3. Post-fire UPDATE — last_status='rate_limited', last_error='SDK error: result_kind=rate_limited'
mock.ExpectExec(`UPDATE workspace_schedules`).
WithArgs(sched.ID, sqlmock.AnyArg(), "rate_limited", sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
// 4. activity_logs INSERT
mock.ExpectExec(`INSERT INTO activity_logs`).
WithArgs(sched.WorkspaceID, sqlmock.AnyArg(), sqlmock.AnyArg(), "rate_limited", sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
s := New(&sdkErrorProxy{kind: "rate_limited"}, nil)
s.fireSchedule(context.Background(), sched)
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet DB expectations for SDK-error first run: %v", err)
}
}
// ── TestFireSchedule_SDKError_AutoDisableOnThirdConsecutive (#1696) ───────────
//
// On the 3rd consecutive SDK error, fireSchedule must auto-disable the
// schedule (enabled=false) in addition to recording the error status.
// Threshold is 3 per #1696 requirement.
func TestFireSchedule_SDKError_AutoDisableOnThirdConsecutive(t *testing.T) {
mock := setupTestDB(t)
sched := scheduleRow{
ID: "sdk2-test-sched-0002",
WorkspaceID: "sdk2-test-workspace2",
Name: "auto-disable-job",
CronExpr: "0 * * * *",
Timezone: "UTC",
Prompt: "do work",
}
// 1. active_tasks check → workspace idle
mock.ExpectQuery(`SELECT COALESCE`).
WillReturnRows(sqlmock.NewRows([]string{"coalesce"}).AddRow(0))
// 2. #1696 consecutive_sdk_errors bump — RETURNING gives count=3 (threshold met).
// Use ExpectQuery (not Exec) because QueryRowContext + RETURNING
// produces a result set consumed via .Scan().
mock.ExpectQuery(`UPDATE workspace_schedules`).
WithArgs(sched.ID).
WillReturnRows(sqlmock.NewRows([]string{"consecutive_sdk_errors"}).AddRow(3))
// 3. Auto-disable UPDATE — sets enabled=false (schedule has hit 3rd SDK error)
mock.ExpectExec(`UPDATE workspace_schedules SET enabled`).
WithArgs(sched.ID).
WillReturnResult(sqlmock.NewResult(0, 1))
// 4. Post-fire UPDATE
mock.ExpectExec(`UPDATE workspace_schedules`).
WithArgs(sched.ID, sqlmock.AnyArg(), "rate_limited", sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
// 5. activity_logs INSERT
mock.ExpectExec(`INSERT INTO activity_logs`).
WithArgs(sched.WorkspaceID, sqlmock.AnyArg(), sqlmock.AnyArg(), "rate_limited", sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
s := New(&sdkErrorProxy{kind: "rate_limited"}, nil)
s.fireSchedule(context.Background(), sched)
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet DB expectations for SDK-error auto-disable: %v", err)
}
}
// ── TestFireSchedule_SDKError_CounterResetOnCleanRun (#1696) ──────────────────
//
// A clean HTTP-200 run (no SDK error) must reset consecutive_sdk_errors to 0.
// This prevents false auto-disable after intermittent SDK blips.
func TestFireSchedule_SDKError_CounterResetOnCleanRun(t *testing.T) {
mock := setupTestDB(t)
sched := scheduleRow{
ID: "sdk3-test-sched-0003",
WorkspaceID: "sdk3-test-workspace3",
Name: "clean-reset-job",
CronExpr: "30 * * * *",
Timezone: "UTC",
Prompt: "do work",
}
// 1. active_tasks check → workspace idle
mock.ExpectQuery(`SELECT COALESCE`).
WillReturnRows(sqlmock.NewRows([]string{"coalesce"}).AddRow(0))
// 2. No SDK error — #1696 counter is reset to 0
// (lastStatus is 'ok', resultKind is empty, so we go to the reset branch)
mock.ExpectExec(`UPDATE workspace_schedules`).
WithArgs(sched.ID).
WillReturnResult(sqlmock.NewResult(0, 1))
// 3. Post-fire UPDATE
mock.ExpectExec(`UPDATE workspace_schedules`).
WithArgs(sched.ID, sqlmock.AnyArg(), "ok", "").
WillReturnResult(sqlmock.NewResult(0, 1))
// 4. activity_logs INSERT
mock.ExpectExec(`INSERT INTO activity_logs`).
WithArgs(sched.WorkspaceID, sqlmock.AnyArg(), sqlmock.AnyArg(), "ok", "").
WillReturnResult(sqlmock.NewResult(0, 1))
s := New(&successProxy{}, nil)
s.fireSchedule(context.Background(), sched)
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet DB expectations for SDK-error counter reset: %v", err)
}
}
// ── sdkErrorProxy ──────────────────────────────────────────────────────────────
//
// sdkErrorProxy is a test double whose ProxyA2ARequest returns HTTP 200 but
// embeds a non-ok result_kind in the response body, simulating a Claude Code
// SDK that returned 200 but the inner LLM call threw a rate-limit / quota error.
// Used by TestFireSchedule_SDKError_* to cover #1696 SDK error detection.
type sdkErrorProxy struct {
kind string // result_kind value to embed in the response body
}
func (p *sdkErrorProxy) ProxyA2ARequest(
_ context.Context, _ string, _ []byte, _ string, _ bool,
) (int, []byte, error) {
body, _ := json.Marshal(map[string]interface{}{
"result": map[string]interface{}{
"kind": p.kind,
"parts": []map[string]interface{}{{"kind": "text", "text": "(no response generated)"}},
},
})
return 200, body, nil
}
// ── TestTruncate_utf8Safe_regression2026 ──────────────────────────────────────
// TestTruncate_utf8Safe_regression2026 locks in the #2026 fix: truncate must
@@ -0,0 +1 @@
ALTER TABLE workspaces DROP COLUMN IF EXISTS compute;
@@ -0,0 +1,2 @@
ALTER TABLE workspaces
ADD COLUMN IF NOT EXISTS compute JSONB NOT NULL DEFAULT '{}'::jsonb;
@@ -0,0 +1,4 @@
-- migration: 20260523000000_schedule_consecutive_sdk_errors.down.sql
-- Reverts #1696 fix for #1696 (consecutive_sdk_errors column)
ALTER TABLE workspace_schedules DROP COLUMN IF EXISTS consecutive_sdk_errors;
@@ -0,0 +1,20 @@
-- migration: 20260523000000_schedule_consecutive_sdk_errors.up.sql
-- Fixes #1696: Add consecutive_sdk_errors counter to track SDK errors (HTTP 200
-- responses where the Claude Code runtime returned a non-ok result_kind).
-- When this counter reaches 3, the scheduler sets last_status='rate_limited'
-- and auto-disables the schedule.
--
-- The core issue: the claude-code-sdk adapter returns HTTP 200 even when the
-- inner LLM call throws (e.g. Max-plan rate-limit). All 3 observed runs logged
-- "completed (HTTP 200)" yet surfaced agent errors in the workspace chat.
-- This counter lets us detect that pattern and escalate appropriately.
ALTER TABLE workspace_schedules
ADD COLUMN IF NOT EXISTS consecutive_sdk_errors INTEGER NOT NULL DEFAULT 0;
COMMENT ON COLUMN workspace_schedules.consecutive_sdk_errors IS
'Count of consecutive scheduler fires where ProxyA2ARequest returned HTTP 200
but the response body contained a non-ok result_kind (e.g. rate_limited,
sdk_error, quota_exhausted). Reset to 0 on any non-SDK-error status.
After 3 consecutive SDK errors the schedule is auto-disabled with
status rate_limited. Fixes #1696.';
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_workspace_display_control_locks_expires;
DROP TABLE IF EXISTS workspace_display_control_locks;
@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS workspace_display_control_locks (
workspace_id uuid PRIMARY KEY REFERENCES workspaces(id) ON DELETE CASCADE,
controller text NOT NULL CHECK (controller IN ('user', 'agent')),
controlled_by text NOT NULL CHECK (length(controlled_by) > 0 AND length(controlled_by) <= 200),
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_workspace_display_control_locks_expires
ON workspace_display_control_locks (expires_at);