Compare commits

...

40 Commits

Author SHA1 Message Date
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-fe e358b9b92f ci: fix PR path filter base diff
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 16s
CI / Python Lint & Test (pull_request) Successful in 1m2s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m12s
E2E Chat / detect-changes (pull_request) Successful in 18s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Has been skipped
Harness Replays / detect-changes (pull_request) Successful in 20s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 20s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 22s
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 9s
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 1m50s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m44s
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 5s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m42s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m40s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 2m2s
gate-check-v3 / gate-check (pull_request) Successful in 16s
qa-review / approved (pull_request) Successful in 12s
sop-checklist / review-refire (pull_request) Has been skipped
sop-checklist / na-declarations (pull_request) N/A: (none)
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 2m0s
security-review / approved (pull_request) Successful in 6s
sop-checklist / all-items-acked (pull_request) Successful in 5s
sop-tier-check / tier-check (pull_request) Successful in 6s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m21s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m31s
CI / Platform (Go) (pull_request) Successful in 4s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 3s
CI / Canvas (Next.js) (pull_request) Successful in 23s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 10s
E2E Chat / E2E Chat (pull_request) Successful in 12s
CI / all-required (pull_request) Successful in 7m47s
Harness Replays / Harness Replays (pull_request) Successful in 16s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m55s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 1m48s
audit-force-merge / audit (pull_request) Successful in 10s
2026-05-20 23:12:27 -07: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
core-fe c37caa2ec9 ci: share path filter helper
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 12s
CI / Detect changes (pull_request) Successful in 14s
CI / Python Lint & Test (pull_request) Successful in 4s
E2E API Smoke Test / detect-changes (pull_request) Successful in 15s
E2E Chat / detect-changes (pull_request) Successful in 6s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Successful in 50s
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 7s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 4s
Harness Replays / detect-changes (pull_request) Successful in 3s
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 3s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m12s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 4s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m16s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 4s
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 1m39s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m9s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m10s
gate-check-v3 / gate-check (pull_request) Successful in 6s
sop-checklist / review-refire (pull_request) Has been skipped
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-tier-check / tier-check (pull_request) Successful in 12s
sop-checklist / all-items-acked (pull_request) Successful in 12s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m17s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 1m20s
qa-review / approved (pull_request) Refired via /qa-recheck by hongming
security-review / approved (pull_request) Refired via /security-recheck by hongming
CI / Platform (Go) (pull_request) Successful in 4m26s
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 23m39s
E2E Chat / E2E Chat (pull_request) Successful in 4s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 5s
Harness Replays / Harness Replays (pull_request) Successful in 22s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 3m9s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 3m32s
2026-05-20 22:32:17 -07:00
core-fe 6e77083b84 ci: path-scope shellcheck on prs
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 5s
CI / Detect changes (pull_request) Successful in 8s
CI / Python Lint & Test (pull_request) Successful in 3s
E2E API Smoke Test / detect-changes (pull_request) Successful in 7s
E2E Chat / detect-changes (pull_request) Successful in 7s
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 10s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 3s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Successful in 1m18s
Harness Replays / detect-changes (pull_request) Successful in 9s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 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 5s
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 3s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m0s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 2m10s
qa-review / approved (pull_request) Failing after 5s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 7s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m24s
gate-check-v3 / gate-check (pull_request) Successful in 6s
security-review / approved (pull_request) Failing after 7s
sop-checklist / review-refire (pull_request) Has been skipped
sop-checklist / all-items-acked (pull_request) Successful in 9s
sop-tier-check / tier-check (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 1m33s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 1m30s
CI / Canvas (Next.js) (pull_request) Successful in 8s
E2E Chat / E2E Chat (pull_request) Successful in 5s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 18s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 10s
Harness Replays / Harness Replays (pull_request) Successful in 3s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m48s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2m12s
CI / Platform (Go) (pull_request) Successful in 5m8s
CI / all-required (pull_request) Successful in 24m47s
2026-05-20 22:20:02 -07: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-fe 3a3f670662 ci: path-scope canvas on prs
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
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
gate-check-v3 / gate-check (pull_request) Waiting to run
qa-review / approved (pull_request) Waiting to run
security-review / approved (pull_request) Waiting to run
sop-checklist / review-refire (pull_request) Waiting to run
sop-tier-check / tier-check (pull_request) Waiting to run
sop-checklist / all-items-acked (pull_request) acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: comprehensive-testing, local-postgres-e2
sop-checklist / na-declarations (pull_request) N/A: (none)
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 13s
CI / Python Lint & Test (pull_request) Successful in 8s
CI / Detect changes (pull_request) Successful in 8s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 11s
E2E API Smoke Test / detect-changes (pull_request) Successful in 12s
E2E Chat / detect-changes (pull_request) Successful in 10s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 11s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 4s
Handlers Postgres Integration / detect-changes (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 8s
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 5s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m3s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 11s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 1m31s
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 1m29s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m28s
CI / Platform (Go) (pull_request) Successful in 2s
CI / Canvas (Next.js) (pull_request) Successful in 2s
CI / all-required (pull_request) Successful in 15m33s
2026-05-20 22:17:30 -07: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 30a8aa10b8 ci: path-scope platform go on prs
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 5s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 14s
CI / Python Lint & Test (pull_request) Successful in 14s
CI / Detect changes (pull_request) Successful in 17s
E2E Chat / detect-changes (pull_request) Successful in 15s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 15s
E2E API Smoke Test / detect-changes (pull_request) Successful in 17s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 18s
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 6s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 16s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m22s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 59s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 1m27s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 7s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 9s
gate-check-v3 / gate-check (pull_request) Successful in 6s
qa-review / approved (pull_request) Failing after 4s
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 5s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m28s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m2s
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 (Next.js) (pull_request) Successful in 5m28s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 3s
CI / Platform (Go) (pull_request) Successful in 5s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 4s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 7s
E2E Chat / E2E Chat (pull_request) Successful in 7s
CI / all-required (pull_request) Successful in 12m13s
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
hongming 08b3aa8a2c Merge pull request 'fix(core): mint staging peer visibility token fallback' (#1631) from fix/staging-peer-visibility-token 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 / Detect changes (push) Successful in 16s
CI / Python Lint & Test (push) Successful in 10s
CI / Shellcheck (E2E scripts) (push) Successful in 15s
E2E API Smoke Test / detect-changes (push) Successful in 21s
E2E Chat / detect-changes (push) Successful in 20s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 19s
Handlers Postgres Integration / detect-changes (push) Successful in 14s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 13s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 8s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 8s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (push) Successful in 1m26s
E2E Chat / E2E Chat (push) Successful in 9s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 5s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (push) Has been skipped
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 2m6s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m50s
CI / Platform (Go) (push) Successful in 6m56s
publish-workspace-server-image / build-and-push (push) Successful in 7m15s
CI / Canvas (Next.js) (push) Successful in 8m19s
CI / all-required (push) Successful in 8m6s
publish-workspace-server-image / Production auto-deploy (push) Successful in 2m53s
CI / Canvas Deploy Reminder (push) Successful in 1s
SECRET_PATTERNS drift lint / Detect SECRET_PATTERNS drift (push) Successful in 39s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m56s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 4m43s
main-red-watchdog / watchdog (push) Successful in 45s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 6s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 11s
gate-check-v3 / gate-check (push) Successful in 31s
ci-required-drift / drift (push) Successful in 1m3s
2026-05-21 04:52:05 +00:00
core-devops 022cc1136b fix(core): mint staging peer visibility token fallback
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 / Shellcheck (E2E scripts) (pull_request) Successful in 25s
CI / Python Lint & Test (pull_request) Successful in 7s
E2E API Smoke Test / detect-changes (pull_request) Successful in 14s
E2E Chat / detect-changes (pull_request) Successful in 10s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Successful in 1m15s
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 7s
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 4s
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 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 4s
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 5m5s
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 3s
CI / Canvas (Next.js) (pull_request) Successful in 6m8s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 4m40s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m7s
audit-force-merge / audit (pull_request) Successful in 6s
2026-05-20 21:35:42 -07:00
hongming a1cfd085a8 Merge pull request 'chore(runtime): delete core workspace copy' (#1620) from chore/delete-core-workspace-runtime into main
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
publish-canvas-image / Build & push canvas image (push) Successful in 3m7s
Block internal-flavored paths / Block forbidden paths (push) Successful in 21s
CI / Detect changes (push) Successful in 35s
CI / Shellcheck (E2E scripts) (push) Successful in 38s
publish-workspace-server-image / build-and-push (push) Successful in 6m38s
CI / Python Lint & Test (push) Successful in 35s
E2E Chat / detect-changes (push) Successful in 13s
E2E API Smoke Test / detect-changes (push) Successful in 13s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 16s
Handlers Postgres Integration / detect-changes (push) Successful in 9s
Harness Replays / detect-changes (push) Successful in 7s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (push) Successful in 1m8s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (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 8s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (push) Successful in 6s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 1m21s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 6s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (push) Failing after 2m13s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 1m37s
CI / Platform (Go) (push) Successful in 4m44s
Ops Scripts Tests / Ops scripts (unittest) (push) Successful in 1m25s
CI / Canvas (Next.js) (push) Successful in 5m49s
CI / all-required (push) Successful in 5m23s
publish-workspace-server-image / Production auto-deploy (push) Successful in 7m6s
E2E Chat / E2E Chat (push) Successful in 4m8s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m36s
Harness Replays / Harness Replays (push) Successful in 23s
CI / Canvas Deploy Reminder (push) Successful in 4s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 1m59s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m18s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 5m0s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 4s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 5s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 7m41s
2026-05-21 04:23:33 +00:00
hongming e97eb95d9d Merge pull request 'ci: keep browser e2e out of normal pr path' (#1629) from fix/split-heavy-e2e-required-path into main
CI / Canvas Deploy Reminder (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 9s
CI / Detect changes (push) Successful in 16s
publish-workspace-server-image / build-and-push (push) Failing after 14s
publish-workspace-server-image / Production auto-deploy (push) Has been skipped
CI / Shellcheck (E2E scripts) (push) Successful in 20s
E2E API Smoke Test / detect-changes (push) Successful in 23s
E2E Chat / detect-changes (push) Successful in 18s
Handlers Postgres Integration / detect-changes (push) Successful in 8s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 11s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 6s
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 4s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (push) Successful in 5s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 1m25s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 9s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 6s
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 1m42s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 1m48s
CI / Platform (Go) (push) Successful in 5m0s
CI / Canvas (Next.js) (push) Successful in 6m9s
ci-required-drift / drift (push) Successful in 1m28s
CI / Python Lint & Test (push) Successful in 6m45s
CI / all-required (push) Successful in 6m57s
E2E Chat / E2E Chat (push) Failing after 5m26s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Has been cancelled
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 2m6s
2026-05-21 04:15:09 +00:00
core-fe 16b203fff1 ci: keep browser e2e out of normal pr path
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
CI / Detect changes (pull_request) Successful in 12s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 13s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 10s
E2E API Smoke Test / detect-changes (pull_request) Successful in 12s
E2E Chat / detect-changes (pull_request) Successful in 9s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 10s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 7s
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 6s
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 1m4s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 10s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
gate-check-v3 / gate-check (pull_request) Successful in 5s
qa-review / approved (pull_request) Failing after 5s
sop-checklist / na-declarations (pull_request) N/A: (none)
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 34s
security-review / approved (pull_request) Failing after 6s
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-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m24s
CI / Platform (Go) (pull_request) Successful in 3m6s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m22s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 5s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 3s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 12s
E2E Chat / E2E Chat (pull_request) Successful in 8s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 6s
CI / Canvas (Next.js) (pull_request) Successful in 5m14s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Python Lint & Test (pull_request) Successful in 6m40s
CI / all-required (pull_request) Successful in 6m43s
audit-force-merge / audit (pull_request) Successful in 7s
2026-05-20 21:02:33 -07:00
hongming c58ffd2828 Merge pull request 'ci: reduce scheduled runner load and prep prebaked browsers' (#1628) from fix/ci-cron-bots-prebake-1357 into main
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
publish-workspace-server-image / build-and-push (push) Failing after 15s
publish-workspace-server-image / Production auto-deploy (push) Has been skipped
Block internal-flavored paths / Block forbidden paths (push) Successful in 5s
CI / Detect changes (push) Successful in 7s
CI / Shellcheck (E2E scripts) (push) Successful in 26s
E2E API Smoke Test / detect-changes (push) Successful in 13s
E2E Chat / detect-changes (push) Successful in 10s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 11s
Handlers Postgres Integration / detect-changes (push) Successful in 7s
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 5s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 1m29s
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 4s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 10s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 3s
CI / Platform (Go) (push) Successful in 4m35s
E2E API Smoke Test / E2E API Smoke Test (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 1m17s
CI / Canvas (Next.js) (push) Successful in 6m12s
CI / Python Lint & Test (push) Successful in 7m7s
CI / all-required (push) Successful in 6m44s
E2E Chat / E2E Chat (push) Failing after 5m39s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 1m29s
CI / Canvas Deploy Reminder (push) Successful in 5s
lint-bp-context-emit-match / lint-bp-context-emit-match (push) Successful in 1m24s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 2m23s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 15s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 10m27s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 4s
MCP Stdio Transport Regression / MCP stdio with regular-file stdout (push) Successful in 46s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m33s
main-red-watchdog / watchdog (push) Successful in 29s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 6m31s
gate-check-v3 / gate-check (push) Successful in 1m9s
2026-05-21 03:17:11 +00:00
core-fe a9bc5e39d5 ci: reduce scheduled runner load and prep prebaked browsers
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 16s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 15s
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 8s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 7s
CI / Platform (Go) (pull_request) Successful in 4m30s
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 3s
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 1m37s
CI / Canvas (Next.js) (pull_request) Successful in 5m49s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 1m32s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 6s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m11s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 10s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m19s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
qa-review / approved (pull_request) Failing after 5s
gate-check-v3 / gate-check (pull_request) Successful in 5s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 3s
sop-checklist / review-refire (pull_request) Has been skipped
security-review / approved (pull_request) Failing after 4s
sop-tier-check / tier-check (pull_request) Successful in 3s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m3s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m20s
CI / Python Lint & Test (pull_request) Successful in 6m53s
CI / all-required (pull_request) Successful in 6m58s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 3s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 2s
E2E Chat / E2E Chat (pull_request) Failing after 6m12s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 9m31s
audit-force-merge / audit (pull_request) Successful in 6s
2026-05-20 17:36:09 -07:00
core-be 2ee97c097d fix(self-deleg): 3-layer defense including SQL self-filter (closes #383)
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 12s
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 7s
CI / Shellcheck (E2E scripts) (push) Failing after 17s
CI / all-required (push) Failing after 2s
E2E API Smoke Test / detect-changes (push) Successful in 6s
E2E Chat / detect-changes (push) Successful in 6s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 7s
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 4s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 8s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 6s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 8s
CI / Platform (Go) (push) Successful in 4m55s
CI / Canvas (Next.js) (push) Successful in 6m13s
CI / Python Lint & Test (push) Successful in 6m43s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 3s
Harness Replays / Harness Replays (push) Successful in 17s
E2E API Smoke Test / E2E API Smoke Test (push) Failing after 2m20s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m31s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 2m43s
E2E Chat / E2E Chat (push) Failing after 7m5s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 5s
main-red-watchdog / watchdog (push) Successful in 31s
gate-check-v3 / gate-check (push) Successful in 22s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m34s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 4s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 13s
gitea-merge-queue / queue (push) Successful in 13s
status-reaper / reap (push) Successful in 1m11s
ci-required-drift / drift (push) Successful in 1m15s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 5m46s
2 APPROVES (core-security id=5165, core-devops id=5167). CI/all-required green. Resolves chloe-dong 小董文婷 self-delegation 400 loop.
Co-authored-by: core-be <core-be@agents.moleculesai.app>
Co-committed-by: core-be <core-be@agents.moleculesai.app>
2026-05-21 00:12:38 +00:00
core-be ee9dc5b9c5 fix(rfc523): scope forbidden-env check to global_secrets (allow user-set workspace_secrets)
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 7s
CI / Detect changes (push) Successful in 14s
CI / Shellcheck (E2E scripts) (push) Successful in 16s
E2E API Smoke Test / detect-changes (push) Successful in 7s
CI / Platform (Go) (push) Successful in 4m59s
E2E Chat / detect-changes (push) Successful in 10s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 12s
E2E Staging SaaS (full lifecycle) / pr-validate (push) Successful in 32s
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
Runtime PR-Built Compatibility / detect-changes (push) Successful in 6s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 4s
CI / Canvas (Next.js) (push) Successful in 6m16s
CI / Python Lint & Test (push) Successful in 6m55s
CI / all-required (push) Successful in 5m42s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 4s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 9s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Failing after 7m52s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 14s
Harness Replays / Harness Replays (push) Successful in 5s
CI / Canvas Deploy Reminder (push) Successful in 7s
E2E API Smoke Test / E2E API Smoke Test (push) Failing after 1m46s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m9s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 2m38s
E2E Chat / E2E Chat (push) Failing after 6m36s
main-red-watchdog / watchdog (push) Successful in 44s
gate-check-v3 / gate-check (push) Successful in 23s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Has started running
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 5m14s
Reviews APPROVED (core-qa,core-devops). CI/all-required green. Non-required E2E + security-review checks noted in PR body; not gating per BP.
Co-authored-by: core-be <core-be@agents.moleculesai.app>
Co-committed-by: core-be <core-be@agents.moleculesai.app>
2026-05-20 23:40:54 +00:00
core-fe 5455ddefe2 feat(canvas): surface current org name/slug/UUID in Settings panel
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
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
publish-canvas-image / Build & push canvas image (push) Failing after 7s
publish-workspace-server-image / build-and-push (push) Successful in 5m3s
Block internal-flavored paths / Block forbidden paths (push) Successful in 17s
CI / Detect changes (push) Successful in 18s
CI / Shellcheck (E2E scripts) (push) Successful in 36s
E2E API Smoke Test / detect-changes (push) Successful in 14s
E2E Chat / detect-changes (push) Successful in 11s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 11s
Handlers Postgres Integration / detect-changes (push) Successful in 6s
Harness Replays / detect-changes (push) Successful in 4s
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 6s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 10s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 8s
CI / Platform (Go) (push) Has been cancelled
CI / Python Lint & Test (push) Has been cancelled
CI / Canvas (Next.js) (push) Has been cancelled
publish-workspace-server-image / Production auto-deploy (push) Has been cancelled
CI / all-required (push) Has been cancelled
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m21s
Reviews APPROVED (core-qa,core-devops). CI/all-required green. Non-required E2E + security-review checks noted in PR body; not gating per BP.
Co-authored-by: Molecule AI · core-fe <core-fe@agents.moleculesai.app>
Co-committed-by: Molecule AI · core-fe <core-fe@agents.moleculesai.app>
2026-05-20 23:27:58 +00:00
core-fe 80d517b8ab fix(canvas): external/MCP workspace progress UX — surface poll-mode queued state (task #227)
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
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
publish-canvas-image / Build & push canvas image (push) Failing after 5s
publish-workspace-server-image / build-and-push (push) Successful in 5m53s
Block internal-flavored paths / Block forbidden paths (push) Successful in 3s
CI / Detect changes (push) Successful in 7s
CI / Shellcheck (E2E scripts) (push) Successful in 31s
CI / Platform (Go) (push) Successful in 4m29s
E2E API Smoke Test / detect-changes (push) Successful in 6s
E2E Chat / detect-changes (push) Successful in 6s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 6s
Handlers Postgres Integration / detect-changes (push) Successful in 8s
Harness Replays / detect-changes (push) Successful in 5s
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 4s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 6s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 5s
CI / Canvas (Next.js) (push) Successful in 5m51s
CI / Python Lint & Test (push) Successful in 6m59s
CI / all-required (push) Successful in 6m50s
publish-workspace-server-image / Production auto-deploy (push) Successful in 13m35s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m47s
gate-check-v3 / gate-check (push) Successful in 28s
main-red-watchdog / watchdog (push) Successful in 37s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 8s
ci-required-drift / drift (push) Successful in 1m6s
Reviews APPROVED (core-qa,core-devops). CI/all-required green. Non-required E2E + security-review checks noted in PR body; not gating per BP.
Co-authored-by: Molecule AI · core-fe <core-fe@agents.moleculesai.app>
Co-committed-by: Molecule AI · core-fe <core-fe@agents.moleculesai.app>
2026-05-20 22:59:38 +00:00
core-be dbbd351c70 fix(workspace-server): debounce file-write → RestartByID tight loop (#624)
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Waiting to run
CI / Canvas (Next.js) (push) Waiting to run
CI / Shellcheck (E2E scripts) (push) Waiting to run
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 / E2E Chat (push) Blocked by required conditions
E2E Chat / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
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
Runtime PR-Built Compatibility / detect-changes (push) Waiting to run
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
publish-workspace-server-image / build-and-push (push) Failing after 18s
publish-workspace-server-image / Production auto-deploy (push) Has been skipped
E2E Staging External Runtime / E2E Staging External Runtime (push) Successful in 5m31s
Reviews APPROVED (core-qa,core-devops). CI/all-required green. Non-required E2E + security-review checks noted in PR body; not gating per BP.
Co-authored-by: core-be <core-be@agents.moleculesai.app>
Co-committed-by: core-be <core-be@agents.moleculesai.app>
2026-05-20 22:59:05 +00:00
core-fe 55fa44571e fix(canvas): polite tasks/cancel before /workspaces/:id/restart for Stop All (task #377 companion)
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
CI / all-required (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Waiting to run
CI / Canvas (Next.js) (push) Waiting to run
CI / Shellcheck (E2E scripts) (push) Waiting to run
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) Waiting to run
E2E API Smoke Test / detect-changes (push) Waiting to run
E2E API Smoke Test / E2E API Smoke Test (push) Blocked by required conditions
E2E Chat / detect-changes (push) Waiting to run
E2E Chat / E2E Chat (push) Blocked by required conditions
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
Handlers Postgres Integration / detect-changes (push) Waiting to run
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
Harness Replays / detect-changes (push) Waiting to run
Harness Replays / Harness Replays (push) Blocked by required conditions
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Waiting to run
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Runtime PR-Built Compatibility / detect-changes (push) Waiting to run
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
publish-canvas-image / Build & push canvas image (push) Failing after 6s
publish-workspace-server-image / build-and-push (push) Has been cancelled
Reviews APPROVED (core-qa,core-devops). CI/all-required green. Non-required E2E + security-review checks noted in PR body; not gating per BP.
Co-authored-by: Molecule AI · core-fe <core-fe@agents.moleculesai.app>
Co-committed-by: Molecule AI · core-fe <core-fe@agents.moleculesai.app>
2026-05-20 22:58:57 +00:00
core-fe 676f9a033b fix(canvas/chat): A2A hints point at Activity tab (closeout internal#212)
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Waiting to run
CI / Canvas (Next.js) (push) Waiting to run
CI / Shellcheck (E2E scripts) (push) Waiting to run
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) Waiting to run
CI / all-required (push) Waiting to run
E2E API Smoke Test / detect-changes (push) Waiting to run
E2E API Smoke Test / E2E API Smoke Test (push) Blocked by required conditions
E2E Chat / detect-changes (push) Waiting to run
E2E Chat / E2E Chat (push) Blocked by required conditions
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
Handlers Postgres Integration / detect-changes (push) Waiting to run
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
Harness Replays / detect-changes (push) Waiting to run
Harness Replays / Harness Replays (push) Blocked by required conditions
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Waiting to run
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Runtime PR-Built Compatibility / detect-changes (push) Waiting to run
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
publish-canvas-image / Build & push canvas image (push) Failing after 7s
publish-workspace-server-image / build-and-push (push) Has been cancelled
Reviews APPROVED (core-qa,core-devops). CI/all-required green. Non-required E2E + security-review checks noted in PR body; not gating per BP.
Co-authored-by: Molecule AI · core-fe <core-fe@agents.moleculesai.app>
Co-committed-by: Molecule AI · core-fe <core-fe@agents.moleculesai.app>
2026-05-20 22:58:51 +00:00
63 changed files with 3633 additions and 394 deletions
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""Shared path-filter helper for Gitea Actions workflows.
Computes changed files against the PR base SHA or push-before SHA and writes
boolean outputs to GITHUB_OUTPUT. If the diff base is missing or untrusted, the
helper fails open by setting every output in the selected profile to true.
"""
from __future__ import annotations
import argparse
import os
import re
import subprocess
import sys
from pathlib import Path
PROFILES: dict[str, dict[str, str]] = {
"ci": {
"platform": r"^workspace-server/",
"canvas": r"^canvas/",
"python": r"^workspace/",
"scripts": r"^tests/e2e/|^scripts/|^infra/scripts/",
},
"handlers-postgres": {
"handlers": (
r"^workspace-server/internal/handlers/"
r"|^workspace-server/internal/wsauth/"
r"|^workspace-server/migrations/"
r"|^\.gitea/workflows/handlers-postgres-integration\.yml$"
),
},
"e2e-api": {
"api": r"^workspace-server/|^tests/e2e/|^\.gitea/workflows/e2e-api\.yml$",
},
}
def classify(profile: str, paths: list[str]) -> dict[str, bool]:
patterns = PROFILES[profile]
return {
name: any(re.search(pattern, path) for path in paths)
for name, pattern in patterns.items()
}
def all_true(profile: str) -> dict[str, bool]:
return {name: True for name in PROFILES[profile]}
def resolve_base(event_name: str, pr_base_sha: str, push_before: str) -> str:
if event_name == "pull_request" and pr_base_sha:
return pr_base_sha
return push_before
def is_zero_sha(value: str) -> bool:
return not value or bool(re.fullmatch(r"0+", value))
def run_git(args: list[str], *, timeout: int = 30) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", *args],
check=False,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=timeout,
)
def base_exists(base: str) -> bool:
return run_git(["cat-file", "-e", base]).returncode == 0
def fetch_base(base: str, base_ref: str) -> None:
# Gitea may reject fetching an arbitrary unadvertised SHA from a shallow
# PR checkout. Fetch the advertised base branch first, then fall back to
# the SHA for hosts that allow it.
if base_ref:
run_git(["fetch", "--depth=1", "origin", base_ref])
if not base_exists(base):
run_git(["fetch", "--depth=1", "origin", base])
def deepen_base_ref(base_ref: str) -> None:
if base_ref:
run_git(["fetch", "--deepen=200", "origin", base_ref], timeout=60)
def merge_base(base: str) -> str | None:
proc = run_git(["merge-base", base, "HEAD"])
if proc.returncode != 0:
return None
value = proc.stdout.strip()
return value or None
def changed_paths(base: str, *, use_merge_base: bool) -> list[str] | None:
compare_base = base
if use_merge_base:
compare_base = merge_base(base) or ""
if not compare_base:
return None
proc = run_git(["diff", "--name-only", compare_base, "HEAD"])
if proc.returncode != 0:
return None
return [line for line in proc.stdout.splitlines() if line]
def write_outputs(values: dict[str, bool], output_path: str | None) -> None:
lines = [f"{name}={'true' if value else 'false'}" for name, value in values.items()]
if output_path:
with Path(output_path).open("a", encoding="utf-8") as fh:
for line in lines:
fh.write(line + "\n")
else:
for line in lines:
print(line)
def detect(
profile: str,
event_name: str,
pr_base_sha: str,
push_before: str,
base_ref: str = "",
) -> dict[str, bool]:
base = resolve_base(event_name, pr_base_sha, push_before)
if is_zero_sha(base):
return all_true(profile)
if not base_exists(base):
fetch_base(base, base_ref)
if not base_exists(base):
return all_true(profile)
use_merge_base = event_name == "pull_request"
if use_merge_base and base_ref and merge_base(base) is None:
deepen_base_ref(base_ref)
paths = changed_paths(base, use_merge_base=use_merge_base)
if paths is None:
return all_true(profile)
return classify(profile, paths)
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--profile", required=True, choices=sorted(PROFILES))
parser.add_argument("--event-name", default=os.environ.get("GITHUB_EVENT_NAME", ""))
parser.add_argument("--pr-base-sha", default="")
parser.add_argument("--base-ref", default="")
parser.add_argument("--push-before", default=os.environ.get("GITHUB_EVENT_BEFORE", ""))
return parser.parse_args(argv)
def main(argv: list[str]) -> int:
args = parse_args(argv)
values = detect(
args.profile,
args.event_name,
args.pr_base_sha,
args.push_before,
args.base_ref,
)
write_outputs(values, os.environ.get("GITHUB_OUTPUT"))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+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}: "
+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]
+43 -69
View File
@@ -86,46 +86,17 @@ jobs:
with:
fetch-depth: 0
- id: check
env:
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PR_BASE_REF: ${{ github.event.pull_request.base.ref }}
PUSH_BEFORE: ${{ github.event.before }}
run: |
# For PR events: diff against the base branch (not HEAD~1 of the branch,
# which may be unrelated after force-pushes). When a push updates a PR,
# both pull_request and push events fire — prefer the PR base so that
# the diff is always computed against the actual merge base, not the
# previous SHA on the branch which may be on a different history line.
BASE="${GITHUB_BASE_REF:-${{ github.event.before }}}"
# GITHUB_BASE_REF is set for PR events (the base branch name).
# For pull_request events we use the stored base.sha; for push events
# (or when base.sha is unavailable) fall back to github.event.before.
if [ "${{ github.event_name }}" = "pull_request" ] && [ -n "${{ github.event.pull_request.base.sha }}" ]; then
BASE="${{ github.event.pull_request.base.sha }}"
fi
# Fallback: if BASE is empty or all zeros (new branch), run everything
if [ -z "$BASE" ] || echo "$BASE" | grep -qE '^0+$'; then
echo "platform=true" >> "$GITHUB_OUTPUT"
echo "canvas=true" >> "$GITHUB_OUTPUT"
echo "python=true" >> "$GITHUB_OUTPUT"
echo "scripts=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Workflow-only edits are covered by the workflow lint family
# and by this workflow's always-present required jobs. Do not fan
# those edits out into Go/Canvas/Python/shellcheck work; the
# downstream jobs still emit their required contexts via no-op
# steps when their surface flag is false.
#
# If the diff itself cannot be trusted, fail open by running every
# surface instead of silently under-testing the PR.
if ! DIFF=$(git diff --name-only "$BASE" HEAD 2>/dev/null); then
echo "platform=true" >> "$GITHUB_OUTPUT"
echo "canvas=true" >> "$GITHUB_OUTPUT"
echo "python=true" >> "$GITHUB_OUTPUT"
echo "scripts=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "platform=$(echo "$DIFF" | grep -qE '^workspace-server/' && echo true || echo false)" >> "$GITHUB_OUTPUT"
echo "canvas=$(echo "$DIFF" | grep -qE '^canvas/' && echo true || echo false)" >> "$GITHUB_OUTPUT"
echo "python=$(echo "$DIFF" | grep -qE '^workspace/' && echo true || echo false)" >> "$GITHUB_OUTPUT"
echo "scripts=$(echo "$DIFF" | grep -qE '^tests/e2e/|^scripts/|^infra/scripts/' && echo true || echo false)" >> "$GITHUB_OUTPUT"
python3 .gitea/scripts/detect-changes.py \
--profile ci \
--event-name "${{ github.event_name }}" \
--pr-base-sha "$PR_BASE_SHA" \
--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
@@ -133,6 +104,7 @@ jobs:
# the name match works on PRs that don't touch workspace-server/).
platform-build:
name: Platform (Go)
needs: changes
runs-on: ubuntu-latest
# mc#774 (closed 2026-05-14): Phase 4 flip of the platform-build job.
# Phase 4 (#656) originally flipped this to continue-on-error: false based on
@@ -153,29 +125,29 @@ jobs:
run:
working-directory: workspace-server
steps:
- if: false
- if: ${{ github.event_name == 'pull_request' && needs.changes.outputs.platform != 'true' }}
working-directory: .
run: echo "No platform/** changes — skipping real build steps; this job always runs to satisfy the required-check name on branch protection."
- if: always()
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' }}
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- if: always()
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }}
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: 'stable'
- if: always()
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }}
run: go mod download
- if: always()
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }}
run: go build ./cmd/server
# CLI (molecli) moved to standalone repo: git.moleculesai.app/molecule-ai/molecule-cli
- if: always()
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }}
run: go vet ./...
- if: always()
- if: ${{ github.event_name != 'pull_request' || 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: always()
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }}
name: Run golangci-lint
run: $(go env GOPATH)/bin/golangci-lint run --timeout 3m ./...
- if: always()
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }}
name: Diagnostic — per-package verbose 60s
run: |
set +e
@@ -191,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: always()
- if: ${{ github.event_name != 'pull_request' || 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
@@ -199,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: always()
- if: ${{ github.event_name != 'pull_request' || 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
@@ -213,7 +185,7 @@ jobs:
END {for (f in s) printf "%6.1f%% %s\n", s[f]/c[f], f}' \
| sort -n
- if: always()
- if: ${{ github.event_name != 'pull_request' || 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).
@@ -301,6 +273,7 @@ jobs:
# siblings — verified empirically on PR #2314).
canvas-build:
name: Canvas (Next.js)
needs: changes
runs-on: ubuntu-latest
timeout-minutes: 20
# Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12.
@@ -309,20 +282,20 @@ jobs:
run:
working-directory: canvas
steps:
- if: false
- if: ${{ github.event_name == 'pull_request' && needs.changes.outputs.canvas != 'true' }}
working-directory: .
run: echo "No canvas/** changes — skipping real build steps; this job always runs to satisfy the required-check name on branch protection."
- if: always()
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' }}
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- if: always()
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.canvas == 'true' }}
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '22'
- if: always()
run: rm -f package-lock.json && npm install
- if: always()
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.canvas == 'true' }}
run: npm ci --include=optional --prefer-offline
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.canvas == 'true' }}
run: npm run build
- if: always()
- if: ${{ github.event_name != 'pull_request' || 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
@@ -331,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: always()
if: ${{ github.event_name != 'pull_request' || 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
@@ -348,15 +321,16 @@ jobs:
# Shellcheck (E2E scripts) — required check, always runs.
shellcheck:
name: Shellcheck (E2E scripts)
needs: changes
runs-on: ubuntu-latest
# Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12.
continue-on-error: false
steps:
- if: false
run: echo "No tests/e2e/ or infra/scripts/ changes — skipping real shellcheck; this job always runs to satisfy the required-check name on branch protection."
- if: always()
- 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' }}
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- if: always()
- if: ${{ github.event_name != 'pull_request' || 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
@@ -367,16 +341,16 @@ jobs:
find tests/e2e infra/scripts -type f -name '*.sh' -print0 \
| xargs -0 shellcheck --severity=warning
- if: always()
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.scripts == 'true' }}
name: Lint cleanup-trap hygiene (RFC #2873)
run: bash tests/e2e/lint_cleanup_traps.sh
- if: always()
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.scripts == 'true' }}
name: Run E2E bash unit tests (no live infra)
run: |
bash tests/e2e/test_model_slug.sh
- if: always()
- if: ${{ github.event_name != 'pull_request' || 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
@@ -386,7 +360,7 @@ jobs:
run: |
bash scripts/test-promote-tenant-image.sh
- if: always()
- if: ${{ github.event_name != 'pull_request' || 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
+13 -1
View File
@@ -43,6 +43,18 @@ name: Continuous synthetic E2E (staging)
on:
schedule:
# Every 30 minutes, on :02 and :32. This keeps a recurring SaaS
# behavior probe while cutting runner occupancy from this workflow by
# roughly two thirds; fast liveness belongs in the lighter smoke/heartbeat
# probes, not in a full tenant/workspace synth every 10 minutes.
#
# Previous cadence was every 10 minutes (:02 :12 :22 :32 :42 :52).
# The current operator-host runner pool is the bottleneck, so full
# synth E2E is deliberately lower-cadence until it moves to a dedicated
# runner host or warm-runtime pool.
#
# Historical notes from the 10-minute shape:
#
# Every 10 minutes, on :02 :12 :22 :32 :42 :52. Three constraints:
# 1. Stay off the top-of-hour. GitHub Actions scheduler drops
# :00 firings under high load (own docs:
@@ -66,7 +78,7 @@ on:
# fires = ~30 min cadence; closer to the 20-min target than the
# current shape and provides a real degradation alarm if drops
# get worse.
- cron: '2,12,22,32,42,52 * * * *'
- cron: '2,32 * * * *'
permissions:
contents: read
# No issue-write here — failures surface as red runs in the workflow
+6 -24
View File
@@ -132,31 +132,13 @@ jobs:
with:
fetch-depth: 0
- id: decide
# Inline replacement for dorny/paths-filter — same pattern PR#372's
# ci.yml port used. Diffs against the PR base or push BEFORE SHA,
# then matches against the api-relevant path set.
run: |
BASE="${GITHUB_BASE_REF:-${{ github.event.before }}}"
if [ "${{ github.event_name }}" = "pull_request" ] && [ -n "${{ github.event.pull_request.base.sha }}" ]; then
BASE="${{ github.event.pull_request.base.sha }}"
fi
if [ -z "$BASE" ] || echo "$BASE" | grep -qE '^0+$'; then
echo "api=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if ! git cat-file -e "$BASE" 2>/dev/null; then
git fetch --depth=1 origin "$BASE" 2>/dev/null || true
fi
if ! git cat-file -e "$BASE" 2>/dev/null; then
echo "api=true" >> "$GITHUB_OUTPUT"
exit 0
fi
CHANGED=$(git diff --name-only "$BASE" HEAD)
if echo "$CHANGED" | grep -qE '^(workspace-server/|tests/e2e/|\.gitea/workflows/e2e-api\.yml$)'; then
echo "api=true" >> "$GITHUB_OUTPUT"
else
echo "api=false" >> "$GITHUB_OUTPUT"
fi
python3 .gitea/scripts/detect-changes.py \
--profile e2e-api \
--event-name "${{ github.event_name }}" \
--pr-base-sha "${{ github.event.pull_request.base.sha }}" \
--base-ref "${{ github.event.pull_request.base.ref }}" \
--push-before "${GITHUB_EVENT_BEFORE:-${{ github.event.before }}}"
# ONE job (no job-level `if:`) that always runs and reports under the
# required-check name `E2E API Smoke Test`. Real work is gated per-step
+42 -4
View File
@@ -1,8 +1,10 @@
name: E2E Chat
# Comprehensive Playwright E2E for the unified chat stack (desktop
# ChatTab + mobile MobileChat). Runs on every PR that touches canvas,
# workspace-server, or this workflow file.
# ChatTab + mobile MobileChat). Heavy browser execution is intentionally
# outside the normal required PR path: PRs run it only after entering the
# `merge-queue`, while push/main, nightly, and manual dispatch preserve
# coverage without making every PR pay the full runtime/browser cost.
#
# Architecture:
# 1. Ephemeral Postgres + Redis (docker, unique container names)
@@ -22,6 +24,11 @@ on:
branches: [main, staging]
pull_request:
branches: [main, staging]
schedule:
# Nightly at 09:00 UTC. Keeps coverage for the currently non-required
# heavy browser lane without spending runner time on every PR.
- cron: '0 9 * * *'
workflow_dispatch:
concurrency:
group: e2e-chat-${{ github.event.pull_request.head.sha || github.sha }}
@@ -50,7 +57,14 @@ jobs:
with:
fetch-depth: 0
- id: decide
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
QUEUE_LABEL: merge-queue
run: |
if [ "${{ github.event_name }}" = "schedule" ] || [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "chat=true" >> "$GITHUB_OUTPUT"
exit 0
fi
BASE="${GITHUB_BASE_REF:-${{ github.event.before }}}"
if [ "${{ github.event_name }}" = "pull_request" ] && [ -n "${{ github.event.pull_request.base.sha }}" ]; then
BASE="${{ github.event.pull_request.base.sha }}"
@@ -67,9 +81,26 @@ jobs:
exit 0
fi
CHANGED=$(git diff --name-only "$BASE" HEAD)
if echo "$CHANGED" | grep -qE '^(canvas/|workspace-server/|\.gitea/workflows/e2e-chat\.yml$)'; then
if ! echo "$CHANGED" | grep -qE '^(canvas/|workspace-server/|\.gitea/workflows/e2e-chat\.yml$)'; then
echo "chat=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ "${{ github.event_name }}" != "pull_request" ]; then
echo "chat=true" >> "$GITHUB_OUTPUT"
exit 0
fi
authfile=$(mktemp)
chmod 600 "$authfile"
printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" > "$authfile"
labels=$(curl -fsS -K "$authfile" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/labels" \
| python3 -c 'import json,sys; print("\n".join(label.get("name","") for label in json.load(sys.stdin)))')
rm -f "$authfile"
if printf '%s\n' "$labels" | grep -qx "$QUEUE_LABEL"; then
echo "chat=true" >> "$GITHUB_OUTPUT"
else
echo "PR is not in merge-queue; skipping heavy E2E Chat for normal PR path."
echo "chat=false" >> "$GITHUB_OUTPUT"
fi
@@ -230,7 +261,14 @@ jobs:
- name: Install Playwright browsers
if: needs.detect-changes.outputs.chat == 'true'
working-directory: canvas
run: npx playwright install --with-deps chromium
run: |
PREBAKED_PLAYWRIGHT=/ms-playwright
if [ -d "${PREBAKED_PLAYWRIGHT}" ] && find "${PREBAKED_PLAYWRIGHT}" -maxdepth 3 -type f -name 'chrome' | grep -q .; then
echo "Using prebaked Playwright Chromium from ${PREBAKED_PLAYWRIGHT}"
echo "PLAYWRIGHT_BROWSERS_PATH=${PREBAKED_PLAYWRIGHT}" >> "$GITHUB_ENV"
exit 0
fi
npx playwright install --with-deps chromium
- name: Start canvas dev server (background)
if: needs.detect-changes.outputs.chat == 'true'
+2
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).
+37 -9
View File
@@ -16,9 +16,9 @@ name: E2E Staging Canvas (Playwright)
# e2e-staging-saas.yml (which tests the API shape) by exercising the
# actual browser + canvas bundle against live staging.
#
# Triggers: push to main/staging or PR touching canvas sources + this workflow,
# manual dispatch, and weekly cron to catch browser/runtime drift even
# when canvas is quiet.
# Triggers: push to main, PR touching canvas sources + this workflow only
# after the PR enters `merge-queue`, manual dispatch, and scheduled cron to
# catch browser/runtime drift even when canvas is quiet.
# Added staging to push/pull_request branches so the auto-promote gate
# check (--event push --branch staging) can see a completed run for this
# workflow — mirrors what PR #1891 does for e2e-api.yml.
@@ -37,9 +37,10 @@ on:
pull_request:
branches: [main]
schedule:
# Weekly on Sunday 08:00 UTC — catches Chrome / Playwright / Next.js
# Nightly at 08:00 UTC — catches Chrome / Playwright / Next.js
# release-note-shaped regressions that don't ride in with a PR.
- cron: '0 8 * * 0'
- cron: '0 8 * * *'
workflow_dispatch:
concurrency:
# Per-SHA grouping (changed 2026-04-28 from a single global group). The
@@ -79,10 +80,13 @@ jobs:
with:
fetch-depth: 0
- id: decide
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
QUEUE_LABEL: merge-queue
# Inline replacement for dorny/paths-filter — see e2e-api.yml.
# Cron triggers always run real work (no diff context).
# Cron and manual triggers always run real work (no diff context).
run: |
if [ "${{ github.event_name }}" = "schedule" ]; then
if [ "${{ github.event_name }}" = "schedule" ] || [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "canvas=true" >> "$GITHUB_OUTPUT"
exit 0
fi
@@ -102,9 +106,26 @@ jobs:
exit 0
fi
CHANGED=$(git diff --name-only "$BASE" HEAD)
if echo "$CHANGED" | grep -qE '^(canvas/|\.gitea/workflows/e2e-staging-canvas\.yml$)'; then
if ! echo "$CHANGED" | grep -qE '^(canvas/|\.gitea/workflows/e2e-staging-canvas\.yml$)'; then
echo "canvas=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ "${{ github.event_name }}" != "pull_request" ]; then
echo "canvas=true" >> "$GITHUB_OUTPUT"
exit 0
fi
authfile=$(mktemp)
chmod 600 "$authfile"
printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" > "$authfile"
labels=$(curl -fsS -K "$authfile" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/labels" \
| python3 -c 'import json,sys; print("\n".join(label.get("name","") for label in json.load(sys.stdin)))')
rm -f "$authfile"
if printf '%s\n' "$labels" | grep -qx "$QUEUE_LABEL"; then
echo "canvas=true" >> "$GITHUB_OUTPUT"
else
echo "PR is not in merge-queue; skipping heavy E2E Staging Canvas for normal PR path."
echo "canvas=false" >> "$GITHUB_OUTPUT"
fi
@@ -169,7 +190,14 @@ jobs:
- name: Install Playwright browsers
if: needs.detect-changes.outputs.canvas == 'true'
timeout-minutes: 10
run: npx playwright install --with-deps chromium
run: |
PREBAKED_PLAYWRIGHT=/ms-playwright
if [ -d "${PREBAKED_PLAYWRIGHT}" ] && find "${PREBAKED_PLAYWRIGHT}" -maxdepth 3 -type f -name 'chrome' | grep -q .; then
echo "Using prebaked Playwright Chromium from ${PREBAKED_PLAYWRIGHT}"
echo "PLAYWRIGHT_BROWSERS_PATH=${PREBAKED_PLAYWRIGHT}" >> "$GITHUB_ENV"
exit 0
fi
npx playwright install --with-deps chromium
- name: Run staging canvas E2E
if: needs.detect-changes.outputs.canvas == 'true'
+6 -2
View File
@@ -13,8 +13,12 @@ name: gitea-merge-queue
# - add `merge-queue-hold` to pause a queued PR without removing it
on:
schedule:
- cron: '*/5 * * * *'
# Schedule moved to operator-config:
# /etc/cron.d/molecule-core-merge-queue ->
# /usr/local/bin/molecule-core-cron-bot.sh merge-queue
#
# The queue bot still processes one PR per tick, but no longer occupies
# one of the shared Actions runners just to poll.
workflow_dispatch:
permissions:
@@ -101,36 +101,13 @@ jobs:
# not present in the shallow checkout.
fetch-depth: 2
- id: filter
# Inline replacement for dorny/paths-filter — see e2e-api.yml.
run: |
# Gitea Actions evaluates github.event.before to empty string in shell
# scripts. Use GITHUB_EVENT_BEFORE shell env var instead (Gitea
# correctly populates it for push events). PR case uses template var.
BASE=""
if [ "${{ github.event_name }}" = "pull_request" ] && [ -n "${{ github.event.pull_request.base.sha }}" ]; then
BASE="${{ github.event.pull_request.base.sha }}"
elif [ -n "$GITHUB_EVENT_BEFORE" ]; then
BASE="$GITHUB_EVENT_BEFORE"
fi
if [ -z "$BASE" ] || echo "$BASE" | grep -qE '^0+$'; then
echo "handlers=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# timeout 30 guards against the case where BASE points to a ref that
# git can resolve but cat-file hangs (rare on corrupted objects).
if ! timeout 30 git cat-file -e "$BASE" 2>/dev/null; then
git fetch --depth=1 origin "$BASE" 2>/dev/null || true
fi
if ! timeout 30 git cat-file -e "$BASE" 2>/dev/null; then
echo "handlers=true" >> "$GITHUB_OUTPUT"
exit 0
fi
CHANGED=$(git diff --name-only "$BASE" HEAD)
if echo "$CHANGED" | grep -qE '^(workspace-server/internal/handlers/|workspace-server/internal/wsauth/|workspace-server/migrations/|\.gitea/workflows/handlers-postgres-integration\.yml$)'; then
echo "handlers=true" >> "$GITHUB_OUTPUT"
else
echo "handlers=false" >> "$GITHUB_OUTPUT"
fi
python3 .gitea/scripts/detect-changes.py \
--profile handlers-postgres \
--event-name "${{ github.event_name }}" \
--pr-base-sha "${{ github.event.pull_request.base.sha }}" \
--base-ref "${{ github.event.pull_request.base.ref }}" \
--push-before "${GITHUB_EVENT_BEFORE:-}"
# Single-job-with-per-step-if pattern: always runs to satisfy the
# required-check name on branch protection; real work gates on the
@@ -25,8 +25,11 @@ 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
# Optional secrets: AWS_STAGING_ECR_ACCESS_KEY_ID, AWS_STAGING_ECR_SECRET_ACCESS_KEY
#
# 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 +68,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 +139,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,21 +186,46 @@ jobs:
--push .
# Build + push tenant image (Go platform + Next.js canvas in one image).
# When staging ECR publisher credentials are configured, push the same
# build to the staging account too so fresh staging/E2E tenants can pull
# without cross-account ECR permissions.
- 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 }}
REPO: ${{ github.repository }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_STAGING_ECR_ACCESS_KEY_ID: ${{ secrets.AWS_STAGING_ECR_ACCESS_KEY_ID }}
AWS_STAGING_ECR_SECRET_ACCESS_KEY: ${{ secrets.AWS_STAGING_ECR_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-2
run: |
set -euo pipefail
ECR_REGISTRY="${TENANT_IMAGE_NAME%%/*}"
aws ecr get-login-password --region us-east-2 | \
docker login --username AWS --password-stdin "${ECR_REGISTRY}"
build_tags=(
--tag "${TENANT_IMAGE_NAME}:${TAG_SHA}"
--tag "${TENANT_IMAGE_NAME}:${TAG_LATEST}"
)
if [ -n "${AWS_STAGING_ECR_ACCESS_KEY_ID:-}" ] && [ -n "${AWS_STAGING_ECR_SECRET_ACCESS_KEY:-}" ]; then
STAGING_ECR_REGISTRY="${STAGING_TENANT_IMAGE_NAME%%/*}"
AWS_ACCESS_KEY_ID="${AWS_STAGING_ECR_ACCESS_KEY_ID}" \
AWS_SECRET_ACCESS_KEY="${AWS_STAGING_ECR_SECRET_ACCESS_KEY}" \
aws ecr get-login-password --region us-east-2 | \
docker login --username AWS --password-stdin "${STAGING_ECR_REGISTRY}"
build_tags+=(
--tag "${STAGING_TENANT_IMAGE_NAME}:${TAG_SHA}"
--tag "${STAGING_TENANT_IMAGE_NAME}:${TAG_LATEST}"
)
else
echo "::notice::Skipping staging ECR tenant push; AWS_STAGING_ECR_ACCESS_KEY_ID/AWS_STAGING_ECR_SECRET_ACCESS_KEY are not configured."
fi
docker buildx build \
--file ./workspace-server/Dockerfile.tenant \
--build-arg NEXT_PUBLIC_PLATFORM_URL= \
@@ -193,8 +234,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.
+6 -13
View File
@@ -53,19 +53,12 @@ name: status-reaper
# `inputs:` block here. Gitea 1.22.6 rejects the whole workflow as
# "unknown on type" when `workflow_dispatch.inputs.X` is present.
on:
# SCHEDULE RE-ENABLED 2026-05-12 rev3 — interim disable (mc#645) reverted now that
# rev3 widens DEFAULT_SWEEP_LIMIT 10 → 30 (covers retroactive-failure timing window).
# Sibling watchdog re-enabled in the same PR with timeout-minutes raised 5 → 15.
schedule:
# Every 5 minutes. Off-zero alignment with sibling cron workflows:
# ci-required-drift (`:17`), main-red-watchdog (`:05`),
# railway-pin-audit (`:23`). 5-min cadence gives a tight enough
# close on schedule-triggered false-reds that main-red-watchdog
# (hourly :05) almost never files an issue on the false case.
# rev3 keeps `*/5` unchanged per hongming-pc2 03:25Z review:
# "trades window-width-cheap for cadence-loady" — N=30 widens
# the lookback cheaply without doubling runner load via `*/2`.
- cron: '*/5 * * * *'
# Schedule moved to operator-config:
# /etc/cron.d/molecule-core-status-reaper ->
# /usr/local/bin/molecule-core-cron-bot.sh status-reaper
#
# This keeps the 5-minute compensation cadence but stops a maintenance
# bot from consuming Gitea Actions runner slots during PR merge waves.
workflow_dispatch:
# Compensating-status POST needs write on repo statuses; no other
+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
+91 -2
View File
@@ -68,14 +68,103 @@ export function Toolbar() {
return c;
}, [nodes]);
/**
* Stop All - task #377 fix.
*
* BEFORE this PR: directly POSTed `/workspaces/:id/restart`, which tears
* the container down and back up. That kills in-flight tool subprocesses
* (e.g. `bash -c 'sleep 600'`) but is heavy and discards any in-progress
* agent state. It also bypasses the runtime-side fast cancel path (task
* #377 PR#40 in template-claude-code) - meaning flipping
* `MOLECULE_STOP_PROPAGATE=true` would produce zero canary signal because
* nothing ever invokes `executor.cancel()` in production.
*
* AFTER this PR (two-phase polite cancel):
*
* 1. POST `tasks/cancel` (A2A JSON-RPC) to each active workspace's
* `/workspaces/:id/a2a` proxy. The platform proxies the envelope to
* the workspace runtime; the a2a-sdk framework dispatches `tasks/cancel`
* to `AgentExecutor.cancel()` (a2a-sdk 1.0.3
* `a2a/compat/v0_3/types.py` line 1125 pins the wire literal as
* `Literal["tasks/cancel"]`; A2A protocol spec section 9.4.5 maps the
* abstract `CancelTask` operation to that wire string). The runtime's
* executor cancel path signals the CLI subprocess group with
* SIGTERM/grace/SIGKILL (template-claude-code PR#40 `stop_propagate.py`).
*
* 2. Poll the canvas store (the platform pushes `TASK_UPDATED` over WS
* on `active_tasks` changes - `canvas-events.ts` line 400) for up to
* `STOP_ALL_DRAIN_TIMEOUT_MS`. A workspace whose `activeTasks` drops
* to 0 is considered drained and is NOT restarted.
*
* 3. For any workspace that DID NOT drain inside the timeout - runtime
* is on an old image without the cancel path, or the cancel
* propagation is stuck - fall back to the original heavy
* `/workspaces/:id/restart`. The original behavior is preserved as a
* floor so a stuck workspace still gets stopped; the polite path is
* a fast top-up that lets well-behaved workspaces cancel without
* losing context.
*
* The polite-cancel envelope mirrors `ScheduleTab.handleRunNow` (line 168)
* which is the only other place in canvas that POSTs `/workspaces/:id/a2a`
* directly. Method string `tasks/cancel` and empty `params` match the
* a2a-sdk shape verified above. The proxy adds `jsonrpc:"2.0"` and `id`
* via `normalizeA2APayload` server-side, so the canvas envelope omits them.
*/
const stopAll = useCallback(async () => {
setStopping(true);
const active = nodes.filter((n) => (n.data.activeTasks as number) > 0);
const activeIds = active.map((n) => n.id);
// Phase 1 - polite cancel on every active workspace in parallel.
// Errors are swallowed (same shape as the pre-fix /restart
// Promise.all): a 4xx/5xx on tasks/cancel just means we fall through
// to /restart for that workspace below.
await Promise.all(
active.map((n) =>
api.post(`/workspaces/${n.id}/restart`).catch(() => {})
activeIds.map((id) =>
api
.post(`/workspaces/${id}/a2a`, {
method: "tasks/cancel",
params: {},
})
.catch(() => {})
)
);
// Phase 2 - poll the store for activeTasks reaching 0, with a hard
// timeout. STOP_ALL_DRAIN_TIMEOUT_MS is sized to cover the runtime's
// own SIGTERM-grace (5s in template-claude-code stop_propagate.py
// `_SIGTERM_GRACE_S`) plus a small WS round-trip buffer for the
// TASK_UPDATED push. STOP_ALL_POLL_INTERVAL_MS keeps the poll cheap
// (no animation jitter, no busy-wait).
const STOP_ALL_DRAIN_TIMEOUT_MS = 8000;
const STOP_ALL_POLL_INTERVAL_MS = 250;
const deadline = Date.now() + STOP_ALL_DRAIN_TIMEOUT_MS;
let undrained = new Set(activeIds);
while (undrained.size > 0 && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, STOP_ALL_POLL_INTERVAL_MS));
const fresh = useCanvasStore.getState().nodes;
const stillActive = new Set<string>();
for (const id of undrained) {
const n = fresh.find((x) => x.id === id);
// Missing node (workspace deleted mid-cancel) is treated as
// drained - there's nothing left to restart and reporting it as
// "still running" would be a lie.
if (n && (n.data.activeTasks as number) > 0) stillActive.add(id);
}
undrained = stillActive;
}
// Phase 3 - hard-restart anything that did not drain. This is the
// same call shape as the pre-fix Stop All, so behavior is strictly a
// superset: undrained workspaces still get the heavy stop, drained
// ones are spared.
if (undrained.size > 0) {
await Promise.all(
Array.from(undrained).map((id) =>
api.post(`/workspaces/${id}/restart`).catch(() => {})
)
);
}
setStopping(false);
}, [nodes]);
@@ -131,14 +131,30 @@ const defaultStore = {
batchDelete: vi.fn(() => Promise.resolve()),
};
vi.mock("@/store/canvas", () => ({
useCanvasStore: vi.fn((selector: (s: typeof defaultStore) => unknown) =>
vi.mock("@/store/canvas", () => {
// useCanvasStore is used in two shapes:
// 1. As a hook: `useCanvasStore((s) => s.x)` — selector path.
// 2. As a static accessor: `useCanvasStore.getState().nodes` —
// used by stopAll's drain-poll loop (task #377 Toolbar fix) and
// restartAll's success-clear loop. Both read the LIVE
// defaultStore object so tests that mutate `defaultStore.nodes`
// mid-flight (e.g. simulating a TASK_UPDATED that drops
// activeTasks to 0) see the update on the next poll tick.
const hook = vi.fn((selector: (s: typeof defaultStore) => unknown) =>
selector(defaultStore)
),
}));
) as unknown as ((selector: (s: typeof defaultStore) => unknown) => unknown) & {
getState: () => typeof defaultStore;
};
hook.getState = () => defaultStore;
return { useCanvasStore: hook };
});
// ── Component under test ───────────────────────────────────────────────────────
import { Toolbar } from "../Toolbar";
// Imported AFTER vi.mock("@/lib/api", ...) above (hoisted) so this
// resolves to the mock module; gives the new task #377 tests a typed
// handle on api.post without a CJS require() (Vitest runs ESM).
import { api as mockedApi } from "@/lib/api";
// ── Tests ─────────────────────────────────────────────────────────────────────
@@ -315,3 +331,157 @@ describe("Toolbar — ? shortcut opens shortcuts dialog", () => {
expect(screen.queryByTestId("shortcuts-dialog")).toBeNull();
});
});
// ── Toolbar — Stop All polite-cancel flow (task #377) ───────────────────────
describe("Toolbar — Stop All polite cancel before restart (#377)", () => {
// `api` resolves to the top-level vi.mock factory's mocked `post`.
// We type-cast so TS allows mockReset/mockResolvedValue/mockImplementation
// calls without leaking the mock surface into the production type.
const api = mockedApi as unknown as { post: ReturnType<typeof vi.fn> };
/**
* Build a working set of two active workspaces so the assertions can
* distinguish per-id behavior (drained vs undrained) within one test.
*/
const seedTwoActive = () => {
defaultStore.nodes = toStoreNodes(makeNodes(["online", "online"], [2, 2]));
};
/**
* Drive an async useCallback handler to completion. Vitest's fake
* timers don't see microtasks unless we yield between advances; the
* helper interleaves `vi.advanceTimersByTimeAsync` with macrotask
* yields so pending fetch resolutions and setTimeout callbacks both
* settle before the assertion runs.
*/
const advanceUntilSettled = async (ms: number) => {
await vi.advanceTimersByTimeAsync(ms);
// One extra tick lets any chained .then() after a setTimeout
// resolution fire before the test moves on.
await Promise.resolve();
};
beforeEach(() => {
vi.useFakeTimers();
api.post.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it("phase 1: issues tasks/cancel via /workspaces/:id/a2a BEFORE any /restart", async () => {
seedTwoActive();
// Hold both tasks/cancel responses open so the click handler is
// observably paused at phase 1. We don't actually need to resolve
// them for the order assertion — just inspect the call log.
let resolveCancels!: () => void;
const cancelGate = new Promise<void>((r) => { resolveCancels = r; });
api.post.mockImplementation(async (path: string) => {
if (path.endsWith("/a2a")) {
await cancelGate;
}
return undefined;
});
render(<Toolbar />);
const btn = screen.getByRole("button", { name: /stop all running tasks/i });
fireEvent.click(btn);
// Yield once so the click handler enters phase 1 and dispatches the
// two /a2a POSTs.
await Promise.resolve();
await Promise.resolve();
const a2aCalls = api.post.mock.calls.filter((c) => String(c[0]).endsWith("/a2a"));
const restartCalls = api.post.mock.calls.filter((c) => String(c[0]).endsWith("/restart"));
expect(a2aCalls.length).toBe(2);
expect(restartCalls.length).toBe(0);
// Each /a2a POST carries the canonical tasks/cancel envelope.
for (const call of a2aCalls) {
expect(call[1]).toEqual({ method: "tasks/cancel", params: {} });
}
// Release the gate so the test cleanup doesn't dangle.
resolveCancels();
await advanceUntilSettled(10_000);
});
it("phase 2: when activeTasks drains to 0 during the poll window, /restart is NOT called", async () => {
seedTwoActive();
api.post.mockResolvedValue(undefined);
render(<Toolbar />);
fireEvent.click(screen.getByRole("button", { name: /stop all running tasks/i }));
// Let phase 1 fire (the two tasks/cancel calls).
await Promise.resolve();
await Promise.resolve();
// Simulate the platform pushing TASK_UPDATED with active_tasks=0
// on both workspaces — emulate by mutating the store directly,
// which is what canvas-events.ts does in production.
defaultStore.nodes = toStoreNodes(makeNodes(["online", "online"], [0, 0]));
// Advance past the first poll interval (250ms) so the loop sees
// the drained store and exits early.
await advanceUntilSettled(400);
// Drain any remaining timers so the handler returns cleanly.
await advanceUntilSettled(10_000);
const restartCalls = api.post.mock.calls.filter((c) => String(c[0]).endsWith("/restart"));
expect(restartCalls.length).toBe(0);
});
it("phase 3: when activeTasks does NOT drain inside the timeout, falls through to /restart for each stuck workspace", async () => {
seedTwoActive();
api.post.mockResolvedValue(undefined);
render(<Toolbar />);
fireEvent.click(screen.getByRole("button", { name: /stop all running tasks/i }));
// Phase 1 dispatch.
await Promise.resolve();
await Promise.resolve();
// Do NOT drain — activeTasks stays at 2 for both. Advance past the
// 8000ms drain timeout plus a buffer so phase 3's /restart POSTs fire.
await advanceUntilSettled(9_000);
await advanceUntilSettled(1_000);
const a2aCalls = api.post.mock.calls.filter((c) => String(c[0]).endsWith("/a2a"));
const restartCalls = api.post.mock.calls.filter((c) => String(c[0]).endsWith("/restart"));
expect(a2aCalls.length).toBe(2);
expect(restartCalls.length).toBe(2);
// Order check: every /a2a call comes before every /restart call.
const lastA2AIdx = Math.max(
...api.post.mock.calls.map((c, i) => (String(c[0]).endsWith("/a2a") ? i : -1))
);
const firstRestartIdx = Math.min(
...api.post.mock.calls.map((c, i) => (String(c[0]).endsWith("/restart") ? i : Infinity))
);
expect(lastA2AIdx).toBeLessThan(firstRestartIdx);
});
it("phase 3 selective: drains only one of two workspaces — /restart is called only for the stuck one", async () => {
seedTwoActive();
api.post.mockResolvedValue(undefined);
render(<Toolbar />);
fireEvent.click(screen.getByRole("button", { name: /stop all running tasks/i }));
await Promise.resolve();
await Promise.resolve();
// ws-0 drains immediately, ws-1 stays stuck for the full timeout.
defaultStore.nodes = toStoreNodes(makeNodes(["online", "online"], [0, 2]));
await advanceUntilSettled(9_500);
const restartCalls = api.post.mock.calls.filter((c) => String(c[0]).endsWith("/restart"));
expect(restartCalls.length).toBe(1);
expect(restartCalls[0][0]).toBe("/workspaces/ws-1/restart");
});
});
@@ -0,0 +1,181 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { api } from '@/lib/api';
import { fetchSession, type Session } from '@/lib/auth';
import { getTenantSlug } from '@/lib/tenant';
import { Spinner } from '@/components/Spinner';
/**
* Organization-identity surface inside SettingsPanel.
*
* Closes a chronic UX gap where users (and our own AI agents) had to
* call /cp/auth/me or /cp/orgs from browser devtools to read their
* org_id UUID. Now: a copy-buttoned view of name + slug + UUID for the
* currently-active org, plus a switcher list when the user belongs to
* multiple orgs.
*
* Data path:
* 1. fetchSession() → /cp/auth/me → current org_id
* 2. api.get('/cp/orgs') → list of all orgs the user belongs to
* 3. Match by id === session.org_id; fall back to host-slug match
* if the session probe loses the race.
*
* Read-only — this tab never mutates. Org creation/switching lives at
* /orgs (the post-signup landing page).
*/
interface Org {
id: string;
slug: string;
name: string;
status?: string;
}
// /cp/orgs may return a bare array or {orgs: []} — see orgs/page.tsx
// for the same defensive unwrap.
type OrgsResponse = Org[] | { orgs?: Org[] };
export function OrgInfoTab() {
const [orgs, setOrgs] = useState<Org[] | null>(null);
const [session, setSession] = useState<Session | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const [sess, body] = await Promise.all([
fetchSession().catch(() => null),
api.get<OrgsResponse>('/cp/orgs'),
]);
if (cancelled) return;
setSession(sess);
setOrgs(Array.isArray(body) ? body : body.orgs ?? []);
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load org info');
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
const tenantSlug = getTenantSlug();
const currentOrg =
orgs?.find((o) => session && o.id === session.org_id) ??
orgs?.find((o) => tenantSlug && o.slug === tenantSlug) ??
null;
const otherOrgs = orgs?.filter((o) => o.id !== currentOrg?.id) ?? [];
if (loading) {
return (
<div
role="status"
aria-live="polite"
className="flex items-center justify-center gap-2 py-6 text-ink-mid text-xs"
>
<Spinner /> Loading organization
</div>
);
}
if (error) {
return (
<div className="p-4">
<div className="px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-[10px] text-bad">
{error}
</div>
</div>
);
}
if (!currentOrg) {
return (
<div className="p-4">
<p className="text-xs text-ink-mid">
No organization found for this session. If this is unexpected, sign out and back in, or visit{' '}
<a href="/orgs" className="underline">/orgs</a>.
</p>
</div>
);
}
return (
<div className="p-4 space-y-4">
<div>
<h3 className="text-sm font-semibold text-ink mb-1">Current Organization</h3>
<p className="text-[10px] text-ink-mid leading-relaxed">
IDs you can paste into API calls, support tickets, or CLI arguments. The UUID never changes;
the slug is the URL subdomain.
</p>
</div>
<OrgIdentityCard org={currentOrg} highlighted />
{otherOrgs.length > 0 && (
<div className="space-y-2 pt-2">
<h4 className="text-[11px] font-semibold text-ink-mid uppercase tracking-wider">
Your other organizations ({otherOrgs.length})
</h4>
{otherOrgs.map((o) => (
<OrgIdentityCard key={o.id} org={o} />
))}
</div>
)}
</div>
);
}
function OrgIdentityCard({ org, highlighted }: { org: Org; highlighted?: boolean }) {
return (
<div
className={`rounded-lg border p-3 space-y-2 ${
highlighted ? 'border-accent/40 bg-accent-strong/5' : 'border-line/40 bg-surface-card/40'
}`}
data-testid={`org-card-${org.slug}`}
>
<div className="flex items-baseline justify-between gap-2">
<span className="text-[12px] font-medium text-ink truncate">{org.name}</span>
{org.status && (
<span className="text-[9px] text-ink-mid uppercase tracking-wider shrink-0">{org.status}</span>
)}
</div>
<IdentityRow label="Slug" value={org.slug} />
<IdentityRow label="UUID" value={org.id} mono />
</div>
);
}
function IdentityRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
const [copied, setCopied] = useState(false);
const onCopy = useCallback(() => {
// Best-effort: jsdom + old Safari throw synchronously on writeText.
try {
navigator.clipboard.writeText(value);
} catch {
/* user can still triple-click select */
}
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [value]);
return (
<div className="flex items-center gap-2">
<span className="text-[10px] text-ink-mid w-10 shrink-0">{label}</span>
<code
className={`flex-1 text-[11px] text-ink bg-surface-sunken/60 px-2 py-1 rounded select-all break-all ${
mono ? 'font-mono' : ''
}`}
>
{value}
</code>
<button
type="button"
onClick={onCopy}
aria-label={`Copy ${label}`}
className="shrink-0 px-2 py-1 bg-surface-card/60 hover:bg-surface-card border border-line/40 rounded text-[10px] text-ink-mid hover:text-ink transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
{copied ? 'Copied' : 'Copy'}
</button>
</div>
);
}
@@ -8,6 +8,7 @@ import { useKeyboardShortcut } from '@/hooks/use-keyboard-shortcut';
import { SecretsTab } from './SecretsTab';
import { TokensTab } from './TokensTab';
import { OrgTokensTab } from './OrgTokensTab';
import { OrgInfoTab } from './OrgInfoTab';
import { UnsavedChangesGuard } from './UnsavedChangesGuard';
/** Module-level ref so TopBar's SettingsButton can receive focus back on close. */
@@ -116,6 +117,9 @@ export function SettingsPanel({ workspaceId }: SettingsPanelProps) {
<Tabs.Trigger value="org-tokens" className="settings-panel__tab">
Org API Keys
</Tabs.Trigger>
<Tabs.Trigger value="org-info" className="settings-panel__tab">
Organization
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="api-keys" className="settings-panel__content">
@@ -129,6 +133,10 @@ export function SettingsPanel({ workspaceId }: SettingsPanelProps) {
<Tabs.Content value="org-tokens" className="settings-panel__content">
<OrgTokensTab />
</Tabs.Content>
<Tabs.Content value="org-info" className="settings-panel__content">
<OrgInfoTab />
</Tabs.Content>
</Tabs.Root>
<div className="settings-panel__footer">
@@ -0,0 +1,207 @@
// @vitest-environment jsdom
/**
* Tests for OrgInfoTab — surfaces current org name/slug/UUID with copy
* buttons, plus a list of the user's other orgs when applicable.
*
* Covers (≥3 cases per the closing-the-UX-gap brief):
* - Loading state (spinner + aria-live)
* - Renders current org matched by session.org_id, with UUID + slug + name
* - Copy button writes the UUID to navigator.clipboard
* - Falls back to host-slug match when session lookup fails
* - Lists other orgs when user belongs to multiple
* - Error banner when /cp/orgs throws
* - Empty/no-match state renders the recovery hint, not a crash
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { OrgInfoTab } from "../OrgInfoTab";
const mockGet = vi.fn();
const mockFetchSession = vi.fn();
const mockGetTenantSlug = vi.fn();
vi.mock("@/lib/api", () => ({
api: { get: (...args: unknown[]) => mockGet(...args) },
}));
vi.mock("@/lib/auth", () => ({
fetchSession: (...args: unknown[]) => mockFetchSession(...args),
}));
vi.mock("@/lib/tenant", () => ({
getTenantSlug: (...args: unknown[]) => mockGetTenantSlug(...args),
}));
// Stub clipboard
vi.stubGlobal("navigator", {
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
});
beforeEach(() => {
vi.useRealTimers();
mockGet.mockReset();
mockFetchSession.mockReset();
mockGetTenantSlug.mockReset();
mockGetTenantSlug.mockReturnValue("");
vi.mocked(navigator.clipboard.writeText).mockReset();
});
afterEach(() => {
cleanup();
});
async function flush() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
const AGENTS_TEAM = {
id: "2355b568-0799-4cc7-9e7f-806747f9958c",
slug: "agents-team",
name: "Agents Team",
status: "running",
};
const OTHER_ORG = {
id: "11111111-1111-4111-8111-111111111111",
slug: "skunkworks",
name: "Skunkworks",
status: "running",
};
// ─── Loading ─────────────────────────────────────────────────────────────────
describe("OrgInfoTab — loading", () => {
it("shows spinner while fetching", () => {
mockGet.mockImplementation(() => new Promise(() => {}));
mockFetchSession.mockImplementation(() => new Promise(() => {}));
render(<OrgInfoTab />);
const status = screen.getByRole("status");
expect(status).toBeTruthy();
expect(status.getAttribute("aria-live")).toBe("polite");
expect(status.textContent).toContain("Loading organization");
});
});
// ─── Current org renders + copy ──────────────────────────────────────────────
describe("OrgInfoTab — current org", () => {
it("renders the org matched by session.org_id with name, slug, UUID", async () => {
mockFetchSession.mockResolvedValue({
user_id: "u-1",
org_id: AGENTS_TEAM.id,
email: "hongming@moleculesai.app",
});
mockGet.mockResolvedValue([AGENTS_TEAM, OTHER_ORG]);
render(<OrgInfoTab />);
await flush();
await waitFor(() => screen.getByText("Current Organization"));
// Name shown
expect(screen.getByText("Agents Team")).toBeTruthy();
// Slug shown
expect(screen.getByText("agents-team")).toBeTruthy();
// UUID shown
expect(screen.getByText(AGENTS_TEAM.id)).toBeTruthy();
});
it("copy-UUID button writes the UUID to navigator.clipboard", async () => {
mockFetchSession.mockResolvedValue({
user_id: "u-1",
org_id: AGENTS_TEAM.id,
email: "hongming@moleculesai.app",
});
mockGet.mockResolvedValue([AGENTS_TEAM]);
render(<OrgInfoTab />);
await flush();
await waitFor(() => screen.getByText(AGENTS_TEAM.id));
const copyUuid = screen.getByRole("button", { name: /Copy UUID/i });
fireEvent.click(copyUuid);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(AGENTS_TEAM.id);
// Optimistic "Copied" label flip
await waitFor(() =>
expect(
screen.getByRole("button", { name: /Copy UUID/i }).textContent,
).toContain("Copied"),
);
});
it("copy-Slug button writes the slug to navigator.clipboard", async () => {
mockFetchSession.mockResolvedValue({
user_id: "u-1",
org_id: AGENTS_TEAM.id,
email: "hongming@moleculesai.app",
});
mockGet.mockResolvedValue([AGENTS_TEAM]);
render(<OrgInfoTab />);
await flush();
await waitFor(() => screen.getByText(AGENTS_TEAM.slug));
fireEvent.click(screen.getByRole("button", { name: /Copy Slug/i }));
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(AGENTS_TEAM.slug);
});
});
// ─── Fallback: host-slug match when session fails ────────────────────────────
describe("OrgInfoTab — fallbacks", () => {
it("falls back to host-slug match when fetchSession rejects", async () => {
mockFetchSession.mockRejectedValue(new Error("session probe failed"));
mockGetTenantSlug.mockReturnValue("agents-team");
mockGet.mockResolvedValue({ orgs: [AGENTS_TEAM, OTHER_ORG] }); // wrapped shape
render(<OrgInfoTab />);
await flush();
await waitFor(() => screen.getByText("Current Organization"));
expect(screen.getByText("Agents Team")).toBeTruthy();
expect(screen.getByText(AGENTS_TEAM.id)).toBeTruthy();
});
it("lists other orgs the user belongs to under a separate header", async () => {
mockFetchSession.mockResolvedValue({
user_id: "u-1",
org_id: AGENTS_TEAM.id,
email: "hongming@moleculesai.app",
});
mockGet.mockResolvedValue([AGENTS_TEAM, OTHER_ORG]);
render(<OrgInfoTab />);
await flush();
await waitFor(() => screen.getByText(/Your other organizations/));
expect(screen.getByText("Skunkworks")).toBeTruthy();
expect(screen.getByText(OTHER_ORG.id)).toBeTruthy();
});
});
// ─── Error + empty handling ──────────────────────────────────────────────────
describe("OrgInfoTab — error + empty", () => {
it("renders an error banner when /cp/orgs throws", async () => {
mockFetchSession.mockResolvedValue(null);
mockGet.mockRejectedValue(new Error("API GET /cp/orgs: 500 boom"));
render(<OrgInfoTab />);
await flush();
await waitFor(() => screen.getByText(/500 boom/));
expect(screen.queryByText("Current Organization")).toBeNull();
});
it("renders the recovery hint when no org matches (no crash)", async () => {
mockFetchSession.mockResolvedValue(null);
mockGetTenantSlug.mockReturnValue("");
mockGet.mockResolvedValue([]);
render(<OrgInfoTab />);
await flush();
await waitFor(() =>
screen.getByText(/No organization found for this session/),
);
});
});
+1
View File
@@ -8,3 +8,4 @@ export { SearchBar } from './SearchBar';
export { EmptyState } from './EmptyState';
export { DeleteConfirmDialog } from './DeleteConfirmDialog';
export { UnsavedChangesGuard } from './UnsavedChangesGuard';
export { OrgInfoTab } from './OrgInfoTab';
@@ -649,7 +649,17 @@ function WaitingBubbles({ visible }: { visible: CommMessage[] }) {
if (!prev || m.timestamp > prev.timestamp) tailByPeer.set(m.peerId, m);
}
const waitingPeers = Array.from(tailByPeer.values()).filter(
(m) => m.flow === "out" && (m.status === "pending" || m.status === "queued"),
// Task #227 — also light the indicator for status="dispatched": that's
// the platform's marker for a poll-mode delegation that's been
// recorded into the peer's inbox but not yet picked up. Without this
// arm, external/MCP peer threads showed an outbound bubble and then
// dead silence until the eventual reply landed — no parity with the
// native push-path "pending" indicator.
(m) =>
m.flow === "out" &&
(m.status === "pending" ||
m.status === "queued" ||
m.status === "dispatched"),
);
if (waitingPeers.length === 0) return null;
return (
@@ -688,7 +698,9 @@ function WaitingBubbles({ visible }: { visible: CommMessage[] }) {
<span className="text-[10px]">
{m.status === "queued"
? `${m.peerName} is busy — reply will arrive when they're free`
: `Waiting for ${m.peerName}`}
: m.status === "dispatched"
? `Queued — ${m.peerName} will pick up on next poll`
: `Waiting for ${m.peerName}`}
</span>
</span>
</div>
@@ -41,6 +41,19 @@ describe("inferA2AErrorHint", () => {
expect(inferA2AErrorHint("RuntimeException in tool call")).toMatch(/runtime threw an exception/);
});
it("points at the Activity tab (the real in-product logs surface), not 'workspace/container logs' (internal#212)", () => {
// Pre-#212 these hints sent users to "workspace logs" / "container
// logs" — neither has a UI affordance in the canvas. Activity tab
// is the in-product surface where the full row lives. Lock the
// copy so a future refactor cannot re-introduce the dangling
// pointer.
expect(inferA2AErrorHint("Agent error: boom")).toMatch(/Activity tab/);
expect(inferA2AErrorHint("some completely novel error nobody has matched yet")).toMatch(/Activity tab/);
// And the two strings together must not regress to the old text.
expect(inferA2AErrorHint("Agent error: boom")).not.toMatch(/container logs/);
expect(inferA2AErrorHint("some novel error")).not.toMatch(/workspace logs/);
});
it("recognises peer-unreachable cases (Activity-tab originals)", () => {
expect(inferA2AErrorHint("workspace not found")).toMatch(/can't be reached/);
expect(inferA2AErrorHint("not accessible")).toMatch(/can't be reached/);
@@ -53,7 +66,8 @@ describe("inferA2AErrorHint", () => {
it("returns a generic fallback for unrecognised text", () => {
const hint = inferA2AErrorHint("some completely novel error nobody has matched yet");
expect(hint).toMatch(/Check the workspace logs|delivery failure/);
// Fallback now sends the user to the Activity tab (post-#212).
expect(hint).toMatch(/Activity tab|delivery failure/);
});
it("Claude SDK wedge wins over the more general timeout pattern", () => {
@@ -38,7 +38,11 @@ export function inferA2AErrorHint(detail: string): string {
return "The connection to the remote agent dropped before a reply arrived. Usually a transient network blip — retry once. If it repeats, the remote container may have crashed mid-request; check its logs.";
}
if (t.includes("agent error") || t.includes("exception")) {
return "The remote agent's runtime threw an exception. Check the workspace's container logs for the traceback. Restart usually clears transient runtime crashes.";
// internal#212 closeout: end users have no "container logs" surface
// in the canvas; the Activity tab IS the user-visible logs surface
// (full row carries request/response body + error_detail). Point
// there so the hint is actionable from inside the product.
return "The remote agent's runtime threw an exception. Open the Activity tab for the full row (request body, response, error_detail) — Restart usually clears transient runtime crashes.";
}
if (
t.includes("not found") ||
@@ -50,5 +54,9 @@ export function inferA2AErrorHint(detail: string): string {
if (detail === "") {
return "The remote agent returned no error detail (the underlying httpx exception had an empty message — typically a connection-reset or silent timeout). A workspace restart is the safe first move.";
}
return "The remote agent reported a delivery failure. Check the workspace logs or try restarting.";
// internal#212 closeout: "workspace logs" pointed at a tab that does
// not exist — Activity tab is the in-product logs surface. Keep the
// hint generic enough for the unrecognised-detail fallback but point
// the user at a real affordance.
return "The remote agent reported a delivery failure. Open the Activity tab for the full row, or try restarting the workspace.";
}
@@ -0,0 +1,178 @@
// @vitest-environment jsdom
//
// Task #227 — external/MCP workspace progress UX parity.
//
// ws-server's `proxyA2ARequest` poll-mode short-circuit
// (workspace-server/internal/handlers/a2a_proxy.go:402-432) returns a
// synthetic `{status:"queued", delivery_mode:"poll", method:"message/send"}`
// HTTP 200 within ~50ms when the target workspace is registered with
// `delivery_mode=poll` — i.e. an operator's laptop running
// `molecule-mcp-claude-channel`, a hermes/codex MCP bridge, or a Cursor
// MCP client. The real agent reply arrives separately via the
// AGENT_MESSAGE WebSocket event after the agent's next
// `wait_for_message` poll (could be 1s, could be 60s).
//
// Pre-#227 behaviour: useChatSend treated the queued-200 as a successful
// round-trip — extractReplyText returned "", no agent bubble was
// created, `releaseSendGuards` flipped `sending` off, and the user saw
// dead silence between their user bubble and the eventual reply with
// NO progress indicator. That's the user-reported gap this task fixes.
//
// These tests pin the new behaviour: on a queued-200, the hook MUST NOT
// call onAgentMessage (no empty bubble) AND MUST NOT call
// releaseSendGuards (spinner persists). The eventual AGENT_MESSAGE WS
// event is what clears the spinner — that path is covered by
// useChatSocket.test.tsx already.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
// Capture the api.post invocations + control responses per-test.
const apiPostMock = vi.fn<
(url: string, body?: unknown, opts?: unknown) => Promise<unknown>
>();
vi.mock("@/lib/api", () => ({
api: {
post: (url: string, body?: unknown, opts?: unknown) =>
apiPostMock(url, body, opts),
get: vi.fn(),
},
}));
// uploads — tests don't go through the upload path; stub the helpers
// useChatSend imports so the module loads.
vi.mock("../../uploads", () => ({
uploadChatFiles: vi.fn(),
FileTooLargeError: class FileTooLargeError extends Error {},
}));
// types — re-export the createMessage helper unchanged; only the
// uploads stub matters above.
import { useChatSend } from "../useChatSend";
beforeEach(() => {
apiPostMock.mockReset();
});
describe("useChatSend — poll-mode (external/MCP) queued-200 handling — task #227", () => {
it("does NOT call onAgentMessage when the synthetic {status:'queued'} response lands (no empty bubble)", async () => {
// Mock the platform's poll-mode short-circuit response shape exactly
// as ws-server's `proxyA2ARequest` returns it (a2a_proxy.go:420-431).
apiPostMock.mockResolvedValueOnce({
status: "queued",
delivery_mode: "poll",
method: "message/send",
});
const onUserMessage = vi.fn();
const onAgentMessage = vi.fn();
const { result } = renderHook(() =>
useChatSend("ws-poll-target", {
getHistoryMessages: () => [],
onUserMessage,
onAgentMessage,
}),
);
await act(async () => {
await result.current.sendMessage("hello external workspace");
// Yield one microtask so the .then runs.
await Promise.resolve();
});
// User bubble fires — the user typed, that part is unconditional.
expect(onUserMessage).toHaveBeenCalledTimes(1);
// CRITICAL: no agent bubble. extractReplyText on a queued envelope
// returns "" — the pre-#227 code would still have hit the
// "releaseSendGuards + no bubble" path, BUT it would have ended
// `sending`. The new code returns early BEFORE that release, so the
// contract under test is "no synthesised empty bubble".
expect(onAgentMessage).not.toHaveBeenCalled();
});
it("keeps `sending` true after a queued-200 — the spinner must persist until the real AGENT_MESSAGE arrives", async () => {
apiPostMock.mockResolvedValueOnce({
status: "queued",
delivery_mode: "poll",
method: "message/send",
});
const { result } = renderHook(() =>
useChatSend("ws-poll-target", {
getHistoryMessages: () => [],
}),
);
await act(async () => {
await result.current.sendMessage("waiting for the operator laptop");
await Promise.resolve();
});
// The spinner-driving state is `sending`. On a queued-200, it must
// remain true — clearing it here is the exact bug task #227
// resurfaces (collapsing the spinner before the agent has even seen
// the message).
expect(result.current.sending).toBe(true);
});
it("ALSO keeps `sending` true even after a follow-up microtask flush — guards against an accidental late release", async () => {
// Defense: ensure no chained .then / .finally accidentally calls
// releaseSendGuards on the queued path. Run several microtask
// ticks and re-assert.
apiPostMock.mockResolvedValueOnce({
status: "queued",
delivery_mode: "poll",
});
const { result } = renderHook(() =>
useChatSend("ws-poll-target", {
getHistoryMessages: () => [],
}),
);
await act(async () => {
await result.current.sendMessage("late-release-guard");
// Flush multiple microtask ticks.
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.sending).toBe(true);
});
it("push-mode (real reply parts) still flips sending=false + creates an agent bubble — non-regression for the default path", async () => {
// Sanity-check the push path still works: a real reply must call
// onAgentMessage and flip sending=false. Without this assertion an
// overzealous "return early on any non-result body" would silently
// break the dominant push-mode path.
apiPostMock.mockResolvedValueOnce({
result: {
parts: [{ kind: "text", text: "hi from native workspace" }],
},
});
const onAgentMessage = vi.fn();
const { result } = renderHook(() =>
useChatSend("ws-native-push", {
getHistoryMessages: () => [],
onAgentMessage,
}),
);
await act(async () => {
await result.current.sendMessage("native push test");
await Promise.resolve();
});
expect(onAgentMessage).toHaveBeenCalledTimes(1);
const msg = onAgentMessage.mock.calls[0][0] as {
role: string;
content: string;
};
expect(msg.role).toBe("agent");
expect(msg.content).toBe("hi from native workspace");
expect(result.current.sending).toBe(false);
});
});
@@ -116,6 +116,77 @@ describe("useChatSocket — surface error_detail to onSendError (internal#212)",
expect(reason.length).toBeGreaterThan(0);
});
// Task #227 — external/MCP (poll-mode) workspace progress UX.
//
// ws-server's `proxyA2ARequest` poll-mode short-circuit fires the
// ACTIVITY_LOGGED a2a_receive with status="ok" and NO duration_ms (no
// reply yet — the request is queued for the agent's next poll). Before
// task #227 the (status==="ok" && durationMs) guard silently dropped
// this row, so the chat UI had ZERO progress signal between "user
// typed" and "agent eventually polled and replied". Lock the queued
// line in so future refactors don't regress to the silent-drop state.
it("emits a 'queued — will pick up on next poll' activity line when a2a_receive status=ok has no duration_ms (poll-mode)", () => {
const onActivityLog = vi.fn();
renderHook(() =>
useChatSocket("ws-self", {
onActivityLog,
}),
);
expect(capturedHandler).not.toBeNull();
act(() => {
capturedHandler!({
event: "ACTIVITY_LOGGED",
workspace_id: "ws-self",
payload: {
activity_type: "a2a_receive",
method: "message/send",
status: "ok",
target_id: "ws-self",
// No duration_ms — this is the queued-for-poll signal.
},
timestamp: "2026-05-20T00:00:00Z",
});
});
expect(onActivityLog).toHaveBeenCalledTimes(1);
const line = onActivityLog.mock.calls[0][0] as string;
// The line MUST be present (not the empty-string silent-drop pattern)
// and MUST mention the queued state so the user has actionable signal.
expect(line.length).toBeGreaterThan(0);
expect(line.toLowerCase()).toMatch(/queued|poll/);
});
// Pair with the above: poll-mode acknowledgement must NOT prematurely
// call onSendComplete — the spinner has to stay up until the actual
// AGENT_MESSAGE reply lands. (The reply-success path with duration_ms
// still calls onSendComplete; that's the push-mode case.)
it("does NOT call onSendComplete on a poll-mode queued a2a_receive (spinner must persist)", () => {
const onSendComplete = vi.fn();
renderHook(() =>
useChatSocket("ws-self", {
onSendComplete,
}),
);
act(() => {
capturedHandler!({
event: "ACTIVITY_LOGGED",
workspace_id: "ws-self",
payload: {
activity_type: "a2a_receive",
method: "message/send",
status: "ok",
target_id: "ws-self",
// No duration_ms.
},
timestamp: "2026-05-20T00:00:00Z",
});
});
expect(onSendComplete).not.toHaveBeenCalled();
});
it("ignores errors targeted at a different workspace's peer", () => {
// Defense against a race where the WS hub fans out to all clients —
// each chat panel must only react when target_id matches its own
@@ -22,6 +22,28 @@ interface A2AResponse {
parts?: A2APart[];
artifacts?: Array<{ parts: A2APart[] }>;
};
/** Set by ws-server's poll-mode short-circuit in `proxyA2ARequest`
* (a2a_proxy.go:416-431) when the target workspace is registered as
* `delivery_mode=poll` — e.g. an operator's laptop running
* `molecule-mcp-claude-channel`, a hermes/codex MCP bridge, or a
* Cursor MCP client. The HTTP 200 carries the synthetic envelope
* `{status:"queued", delivery_mode:"poll", method:"message/send"}`
* immediately (~50ms), BEFORE the agent has produced a reply.
*
* Task #227 routing: when this field is "queued" the caller must NOT
* treat the 200 as "agent done" — there are no `result.parts` yet
* (the reply will arrive separately via the AGENT_MESSAGE WS event
* after the agent's next poll). Keep the spinner up; the eventual
* AGENT_MESSAGE flips `sending` off via the existing useChatSocket
* `onSendComplete` path. Without this distinction the spinner
* disappeared immediately and external/MCP workspaces had no progress
* UX between send and reply. */
status?: string;
/** Companion to `status` — "poll" when the queued short-circuit fired.
* Defensive: we key the poll-mode-skip decision on status==="queued"
* (the canonical signal) rather than on this field, but it's surfaced
* here so future debugging / tests can assert on the full envelope. */
delivery_mode?: string;
}
export function extractReplyText(resp: A2AResponse): string {
@@ -195,6 +217,30 @@ export function useChatSend(workspaceId: string, options: UseChatSendOptions) {
sendInFlightRef.current = false;
return;
}
// Task #227 — poll-mode (external/MCP workspace) queued-200
// short-circuit. ws-server's `proxyA2ARequest` returns
// `{status:"queued", delivery_mode:"poll", ...}` immediately
// when the target has no URL (delivery_mode=poll), BEFORE the
// agent has produced any reply. There is no `result.parts`
// payload here — the actual reply will arrive separately via
// the AGENT_MESSAGE WebSocket event after the agent's next
// `wait_for_message` poll.
//
// Keep the spinner up by deliberately NOT calling
// releaseSendGuards: the user-facing "thinking" state must
// persist until the AGENT_MESSAGE lands (handled by the
// useChatSocket `onAgentMessage`/`onSendComplete` path) or an
// explicit error fires (`onSendError` from an ACTIVITY_LOGGED
// status="error"). Don't synthesise an empty agent bubble.
//
// sendInFlightRef stays true intentionally — it's the dedup
// guard for the user typing two messages back-to-back; for
// poll mode the second message would race the first agent's
// reply, so blocking is correct (matches push-mode behaviour
// where `sending` blocks the textarea).
if (resp?.status === "queued") {
return;
}
const replyText = extractReplyText(resp);
const replyFiles = extractFilesFromTask(
(resp?.result ?? {}) as Record<string, unknown>,
@@ -62,6 +62,25 @@ export function useChatSocket(
line = `${targetName} responded (${sec}s)`;
const own = (targetId || msg.workspace_id) === workspaceId;
if (own) callbacksRef.current.onSendComplete?.();
} else if (status === "ok" && !durationMs) {
// Task #227 — poll-mode (external/MCP workspace) queued receipt.
// ws-server `logA2AReceiveQueued` writes a "received but no
// reply yet" row with status="ok" and NO duration_ms, then
// immediately returns the synthetic {status:"queued"} 200 to
// the caller. Before this branch the row was silently dropped
// by the (status==="ok" && durationMs) guard above — leaving
// the chat UI with zero progress signal for the entire window
// between "user typed" and "agent eventually polled and
// replied". Surface the queued state explicitly so the user
// sees acknowledgement (matches the queued-delegation
// indicator in AgentCommsPanel.WaitingBubbles).
//
// We intentionally do NOT call onSendComplete here: the
// outbound is not done — only acknowledged. The MyChatPanel
// spinner stays up until the actual AGENT_MESSAGE reply lands
// (poll path) or an explicit error fires (which still hits
// the status==="error" branch below).
line = `${targetName} queued — agent will pick up on next poll`;
} else if (status === "error") {
line = `${targetName} error`;
const own = (targetId || msg.workspace_id) === workspaceId;
+3
View File
@@ -523,6 +523,9 @@ export function buildNodesAndEdges(
// that don't yet include these columns in the GET response.
broadcastEnabled: ws.broadcast_enabled ?? false,
talkToUserEnabled: ws.talk_to_user_enabled ?? true,
// A2A delivery mode (task #227). Absent on older ws-server builds
// — leave undefined so the chat UI's "?? 'push'" fallback applies.
deliveryMode: ws.delivery_mode,
},
};
if (hasParent) {
+22
View File
@@ -106,6 +106,28 @@ export interface WorkspaceNodeData extends Record<string, unknown> {
* send_message_to_user / POST /notify return 403 and the canvas
* shows a "not enabled" state with a button to re-enable. Default true. */
talkToUserEnabled?: boolean;
/** A2A inbound delivery mode for this workspace — "push" (default —
* synchronous HTTP dispatch by ws-server `proxyA2ARequest`) or "poll"
* (workspace has no URL; ws-server logs the request and the agent
* consumes it via `wait_for_message` / GET /activity?since_id=).
*
* Why surfaced to the UI: poll-mode targets (external/MCP workspaces:
* `molecule-mcp-claude-channel` on an operator laptop, hermes/codex
* bridge clients, Cursor MCP) acknowledge a canvas `message/send` with
* a synthetic `{status:"queued"}` 200 within ~50ms. Without this flag
* the chat UI cannot tell that gap from a real round-trip — the
* spinner disappears immediately and the user sees dead silence until
* the agent eventually polls and replies via the AGENT_MESSAGE WS
* event (could be seconds, could be minutes). Task #227 — render a
* "queued — agent will pick up on next poll" state for poll-mode
* sends so external/MCP workspaces have progress UX parity with
* native runtimes (claude-code / codex / hermes / openclaw).
*
* Sourced from the GET /workspaces response (`delivery_mode` snake_case
* field, mapped here in canvas-topology.ts). Absent on older platform
* builds — that fallthrough is treated as "push" to match
* ws-server's `lookupDeliveryMode` default. */
deliveryMode?: string;
}
export type PanelTab = "details" | "skills" | "chat" | "terminal" | "config" | "schedule" | "channels" | "files" | "memory" | "traces" | "events" | "activity" | "audit";
+10
View File
@@ -342,6 +342,16 @@ export interface WorkspaceData {
/** Workspace ability flags (migration 20260514). */
broadcast_enabled?: boolean;
talk_to_user_enabled?: boolean;
/** A2A delivery mode for inbound messages — "push" (default, synchronous
* HTTP dispatch to `url`) or "poll" (queued to activity_logs, agent
* picks up via `wait_for_message` / GET /activity?since_id=). Surfaced
* in the GET /workspaces response since #2339 PR 1; older platform
* versions return it absent so the canvas treats absent as "push" (the
* documented default in `lookupDeliveryMode`). Used by the chat UI to
* render an "agent will pick up on next poll" indicator instead of
* collapsing the spinner the moment the synchronous queued-200 returns
* (task #227 — external/MCP workspaces had no progress UX). */
delivery_mode?: string;
}
let socket: ReconnectingSocket | null = null;
@@ -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
+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)
+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
+68 -11
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
@@ -104,6 +106,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
@@ -218,16 +260,31 @@ 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 an admin test token below.
WTOK=$(echo "$R" | extract_auth_token)
[ -n "$WID" ] || fail "$rt workspace create failed: $(echo "$R" | head -c 300)"
[ -n "$WTOK" ] || fail "$rt workspace did not return an auth_token — cannot drive its MCP call (resp: $(echo "$R" | head -c 300))"
TOKEN_DIAG=""
if [ -z "$WTOK" ]; then
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
if [ -z "$WTOK" ]; then
TTOK_FILE=$(mktemp)
TTOK_CODE=$(tenant_call_capture GET "/admin/workspaces/$WID/test-token" "$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="${TOKEN_DIAG}
GET /admin/workspaces/$WID/test-token -> 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 (create_resp: $(echo "$R" | redact_token_body); token_fallbacks: $TOKEN_DIAG)"
WS_IDS[$rt]="$WID"
WS_TOKENS[$rt]="$WTOK"
ALL_WS_IDS="$ALL_WS_IDS $WID"
+193
View File
@@ -0,0 +1,193 @@
"""Tests for `.gitea/scripts/detect-changes.py`."""
from __future__ import annotations
import importlib.util
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
SCRIPT = REPO_ROOT / ".gitea" / "scripts" / "detect-changes.py"
def load_module():
spec = importlib.util.spec_from_file_location("detect_changes", SCRIPT)
assert spec is not None
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
return module
def test_ci_profile_classifies_surfaces():
mod = load_module()
assert mod.classify("ci", ["workspace-server/internal/handlers/a2a_proxy.go"]) == {
"platform": True,
"canvas": False,
"python": False,
"scripts": False,
}
assert mod.classify("ci", ["canvas/src/app/page.tsx"]) == {
"platform": False,
"canvas": True,
"python": False,
"scripts": False,
}
assert mod.classify("ci", ["tests/e2e/test_model_slug.sh"]) == {
"platform": False,
"canvas": False,
"python": False,
"scripts": True,
}
assert mod.classify("ci", [".gitea/workflows/ci.yml", "README.md"]) == {
"platform": False,
"canvas": False,
"python": False,
"scripts": False,
}
def test_handlers_postgres_profile_is_narrower_than_workspace_server():
mod = load_module()
assert mod.classify("handlers-postgres", ["workspace-server/internal/handlers/a2a_proxy.go"]) == {
"handlers": True,
}
assert mod.classify("handlers-postgres", ["workspace-server/internal/provisioner/provisioner.go"]) == {
"handlers": False,
}
def test_e2e_api_profile_covers_api_inputs():
mod = load_module()
assert mod.classify("e2e-api", ["workspace-server/internal/handlers/workspace.go"]) == {
"api": True,
}
assert mod.classify("e2e-api", ["tests/e2e/test_api.sh"]) == {"api": True}
assert mod.classify("e2e-api", ["canvas/src/app/page.tsx"]) == {"api": False}
def test_fail_open_all_true_for_missing_base():
mod = load_module()
assert mod.all_true("ci") == {
"platform": True,
"canvas": True,
"python": True,
"scripts": True,
}
def test_fetch_base_prefers_advertised_base_ref(monkeypatch):
mod = load_module()
calls: list[list[str]] = []
exists_checks = 0
def fake_base_exists(base: str) -> bool:
nonlocal exists_checks
exists_checks += 1
return exists_checks >= 1
def fake_run_git(args: list[str], *, timeout: int = 30):
calls.append(args)
class Result:
returncode = 0
stdout = ""
stderr = ""
return Result()
monkeypatch.setattr(mod, "base_exists", fake_base_exists)
monkeypatch.setattr(mod, "run_git", fake_run_git)
mod.fetch_base("abc123", "main")
assert calls == [["fetch", "--depth=1", "origin", "main"]]
def test_fetch_base_falls_back_to_sha_when_ref_fetch_does_not_materialize(monkeypatch):
mod = load_module()
calls: list[list[str]] = []
monkeypatch.setattr(mod, "base_exists", lambda _base: False)
def fake_run_git(args: list[str], *, timeout: int = 30):
calls.append(args)
class Result:
returncode = 0
stdout = ""
stderr = ""
return Result()
monkeypatch.setattr(mod, "run_git", fake_run_git)
mod.fetch_base("abc123", "main")
assert calls == [
["fetch", "--depth=1", "origin", "main"],
["fetch", "--depth=1", "origin", "abc123"],
]
def test_changed_paths_uses_merge_base_for_pull_request(monkeypatch):
mod = load_module()
calls: list[list[str]] = []
def fake_run_git(args: list[str], *, timeout: int = 30):
calls.append(args)
class Result:
returncode = 0
stdout = "workspace/agent.py\n"
stderr = ""
if args[0] == "merge-base":
Result.stdout = "merge123\n"
return Result()
monkeypatch.setattr(mod, "run_git", fake_run_git)
assert mod.changed_paths("base123", use_merge_base=True) == ["workspace/agent.py"]
assert calls == [
["merge-base", "base123", "HEAD"],
["diff", "--name-only", "merge123", "HEAD"],
]
def test_detect_deepens_base_ref_when_pr_merge_base_missing(monkeypatch):
mod = load_module()
calls: list[tuple[str, str | None]] = []
merge_base_calls = 0
monkeypatch.setattr(mod, "base_exists", lambda _base: True)
def fake_merge_base(base: str):
nonlocal merge_base_calls
merge_base_calls += 1
if merge_base_calls == 1:
return None
return "merge123"
def fake_deepen_base_ref(base_ref: str):
calls.append(("deepen", base_ref))
def fake_changed_paths(base: str, *, use_merge_base: bool):
calls.append(("changed", str(use_merge_base)))
return [".gitea/workflows/ci.yml"]
monkeypatch.setattr(mod, "merge_base", fake_merge_base)
monkeypatch.setattr(mod, "deepen_base_ref", fake_deepen_base_ref)
monkeypatch.setattr(mod, "changed_paths", fake_changed_paths)
assert mod.detect("ci", "pull_request", "base123", "", "main") == {
"platform": False,
"canvas": False,
"python": False,
"scripts": False,
}
assert calls == [("deepen", "main"), ("changed", "True")]
+28
View File
@@ -0,0 +1,28 @@
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parents[1]
def workflow_on(path: Path):
doc = yaml.safe_load(path.read_text())
return doc.get("on") or doc.get(True)
def test_browser_e2e_workflows_are_not_unconditional_pr_heavy_lanes():
workflows = [
ROOT / ".gitea/workflows/e2e-chat.yml",
ROOT / ".gitea/workflows/e2e-staging-canvas.yml",
]
for path in workflows:
text = path.read_text()
events = workflow_on(path)
assert "workflow_dispatch" in events
assert "schedule" in events
assert "merge-queue" in text
assert "/issues/${{ github.event.pull_request.number }}/labels" in text
assert "PR is not in merge-queue" in text
+72 -7
View File
@@ -26,9 +26,11 @@ import re
import subprocess
import sys
import textwrap
import importlib.util
from pathlib import Path
import pytest # noqa: F401 (declares the dep)
import yaml
REPO_ROOT = Path(__file__).resolve().parents[1]
SCRIPT = REPO_ROOT / ".gitea" / "scripts" / "lint-workflow-yaml.py"
@@ -616,16 +618,24 @@ def test_rule10_docker_info_head_in_separate_step_without_pipefail_passes(tmp_pa
CI_WORKFLOW = REPO_ROOT / ".gitea" / "workflows" / "ci.yml"
CI_SURFACES = ("platform", "canvas", "python", "scripts")
DETECT_CHANGES_SCRIPT = REPO_ROOT / ".gitea" / "scripts" / "detect-changes.py"
def _load_detect_changes():
spec = importlib.util.spec_from_file_location("detect_changes", DETECT_CHANGES_SCRIPT)
assert spec is not None
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
return module
def _ci_change_patterns() -> dict[str, re.Pattern[str]]:
text = CI_WORKFLOW.read_text(encoding="utf-8")
patterns: dict[str, re.Pattern[str]] = {}
for surface, pattern in re.findall(
r'echo "(platform|canvas|python|scripts)=.*?grep -qE \'([^\']+)\'',
text,
):
patterns[surface] = re.compile(pattern)
detect_changes = _load_detect_changes()
patterns = {
surface: re.compile(pattern)
for surface, pattern in detect_changes.PROFILES["ci"].items()
}
assert set(patterns) == set(CI_SURFACES)
return patterns
@@ -693,3 +703,58 @@ def test_ci_change_detector_docs_and_meta_scripts_do_not_trigger_surfaces():
"python": False,
"scripts": False,
}
def test_ci_platform_go_pr_steps_are_path_scoped():
doc = yaml.safe_load(CI_WORKFLOW.read_text(encoding="utf-8"))
platform = doc["jobs"]["platform-build"]
assert platform.get("needs") == "changes"
expensive_steps = [
step
for step in platform["steps"]
if step.get("uses")
or step.get("run", "").startswith("go ")
or "golangci-lint" in step.get("run", "")
]
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
def test_ci_canvas_nextjs_pr_steps_are_path_scoped():
doc = yaml.safe_load(CI_WORKFLOW.read_text(encoding="utf-8"))
canvas = doc["jobs"]["canvas-build"]
assert canvas.get("needs") == "changes"
expensive_steps = [
step
for step in canvas["steps"]
if step.get("uses")
or step.get("run", "").startswith("npm ")
or step.get("run", "").startswith("npx ")
]
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
def test_ci_shellcheck_pr_steps_are_path_scoped():
doc = yaml.safe_load(CI_WORKFLOW.read_text(encoding="utf-8"))
shellcheck = doc["jobs"]["shellcheck"]
assert shellcheck.get("needs") == "changes"
expensive_steps = [
step
for step in shellcheck["steps"]
if step.get("uses") or step.get("run", "").startswith(("bash ", "find ", "shellcheck "))
]
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
+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(
@@ -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 {
@@ -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) {
@@ -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())
}
}
@@ -122,8 +122,22 @@ func (h *DelegationHandler) Delegate(c *gin.Context) {
// #548 — prevent self-delegation: a workspace delegating to itself
// acquires _run_lock twice on the same mutex, deadlocking permanently.
//
// #383 — the error message is the agent-visible string when this 400
// fires on the SDK's _delegate_sync_via_polling path. The previous
// terse "self-delegation not permitted" was correct but indistinct
// from a transient rate-limit or auth failure, so the LLM would
// re-attempt every 2-3s in a tight loop (chloe-dong tenant external
// workspace, 2026-05-20). The expanded message is explicit about
// (a) what just happened, (b) why it cannot succeed, (c) what to do
// instead — so the agent's retry heuristic recognizes the path as
// terminal and stops.
if sourceID == body.TargetID {
c.JSON(http.StatusBadRequest, gin.H{"error": "self-delegation not permitted"})
c.JSON(http.StatusBadRequest, gin.H{
"error": "self-delegation not permitted",
"reason": "the source workspace and target workspace are the same; you cannot delegate a task to yourself",
"hint": "do the work yourself, or pick a different peer via list_peers — retrying with the same target_id will fail every time",
})
return
}
@@ -256,24 +256,43 @@ func (h *DiscoveryHandler) Peers(c *gin.Context) {
peers = append(peers, siblings...)
}
// Children
// Children — exclude self defensively. A child row whose parent_id
// equals the requesting workspaceID can never legitimately be the
// caller (a workspace can't be its own child), but a future data-
// integrity defect (e.g. self-loop introduced by a buggy register
// path) would otherwise smuggle the caller back into its own peer
// list. The agent then attempts `delegate_task(<own_id>)`, which
// either deadlocks _run_lock (sync path) or hits the platform's
// self-delegation 400 in a tight loop (#383). The `w.id != $2`
// clause makes self-delegation-via-peer-list impossible regardless
// of DB state.
children, _ := queryPeerMaps(`
SELECT w.id, w.name, COALESCE(w.role, ''), w.tier, w.status,
COALESCE(w.agent_card, 'null'::jsonb), COALESCE(w.url, ''),
w.parent_id, w.active_tasks
FROM workspaces w WHERE w.parent_id = $1 AND w.status != 'removed'`, workspaceID)
FROM workspaces w WHERE w.parent_id = $1 AND w.id != $2 AND w.status != 'removed'`,
workspaceID, workspaceID)
peers = append(peers, children...)
// Parent
// Parent — same defense-in-depth. A workspace whose parent_id points
// to itself is data corruption, but the peer-list endpoint must not
// propagate that corruption back to the agent as a "peer who is also
// you" entry.
if parentID.Valid {
parent, _ := queryPeerMaps(`
SELECT w.id, w.name, COALESCE(w.role, ''), w.tier, w.status,
COALESCE(w.agent_card, 'null'::jsonb), COALESCE(w.url, ''),
w.parent_id, w.active_tasks
FROM workspaces w WHERE w.id = $1 AND w.status != 'removed'`, parentID.String)
FROM workspaces w WHERE w.id = $1 AND w.id != $2 AND w.status != 'removed'`,
parentID.String, workspaceID)
peers = append(peers, parent...)
}
// #383 final-line defense: even if a future code path adds a query
// that doesn't filter self, strip the caller's own row before
// returning. Cheap O(n) over a peer set bounded at <50 rows.
peers = excludeSelfFromPeers(peers, workspaceID)
peers = filterPeersByQuery(peers, c.Query("q"))
if peers == nil {
@@ -282,6 +301,32 @@ func (h *DiscoveryHandler) Peers(c *gin.Context) {
c.JSON(http.StatusOK, peers)
}
// excludeSelfFromPeers strips any peer entry whose ``id`` equals
// ``workspaceID`` (the caller's own row). Final-line defense for #383
// (self-delegation 400-loop on external workspaces): a peer-list that
// includes the requester's own row is the root mechanism by which an
// agent ends up delegating to itself. The pre-DB filters in Peers
// already enforce `w.id != $caller` on each branch; this function
// guarantees the contract holds regardless of which query path
// returned the row, including future ones added without a self-filter.
//
// O(n) over a peer set bounded at <50 rows per `Peers` comment — well
// below the hot-path overhead of the existing filterPeersByQuery.
func excludeSelfFromPeers(peers []map[string]interface{}, workspaceID string) []map[string]interface{} {
if len(peers) == 0 {
return peers
}
out := make([]map[string]interface{}, 0, len(peers))
for _, p := range peers {
id, _ := p["id"].(string)
if id == workspaceID {
continue
}
out = append(out, p)
}
return out
}
// filterPeersByQuery returns peers whose name or role case-insensitively
// contains q. Whitespace-trimmed empty q is a no-op (returns input unchanged).
func filterPeersByQuery(peers []map[string]interface{}, q string) []map[string]interface{} {
@@ -125,14 +125,14 @@ func TestPeers_WithParent(t *testing.T) {
WillReturnRows(sqlmock.NewRows(peerCols).
AddRow("ws-sibling-2", "Sibling Two", "worker", 1, "online", []byte("null"), "http://localhost:8002", "ws-parent", 0))
// Expect children query
mock.ExpectQuery("SELECT w.id, w.name.*WHERE w.parent_id = \\$1 AND w.status").
WithArgs("ws-sibling-1").
// Expect children query — #383 added explicit `w.id != $2` self-filter
mock.ExpectQuery("SELECT w.id, w.name.*WHERE w.parent_id = \\$1 AND w.id != \\$2 AND w.status").
WithArgs("ws-sibling-1", "ws-sibling-1").
WillReturnRows(sqlmock.NewRows(peerCols))
// Expect parent query
mock.ExpectQuery("SELECT w.id, w.name.*WHERE w.id = \\$1 AND w.status").
WithArgs("ws-parent").
// Expect parent query — #383 added explicit `w.id != $2` self-filter
mock.ExpectQuery("SELECT w.id, w.name.*WHERE w.id = \\$1 AND w.id != \\$2 AND w.status").
WithArgs("ws-parent", "ws-sibling-1").
WillReturnRows(sqlmock.NewRows(peerCols).
AddRow("ws-parent", "Parent PM", "manager", 2, "online", []byte("null"), "http://localhost:8001", nil, 1))
@@ -228,9 +228,9 @@ func TestPeers_RootWorkspace_NoPeers(t *testing.T) {
WithArgs("ws-root-alone").
WillReturnRows(sqlmock.NewRows(peerCols))
// Children — none
mock.ExpectQuery("SELECT w.id, w.name.*WHERE w.parent_id = \\$1").
WithArgs("ws-root-alone").
// Children — none. #383 added explicit `w.id != $2` self-filter.
mock.ExpectQuery("SELECT w.id, w.name.*WHERE w.parent_id = \\$1 AND w.id != \\$2").
WithArgs("ws-root-alone", "ws-root-alone").
WillReturnRows(sqlmock.NewRows(peerCols))
// No parent query since parent_id is NULL
@@ -282,12 +282,14 @@ func peersFilterFixture(t *testing.T) (*DiscoveryHandler, sqlmock.Sqlmock) {
AddRow("ws-alpha", "Alpha Researcher", "researcher", 1, "online", []byte("null"), "http://a", "ws-pm", 0).
AddRow("ws-beta", "Beta Designer", "designer", 1, "online", []byte("null"), "http://b", "ws-pm", 0))
mock.ExpectQuery("SELECT w.id, w.name.*WHERE w.parent_id = \\$1 AND w.status").
WithArgs("ws-self").
// #383 — children query gained explicit `w.id != $2` self-filter.
mock.ExpectQuery("SELECT w.id, w.name.*WHERE w.parent_id = \\$1 AND w.id != \\$2 AND w.status").
WithArgs("ws-self", "ws-self").
WillReturnRows(sqlmock.NewRows(cols))
mock.ExpectQuery("SELECT w.id, w.name.*WHERE w.id = \\$1 AND w.status").
WithArgs("ws-pm").
// #383 — parent query gained explicit `w.id != $2` self-filter.
mock.ExpectQuery("SELECT w.id, w.name.*WHERE w.id = \\$1 AND w.id != \\$2 AND w.status").
WithArgs("ws-pm", "ws-self").
WillReturnRows(sqlmock.NewRows(cols).
AddRow("ws-pm", "PM Workspace", "manager", 2, "online", []byte("null"), "http://pm", nil, 1))
@@ -966,8 +968,9 @@ func TestPeers_DevModeFailOpen_AllowsBearerlessRequest(t *testing.T) {
mock.ExpectQuery("SELECT w.id.+WHERE w.parent_id IS NULL AND w.id").
WithArgs("ws-dev").
WillReturnRows(sqlmock.NewRows(peerCols))
mock.ExpectQuery("SELECT w.id.+WHERE w.parent_id = \\$1 AND w.status").
WithArgs("ws-dev").
// #383 — children query gained explicit `w.id != $2` self-filter.
mock.ExpectQuery("SELECT w.id.+WHERE w.parent_id = \\$1 AND w.id != \\$2 AND w.status").
WithArgs("ws-dev", "ws-dev").
WillReturnRows(sqlmock.NewRows(peerCols))
w := httptest.NewRecorder()
@@ -1030,3 +1033,183 @@ func TestPeers_DevModeFailOpen_ClosedInProduction(t *testing.T) {
t.Fatalf("expected 401 in production, got %d: %s", w.Code, w.Body.String())
}
}
// ==================== Peers — #383 self never appears in result ====================
// TestPeers_ExcludeSelf_DefenseInDepth verifies the final-line filter in
// Peers strips any row whose id matches the caller. The pre-DB SQL filters
// already do this, but a future code path that omits the `w.id != $caller`
// clause must not be able to smuggle a self-row through. This test
// simulates that future-defect case by mocking the children query to
// (incorrectly) return a row whose id matches the caller, and asserts the
// final filter still drops it.
//
// Root cause class for #383: an agent that sees its own row in /peers
// proceeds to delegate_task to itself, hitting the platform's
// self-delegation 400 in a tight loop. The fix in discovery.go is
// defense-in-depth: even if the SQL filter regresses, this handler-level
// filter prevents the 400-loop from materializing.
func TestPeers_ExcludeSelf_DefenseInDepth(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewDiscoveryHandler()
const selfID = "ws-xiaodong"
// parent_id lookup — workspace has a parent.
mock.ExpectQuery("SELECT parent_id FROM workspaces WHERE id =").
WithArgs(selfID).
WillReturnRows(sqlmock.NewRows([]string{"parent_id"}).AddRow("ws-parent"))
peerCols := []string{"id", "name", "role", "tier", "status", "agent_card", "url", "parent_id", "active_tasks"}
// Siblings — returns one legitimate sibling. The SQL filter excludes
// self at the source.
mock.ExpectQuery("SELECT w.id, w.name.*WHERE w.parent_id = \\$1 AND w.id != \\$2").
WithArgs("ws-parent", selfID).
WillReturnRows(sqlmock.NewRows(peerCols).
AddRow("ws-sibling", "Sibling", "worker", 1, "online", []byte("null"), "http://localhost:8002", "ws-parent", 0))
// Children — simulates the data-integrity defect class: the DB
// (incorrectly) returns the caller's own row in the children set.
// In real production this would require a workspace whose
// parent_id points to itself — corruption only, but the handler
// must not propagate it.
mock.ExpectQuery("SELECT w.id, w.name.*WHERE w.parent_id = \\$1 AND w.id != \\$2 AND w.status").
WithArgs(selfID, selfID).
WillReturnRows(sqlmock.NewRows(peerCols).
AddRow(selfID, "Self As Child", "worker", 1, "online", []byte("null"), "http://localhost:8001", selfID, 0).
AddRow("ws-child", "Real Child", "worker", 1, "online", []byte("null"), "http://localhost:8003", selfID, 0))
// Parent — explicit `w.id != $2` clause so the parent path is also
// self-filtered. parentID.String = "ws-parent" != selfID, so the
// row is included.
mock.ExpectQuery("SELECT w.id, w.name.*WHERE w.id = \\$1 AND w.id != \\$2 AND w.status").
WithArgs("ws-parent", selfID).
WillReturnRows(sqlmock.NewRows(peerCols).
AddRow("ws-parent", "Parent", "manager", 2, "online", []byte("null"), "http://localhost:8004", nil, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: selfID}}
c.Request = httptest.NewRequest("GET", "/registry/"+selfID+"/peers", nil)
handler.Peers(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var peers []map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &peers); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
// The defense-in-depth filter must drop the self row even though
// the (mocked-defective) children query returned it.
for _, p := range peers {
if id, _ := p["id"].(string); id == selfID {
t.Fatalf("peer list contains caller's own id %q — self-delegation defense regressed; full list: %+v", selfID, peers)
}
}
// Sanity: the three legitimate peers (sibling, real child, parent)
// must all be present. Catches an over-aggressive filter that
// strips legitimate rows.
expectedIDs := map[string]bool{"ws-sibling": false, "ws-child": false, "ws-parent": false}
for _, p := range peers {
if id, _ := p["id"].(string); id != "" {
if _, ok := expectedIDs[id]; ok {
expectedIDs[id] = true
}
}
}
for id, found := range expectedIDs {
if !found {
t.Errorf("legitimate peer %q missing from response; got %+v", id, peers)
}
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
// TestExcludeSelfFromPeers_Unit exercises the helper directly so the
// defense-in-depth contract is asserted independently of SQL mocking.
// Pure-function tests run in microseconds and pin the filter shape
// (empty input, no-match passthrough, single-row drop, multi-row drop,
// preserves order) so future edits to the helper can't silently
// regress to "returns input unchanged".
func TestExcludeSelfFromPeers_Unit(t *testing.T) {
t.Run("empty input returns empty slice", func(t *testing.T) {
out := excludeSelfFromPeers(nil, "ws-self")
if len(out) != 0 {
t.Errorf("expected empty, got %+v", out)
}
})
t.Run("no self in list passes through unchanged", func(t *testing.T) {
in := []map[string]interface{}{
{"id": "ws-a", "name": "A"},
{"id": "ws-b", "name": "B"},
}
out := excludeSelfFromPeers(in, "ws-self")
if len(out) != 2 {
t.Fatalf("expected 2, got %d (%+v)", len(out), out)
}
if out[0]["id"] != "ws-a" || out[1]["id"] != "ws-b" {
t.Errorf("order not preserved: %+v", out)
}
})
t.Run("self row dropped, others preserved", func(t *testing.T) {
in := []map[string]interface{}{
{"id": "ws-a", "name": "A"},
{"id": "ws-self", "name": "Me"},
{"id": "ws-b", "name": "B"},
}
out := excludeSelfFromPeers(in, "ws-self")
if len(out) != 2 {
t.Fatalf("expected 2, got %d (%+v)", len(out), out)
}
if out[0]["id"] != "ws-a" || out[1]["id"] != "ws-b" {
t.Errorf("expected [ws-a, ws-b], got %+v", out)
}
})
t.Run("multiple self rows all dropped", func(t *testing.T) {
// Pathological — should never happen, but the contract is
// "no row with id==workspaceID survives", not "at most one
// such row is dropped". Pin it.
in := []map[string]interface{}{
{"id": "ws-self", "name": "Me1"},
{"id": "ws-a", "name": "A"},
{"id": "ws-self", "name": "Me2"},
}
out := excludeSelfFromPeers(in, "ws-self")
if len(out) != 1 {
t.Fatalf("expected 1, got %d (%+v)", len(out), out)
}
if out[0]["id"] != "ws-a" {
t.Errorf("expected [ws-a], got %+v", out)
}
})
t.Run("row with missing id key is preserved (not a self-collision)", func(t *testing.T) {
// A peer row with no "id" key shouldn't be silently dropped
// by the self-filter — it's a malformed row class that
// belongs to a different defect.
in := []map[string]interface{}{
{"name": "no-id-row"},
{"id": "ws-self", "name": "Me"},
}
out := excludeSelfFromPeers(in, "ws-self")
if len(out) != 1 {
t.Fatalf("expected 1, got %d (%+v)", len(out), out)
}
if out[0]["name"] != "no-id-row" {
t.Errorf("expected no-id-row preserved, got %+v", out)
}
})
}
@@ -432,9 +432,10 @@ func TestExtended_Peers(t *testing.T) {
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "role", "tier", "status", "agent_card", "url", "parent_id", "active_tasks"}).
AddRow("ws-sibling", "Sibling Agent", "worker", 1, "online", []byte("null"), "http://localhost:9001", nil, 0))
// Expect children query (workspaces with parent_id = ws-peer)
// Expect children query (workspaces with parent_id = ws-peer, excluding self)
// Query now binds (parent_id, self_id) for the self-filter guard added in #383.
mock.ExpectQuery("SELECT w.id, w.name").
WithArgs("ws-peer").
WithArgs("ws-peer", "ws-peer").
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "role", "tier", "status", "agent_card", "url", "parent_id", "active_tasks"}))
// No parent query since workspace is root-level
@@ -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)
}
@@ -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.
@@ -234,9 +234,13 @@ func (h *TemplatesHandler) ReplaceFiles(c *gin.Context) {
"source": "ec2-ssh",
})
if h.wh != nil {
// RFC internal#524 Layer 1: per-handler goAsync (drains via h.wh.waitAsyncForTest)
wsID := workspaceID
h.wh.goAsync(func() { h.wh.RestartByID(wsID) })
// internal#624: 15s per-workspace debounce around the file-write
// → RestartByID trigger. Canvas Save / ReplaceFiles fires N PUTs
// in a burst; without this each PUT chains into the
// coalesceRestart drain loop. The helper still uses goAsync
// internally (drains via h.wh.waitAsyncForTest), preserving
// RFC internal#524 Layer 1.
h.wh.maybeRestartAfterFileWrite(workspaceID)
}
return
}
@@ -270,9 +274,13 @@ func (h *TemplatesHandler) ReplaceFiles(c *gin.Context) {
"source": "container",
})
if h.wh != nil {
// RFC internal#524 Layer 1: per-handler goAsync (drains via h.wh.waitAsyncForTest)
wsID := workspaceID
h.wh.goAsync(func() { h.wh.RestartByID(wsID) })
// internal#624: 15s per-workspace debounce around the file-write
// → RestartByID trigger. Canvas Save / ReplaceFiles fires N PUTs
// in a burst; without this each PUT chains into the
// coalesceRestart drain loop. The helper still uses goAsync
// internally (drains via h.wh.waitAsyncForTest), preserving
// RFC internal#524 Layer 1.
h.wh.maybeRestartAfterFileWrite(workspaceID)
}
return
}
@@ -292,8 +300,12 @@ func (h *TemplatesHandler) ReplaceFiles(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "replaced", "workspace": workspaceID, "files": len(body.Files), "source": "volume"})
if h.wh != nil {
// RFC internal#524 Layer 1: per-handler goAsync (drains via h.wh.waitAsyncForTest)
wsID := workspaceID
h.wh.goAsync(func() { h.wh.RestartByID(wsID) })
// internal#624: 15s per-workspace debounce around the file-write
// → RestartByID trigger. Canvas Save / ReplaceFiles fires N PUTs
// in a burst; without this each PUT chains into the
// coalesceRestart drain loop. The helper still uses goAsync
// internally (drains via h.wh.waitAsyncForTest), preserving
// RFC internal#524 Layer 1.
h.wh.maybeRestartAfterFileWrite(workspaceID)
}
}
+42 -18
View File
@@ -570,9 +570,13 @@ func (h *TemplatesHandler) WriteFile(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"status": "saved", "path": filePath})
if h.wh != nil {
// RFC internal#524 Layer 1: per-handler goAsync (drains via h.wh.waitAsyncForTest)
wsID := workspaceID
h.wh.goAsync(func() { h.wh.RestartByID(wsID) })
// internal#624: 15s per-workspace debounce around the file-write
// → RestartByID trigger. Canvas Save fires N PUTs in a burst;
// without this each PUT chains into the coalesceRestart drain
// loop and produces back-to-back EC2 recreate cycles. The
// helper still uses goAsync internally (drains via
// h.wh.waitAsyncForTest), preserving RFC internal#524 Layer 1.
h.wh.maybeRestartAfterFileWrite(workspaceID)
}
return
}
@@ -586,9 +590,13 @@ func (h *TemplatesHandler) WriteFile(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"status": "saved", "path": filePath})
if h.wh != nil {
// RFC internal#524 Layer 1: per-handler goAsync (drains via h.wh.waitAsyncForTest)
wsID := workspaceID
h.wh.goAsync(func() { h.wh.RestartByID(wsID) })
// internal#624: 15s per-workspace debounce around the file-write
// → RestartByID trigger. Canvas Save fires N PUTs in a burst;
// without this each PUT chains into the coalesceRestart drain
// loop and produces back-to-back EC2 recreate cycles. The
// helper still uses goAsync internally (drains via
// h.wh.waitAsyncForTest), preserving RFC internal#524 Layer 1.
h.wh.maybeRestartAfterFileWrite(workspaceID)
}
return
}
@@ -602,9 +610,13 @@ func (h *TemplatesHandler) WriteFile(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"status": "saved", "path": filePath})
if h.wh != nil {
// RFC internal#524 Layer 1: per-handler goAsync (drains via h.wh.waitAsyncForTest)
wsID := workspaceID
h.wh.goAsync(func() { h.wh.RestartByID(wsID) })
// internal#624: 15s per-workspace debounce around the file-write
// → RestartByID trigger. Canvas Save fires N PUTs in a burst;
// without this each PUT chains into the coalesceRestart drain
// loop and produces back-to-back EC2 recreate cycles. The
// helper still uses goAsync internally (drains via
// h.wh.waitAsyncForTest), preserving RFC internal#524 Layer 1.
h.wh.maybeRestartAfterFileWrite(workspaceID)
}
}
@@ -657,9 +669,13 @@ func (h *TemplatesHandler) DeleteFile(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"status": "deleted", "path": filePath})
if h.wh != nil {
// RFC internal#524 Layer 1: per-handler goAsync (drains via h.wh.waitAsyncForTest)
wsID := workspaceID
h.wh.goAsync(func() { h.wh.RestartByID(wsID) })
// internal#624: 15s per-workspace debounce around the file-write
// → RestartByID trigger. Canvas Save fires N PUTs in a burst;
// without this each PUT chains into the coalesceRestart drain
// loop and produces back-to-back EC2 recreate cycles. The
// helper still uses goAsync internally (drains via
// h.wh.waitAsyncForTest), preserving RFC internal#524 Layer 1.
h.wh.maybeRestartAfterFileWrite(workspaceID)
}
return
}
@@ -677,9 +693,13 @@ func (h *TemplatesHandler) DeleteFile(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"status": "deleted", "path": filePath})
if h.wh != nil {
// RFC internal#524 Layer 1: per-handler goAsync (drains via h.wh.waitAsyncForTest)
wsID := workspaceID
h.wh.goAsync(func() { h.wh.RestartByID(wsID) })
// internal#624: 15s per-workspace debounce around the file-write
// → RestartByID trigger. Canvas Save fires N PUTs in a burst;
// without this each PUT chains into the coalesceRestart drain
// loop and produces back-to-back EC2 recreate cycles. The
// helper still uses goAsync internally (drains via
// h.wh.waitAsyncForTest), preserving RFC internal#524 Layer 1.
h.wh.maybeRestartAfterFileWrite(workspaceID)
}
return
}
@@ -692,8 +712,12 @@ func (h *TemplatesHandler) DeleteFile(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"status": "deleted", "path": filePath})
if h.wh != nil {
// RFC internal#524 Layer 1: per-handler goAsync (drains via h.wh.waitAsyncForTest)
wsID := workspaceID
h.wh.goAsync(func() { h.wh.RestartByID(wsID) })
// internal#624: 15s per-workspace debounce around the file-write
// → RestartByID trigger. Canvas Save fires N PUTs in a burst;
// without this each PUT chains into the coalesceRestart drain
// loop and produces back-to-back EC2 recreate cycles. The
// helper still uses goAsync internally (drains via
// h.wh.waitAsyncForTest), preserving RFC internal#524 Layer 1.
h.wh.maybeRestartAfterFileWrite(workspaceID)
}
}
@@ -869,14 +869,31 @@ func applyRuntimeModelEnv(envVars map[string]string, runtime, model string) {
// Returns nil map + error string on decrypt failure. Shared by both Docker
// and control plane provisioning paths to avoid duplication.
//
// The second return value (globalKeys) records which keys originated from
// the operator-controlled `global_secrets` table — used by RFC#523 Layer 1
// to constrain its forbidden-key check to the operator-bleed channel,
// instead of blanket-blocking by name across BOTH provenance channels (the
// over-fire that breaks the legitimate user flow of pasting their own
// GitHub PAT into the canvas Secrets tab → workspace_secrets row). See
// `feedback_upstream_docs_first_before_hypothesizing`: RFC#523's threat
// model (issue molecule-ai/internal#523 §"Threat model") names operator-
// scope tokens being injected via provision-time env / operator-side
// stores — NOT the user's own scoped PAT they explicitly authorized via
// the per-workspace Secrets tab.
//
// The merged map preserves the existing precedence semantic (workspace
// rows overwrite global rows on key collision); only the provenance side-
// channel is new. Existing single-return callers can ignore globalKeys.
//
// F1086 / #1206: the returned error string is the SAFE-CANNED message that
// gets persisted to workspaces.last_sample_error AND broadcast as the
// WORKSPACE_PROVISION_FAILED payload. Internal detail (the secret key name,
// the encryption version, the decrypt-error text) is logged here, never
// returned to the caller, so it can't leak via the canvas event stream
// (cf. TestProvisionWorkspace_NoInternalErrorsInBroadcast).
func loadWorkspaceSecrets(ctx context.Context, workspaceID string) (map[string]string, string) {
func loadWorkspaceSecrets(ctx context.Context, workspaceID string) (map[string]string, map[string]struct{}, string) {
envVars := map[string]string{}
globalKeys := map[string]struct{}{}
globalRows, globalErr := db.DB.QueryContext(ctx,
`SELECT key, encrypted_value, encryption_version FROM global_secrets`)
if globalErr == nil {
@@ -889,9 +906,10 @@ func loadWorkspaceSecrets(ctx context.Context, workspaceID string) (map[string]s
decrypted, decErr := crypto.DecryptVersioned(v, ver)
if decErr != nil {
log.Printf("Provisioner: FATAL — failed to decrypt global secret %s (version=%d): %v — aborting provision of workspace %s", k, ver, decErr, workspaceID)
return nil, "failed to decrypt global secret"
return nil, nil, "failed to decrypt global secret"
}
envVars[k] = string(decrypted)
globalKeys[k] = struct{}{}
}
}
if err := globalRows.Err(); err != nil {
@@ -910,16 +928,22 @@ func loadWorkspaceSecrets(ctx context.Context, workspaceID string) (map[string]s
decrypted, decErr := crypto.DecryptVersioned(v, ver)
if decErr != nil {
log.Printf("Provisioner: FATAL — failed to decrypt workspace secret %s (version=%d) for %s: %v — aborting provision", k, ver, workspaceID, decErr)
return nil, "failed to decrypt workspace secret"
return nil, nil, "failed to decrypt workspace secret"
}
envVars[k] = string(decrypted)
// User-authored workspace_secrets value supersedes any
// global_secrets row of the same key — including dropping
// the operator-bleed provenance flag. The user explicitly
// re-set the value via the canvas Secrets tab, so it is
// no longer "the operator-store version."
delete(globalKeys, k)
}
}
if err := wsRows.Err(); err != nil {
log.Printf("Provisioner: workspace_secrets rows.Err workspace=%s: %v", workspaceID, err)
}
}
return envVars, ""
return envVars, globalKeys, ""
}
// provisionWorkspaceCP provisions a workspace via the control plane API.
@@ -135,6 +135,15 @@ func isForbiddenTenantEnvKey(key string) bool {
// message and the structured-extra payload that goes to the
// canvas Events tab. Sorting makes the message stable across
// Go's randomized map iteration.
//
// PROVENANCE NOTE: this helper checks by env-var name ONLY and is
// unaware of where each value came from. Production provision code
// uses findForbiddenTenantEnvKeysFromGlobals instead, restricting
// the check to keys originating from the operator-controlled
// global_secrets table — see the doc-comment on that function and
// the RFC#523 Layer 1 block in prepareProvisionContext. This name-
// only helper is kept for the workspace_secrets-write CI lint
// (Layer 3) and for tests that pin the deny-set definition.
func findForbiddenTenantEnvKeys(envVars map[string]string) []string {
if len(envVars) == 0 {
return []string{}
@@ -149,6 +158,48 @@ func findForbiddenTenantEnvKeys(envVars map[string]string) []string {
return found
}
// findForbiddenTenantEnvKeysFromGlobals is the provenance-aware
// variant used by RFC#523 Layer 1 in prepareProvisionContext. It
// restricts the forbidden-key scan to keys whose value originated
// from the operator-controlled `global_secrets` table.
//
// Fixes the over-fire reported by CTO empirical 2026-05-20: a user
// who explicitly pastes their own scoped GitHub PAT under
// GITHUB_TOKEN into the canvas Secrets tab (a `workspace_secrets`
// row) was being blocked alongside the genuine operator-bleed case.
// RFC#523's threat model (issue molecule-ai/internal#523 §"Threat
// model") names operator-scope tokens injected via operator-side
// stores; user-authored workspace_secrets is out of scope.
//
// globalSecretKeys is the set returned as the second value from
// loadWorkspaceSecrets. A key that exists in BOTH stores is treated
// as workspace_secrets (user override wins) — loadWorkspaceSecrets
// drops the global flag when the workspace row is read.
//
// Empty/nil globalSecretKeys means no operator-side source was
// loaded (e.g. tests, or table empty); the scan returns no hits.
// Deterministic sort order, same as findForbiddenTenantEnvKeys.
func findForbiddenTenantEnvKeysFromGlobals(envVars map[string]string, globalSecretKeys map[string]struct{}) []string {
if len(envVars) == 0 || len(globalSecretKeys) == 0 {
return []string{}
}
found := make([]string, 0)
for k := range globalSecretKeys {
if _, present := envVars[k]; !present {
// Defensive: a key flagged as global-origin must also
// be in the resolved env-set. If not, skip — the
// loadWorkspaceSecrets contract guarantees this never
// happens, but the helper stays total.
continue
}
if isForbiddenTenantEnvKey(k) {
found = append(found, k)
}
}
sort.Strings(found)
return found
}
// formatForbiddenTenantEnvError builds the safe-canned user-facing
// message for a provision aborted because forbidden env keys are
// present in the resolved env-set. The message names the
@@ -150,6 +150,106 @@ func TestFindForbiddenTenantEnvKeys_SingleAndMultipleSorted(t *testing.T) {
}
}
// TestFindForbiddenTenantEnvKeysFromGlobals pins the provenance-aware
// behaviour added 2026-05-20 to fix the RFC#523 Layer 1 over-fire: a
// user-set workspace_secrets row with key=GITHUB_TOKEN must NOT be
// flagged, while a global_secrets row of the same key MUST be.
//
// Cross-references the empirical bug: CTO 2026-05-20 hit
// `provision aborted: env var "GITHUB_TOKEN" is operator-scope...`
// after pasting their own scoped PAT into the canvas Secrets tab
// (workspace_secrets) — the original blanket check fired on the
// merged env-set regardless of provenance.
func TestFindForbiddenTenantEnvKeysFromGlobals_UserSetAllowed(t *testing.T) {
// User pasted their own PAT via canvas Secrets tab —
// workspace_secrets row only. globalSecretKeys is empty for
// this key, so the check MUST not fire.
envVars := map[string]string{
"GITHUB_TOKEN": "ghp_FAKEUSERPAT_user_set_via_canvas",
"ANTHROPIC_API_KEY": "sk-ant-keep",
}
globalKeys := map[string]struct{}{} // nothing from global_secrets
got := findForbiddenTenantEnvKeysFromGlobals(envVars, globalKeys)
if len(got) != 0 {
t.Errorf("user-set workspace_secrets with GITHUB_TOKEN: got %v; want empty (provenance-allowed)", got)
}
}
func TestFindForbiddenTenantEnvKeysFromGlobals_OperatorLeakBlocked(t *testing.T) {
// Operator-store bleed — GITHUB_TOKEN sourced from global_secrets.
// This is the literal RFC#523 §"Threat model" attack vector.
// Check MUST fire and name GITHUB_TOKEN.
envVars := map[string]string{
"GITHUB_TOKEN": "ghp_OPERATOR_LEAK_from_global_secrets",
"ANTHROPIC_API_KEY": "sk-ant-keep",
}
globalKeys := map[string]struct{}{
"GITHUB_TOKEN": {},
"ANTHROPIC_API_KEY": {},
}
got := findForbiddenTenantEnvKeysFromGlobals(envVars, globalKeys)
if len(got) != 1 || got[0] != "GITHUB_TOKEN" {
t.Errorf("operator-leak GITHUB_TOKEN in global_secrets: got %v; want [GITHUB_TOKEN]", got)
}
}
func TestFindForbiddenTenantEnvKeysFromGlobals_UserOverrideOfGlobalAllowed(t *testing.T) {
// Both stores have the key; loadWorkspaceSecrets drops the global
// flag when the workspace row supersedes (caller contract).
// Simulate that here: globalKeys does NOT contain GITHUB_TOKEN
// because workspace_secrets re-set it. Allowed.
envVars := map[string]string{
"GITHUB_TOKEN": "ghp_USER_RESET_after_global_was_present",
}
globalKeys := map[string]struct{}{} // workspace overrode → flag dropped
got := findForbiddenTenantEnvKeysFromGlobals(envVars, globalKeys)
if len(got) != 0 {
t.Errorf("user-override of global GITHUB_TOKEN: got %v; want empty", got)
}
}
func TestFindForbiddenTenantEnvKeysFromGlobals_MultipleOperatorLeaks(t *testing.T) {
// Multiple operator-leaked tokens — must return sorted slice.
envVars := map[string]string{
"GITHUB_TOKEN": "leak1",
"CP_ADMIN_API_TOKEN": "leak2",
"MOLECULE_OPERATOR_HOST": "leak3",
"RAILWAY_TOKEN": "leak4",
"ANTHROPIC_API_KEY": "user-allowed",
}
globalKeys := map[string]struct{}{
"GITHUB_TOKEN": {},
"CP_ADMIN_API_TOKEN": {},
"MOLECULE_OPERATOR_HOST": {},
"RAILWAY_TOKEN": {},
}
got := findForbiddenTenantEnvKeysFromGlobals(envVars, globalKeys)
want := []string{"CP_ADMIN_API_TOKEN", "GITHUB_TOKEN", "MOLECULE_OPERATOR_HOST", "RAILWAY_TOKEN"}
if len(got) != len(want) {
t.Fatalf("operator-leak multi: got %v; want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("operator-leak multi[%d] = %q; want %q (full got=%v)", i, got[i], want[i], got)
}
}
}
func TestFindForbiddenTenantEnvKeysFromGlobals_EmptyInputs(t *testing.T) {
if got := findForbiddenTenantEnvKeysFromGlobals(nil, nil); len(got) != 0 {
t.Errorf("nil/nil: got %v; want empty", got)
}
if got := findForbiddenTenantEnvKeysFromGlobals(map[string]string{}, map[string]struct{}{}); len(got) != 0 {
t.Errorf("empty/empty: got %v; want empty", got)
}
// Non-empty envVars but no global provenance — nothing came from
// global_secrets, so nothing to block (even if a workspace_secrets
// row exists for GITHUB_TOKEN).
if got := findForbiddenTenantEnvKeysFromGlobals(map[string]string{"GITHUB_TOKEN": "ghp_user"}, map[string]struct{}{}); len(got) != 0 {
t.Errorf("workspace-only GITHUB_TOKEN: got %v; want empty", got)
}
}
func TestFormatForbiddenTenantEnvError_Phrasing(t *testing.T) {
// Empty input — defensive total function.
if msg := formatForbiddenTenantEnvError(nil); !strings.Contains(msg, "RFC#523") {
@@ -120,38 +120,52 @@ func (h *WorkspaceHandler) prepareProvisionContext(
payload models.CreateWorkspacePayload,
resetClaudeSession bool,
) (*preparedProvisionContext, *provisionAbort) {
envVars, decryptErr := loadWorkspaceSecrets(ctx, workspaceID)
envVars, globalSecretKeys, decryptErr := loadWorkspaceSecrets(ctx, workspaceID)
if decryptErr != "" {
return nil, &provisionAbort{Msg: decryptErr}
}
// RFC#523 Layer 1 (task #146): refuse to start a tenant workspace
// when any forbidden operator-scope env var is present in the
// resolved secret-load env-set. Runs IMMEDIATELY after
// loadWorkspaceSecrets and BEFORE applyAgentGitHTTPCreds — the
// per-agent persona injection sets a fallback GITEA_USER/GITEA_TOKEN
// pair that the buildContainerEnv forensic #145 guard will strip
// later. We want THIS layer to catch leaks from the operator-
// controlled stores (global_secrets, workspace_secrets) only, not
// the deliberate per-agent platform injection that lives downstream.
// RFC#523 Layer 1 (issue molecule-ai/internal#523): refuse to start a
// tenant workspace when any forbidden operator-scope env var is
// present in the operator-controlled store (global_secrets).
//
// Threat model is "an upstream secret-writer accidentally widened
// the propagation set" — e.g. an operator pastes GITEA_TOKEN into
// a workspace_secrets row. Caught here, surfaced loudly to the
// canvas Events tab, fail-closed. The existing forensic #145 guard
// in provisioner.buildContainerEnv / CPProvisioner.Start stays as
// defense-in-depth: it silently strips at container-env-build time.
// PROVENANCE-AWARE — fix for the over-fire reported by CTO empirical
// 2026-05-20: the original implementation ran this check on the
// merged env-set, which conflated two very different sources:
//
// 1. global_secrets — operator-side store. ANY operator-scope token
// here is an upstream bleed (e.g. tenant_secrets_seed.go pre-
// 4f45d37 propagating CP-env GITHUB_TOKEN into every fresh
// tenant's row). RFC#523's literal threat model.
//
// 2. workspace_secrets — user-set via the canvas Secrets tab,
// authenticated as the workspace owner. If the user pastes
// their own scoped GitHub PAT under GITHUB_TOKEN so the agent
// can push to their personal repos, that is the system working
// as designed — not the leak RFC#523 was written to catch.
//
// The provenance side-channel from loadWorkspaceSecrets tells us
// which keys came from global_secrets (workspace_secrets writes
// override and clear the flag, since the user explicitly re-set
// the value). We restrict the abort to that set.
//
// Defense-in-depth NOT removed: provisioner.buildContainerEnv still
// runs the forensic #145 silent-strip (lower-confidence late layer),
// and workspace/entrypoint.sh has Layer 2 inside the container. If a
// real operator-scope token slips into workspace_secrets some other
// way, the later layers (and the per-workspace SG, and the per-tenant
// VPC isolation) are still in force.
//
// Key names (not values) are echoed in the user-facing error so
// the operator can locate and remove the offending row. Per memory
// `feedback_passwords_in_chat_are_burned`, key names are not
// secret; values would be.
if forbidden := findForbiddenTenantEnvKeys(envVars); len(forbidden) > 0 {
if forbidden := findForbiddenTenantEnvKeysFromGlobals(envVars, globalSecretKeys); len(forbidden) > 0 {
msg := formatForbiddenTenantEnvError(forbidden)
log.Printf("Provisioner: ABORT workspace=%s — forbidden operator-scope env keys present: %v (RFC#523)", workspaceID, forbidden)
log.Printf("Provisioner: ABORT workspace=%s — forbidden operator-scope env keys present in global_secrets: %v (RFC#523)", workspaceID, forbidden)
return nil, &provisionAbort{
Msg: msg,
Extra: map[string]interface{}{"error": msg, "forbidden_env_keys": forbidden, "rfc": "523"},
Extra: map[string]interface{}{"error": msg, "forbidden_env_keys": forbidden, "rfc": "523", "source": "global_secrets"},
}
}
@@ -70,6 +70,97 @@ var restartDebounceWindow = 60 * time.Second
// workspace-server yet — that's a separate RFC.
var restartByIDDropCounter atomic.Uint64
// fileWriteRestartDebounceWindow is the per-workspace coalescing window for
// the file-write → RestartByID trigger fired by templates.go's WriteFile,
// DeleteFile, and ReplaceFiles handlers (and template_import.go's variants).
//
// Background (internal#624 2026-05-20): canvas Save fires N PUT /files
// requests in a 30-60s burst (claude-code SEO agent observed 10-17 files in
// 60s). Each successful write previously fired `goAsync(RestartByID)`. The
// 60s self-fire debounce in RestartByID itself catches calls 1-60s, but
// writes at T+65s+ pass the debounce, set pending=true on a still-running
// coalesceRestart cycle, and drain immediately into cycle 2 — which DELETEs
// + recreates EC2 mid-burst, returning 500 EC2InstanceStateInvalidException
// on the in-flight user PUTs.
//
// 15s is sized to absorb a canvas Save burst (writes typically land within
// a 5-10s window) while still letting a deliberate "edit, wait, edit again"
// pattern restart twice. Bigger than that would silently swallow legitimate
// rapid-iteration edits; smaller would let burst tails leak through.
var fileWriteRestartDebounceWindow = 15 * time.Second
// fileWriteRestartLastFireAt records the last time `maybeRestartAfterFileWrite`
// actually fired a restart for each workspace. sync.Map (not RWMutex+map)
// because writes happen on every successful file-write handler, reads on
// every subsequent file-write handler call — both per-workspace — and the
// keys are sparse + long-lived. Stored as int64 unix-nano so the load/store
// path can stay lock-free (atomic.Int64 inside sync.Map.Value is fine, but
// time.Time itself isn't atomically loadable).
var fileWriteRestartLastFireAt sync.Map // map[workspaceID]*atomic.Int64
// fileWriteRestartDropCounter counts how many file-write restart triggers
// were silently coalesced. Same observability rationale as
// restartByIDDropCounter — package-level atomic so tests can assert the
// drop fired and ops can correlate with "user clicked Save 10 times,
// only saw 1 restart cycle".
var fileWriteRestartDropCounter atomic.Uint64
// maybeRestartAfterFileWrite is the call-site debounce wrapper for the 9
// file-write trigger sites in templates.go + template_import.go. Replaces
// the direct `goAsync(func() { wh.RestartByID(wsID) })` pattern with a
// 15s per-workspace coalescing window:
//
// - First call (no prior fire OR last fire >15s ago): records the
// current timestamp and fires goAsync(RestartByID).
// - Subsequent calls within 15s of the last fire: silently dropped,
// drop counter incremented.
//
// This is the call-site-layer protection (internal#624 Path A). The drain-
// loop layer in coalesceRestart (Path B, re-stamping restartStartedAt per
// iteration) is the platform-layer defense in depth — together they close
// the file-write tight-loop class regardless of which entry point fires.
//
// Stateless on the handler so any handler with access to a WorkspaceHandler
// can use it; the per-workspace state lives in the package-level sync.Map.
func (h *WorkspaceHandler) maybeRestartAfterFileWrite(workspaceID string) {
now := time.Now().UnixNano()
// LoadOrStore the per-workspace last-fire stamp. First write for a
// brand-new workspace falls through the CompareAndSwap below because
// the zero-init value (0) is far enough in the past to satisfy the
// "last fire >15s ago" predicate.
sv, _ := fileWriteRestartLastFireAt.LoadOrStore(workspaceID, new(atomic.Int64))
stamp := sv.(*atomic.Int64)
// CAS loop: read last, decide, swap. We use CAS instead of Lock/Unlock
// because the typical case is "thousands of writes, one restart per
// 15s" — uncontended atomic is ~5ns vs ~30ns mutex. Bounded retry
// because in the rare contended case (two writes finishing nanoseconds
// apart) one will win the swap and the other will see the new stamp,
// drop, and bail.
for retry := 0; retry < 4; retry++ {
last := stamp.Load()
elapsed := time.Duration(now - last)
if last != 0 && elapsed < fileWriteRestartDebounceWindow {
// Within debounce window — drop silently.
fileWriteRestartDropCounter.Add(1)
log.Printf("maybeRestartAfterFileWrite: %s — coalesced "+
"(last fire %s ago < %s window; total dropped=%d)",
workspaceID, elapsed.Round(time.Millisecond),
fileWriteRestartDebounceWindow,
fileWriteRestartDropCounter.Load())
return
}
if stamp.CompareAndSwap(last, now) {
break
}
// Another writer beat us to the stamp update. Re-read and retry;
// the retry will almost certainly see the new value and drop.
}
h.goAsync(func() { h.RestartByID(workspaceID) })
}
// 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
@@ -513,6 +604,27 @@ func coalesceRestart(workspaceID string, cycle func()) {
// inside provisionWorkspace, so any writes that committed since the
// last cycle are picked up. Continues until no pending request was
// observed at the top of an iteration.
//
// internal#624 Path B (defense in depth for the file-write tight-loop
// class): re-stamp restartStartedAt at the top of every drain iteration
// past the first. The original design (stamp only on false→true edge)
// treated all drained pending as "one event from the debounce's POV",
// which is correct for the secrets-batch use case but lets a file-write
// burst at T+65s of a 60s drain pipe straight into another full cycle.
// Re-stamping closes that hole — each drained cycle gets its own fresh
// debounce window, so any RestartByID arriving during cycle N is
// dropped by shouldDebounceRestart instead of accumulating into
// pending=true for cycle N+1.
//
// The original "one cycle picks up everyone who arrived during it"
// semantic still holds for the secrets-write path: callers that hit
// coalesceRestart during cycle 1 still set pending=true and still get
// their effects landed in cycle 2. What changes is that callers
// arriving during cycle 2 (via RestartByID) now hit the re-stamped
// debounce and are dropped instead of being chained into cycle 3,
// which is exactly the chain that produced the 22:08-22:10 thrash on
// 3fe84b89.
iteration := 0
for {
state.mu.Lock()
if !state.pending {
@@ -520,7 +632,13 @@ func coalesceRestart(workspaceID string, cycle func()) {
return // defer clears running
}
state.pending = false
if iteration > 0 {
// Re-stamp for drained iterations only; the false→true edge
// already stamped at the top of coalesceRestart.
state.restartStartedAt = time.Now()
}
state.mu.Unlock()
iteration++
cycle()
}
@@ -0,0 +1,316 @@
package handlers
// Tests for internal#624 — file-write → RestartByID tight-loop fix.
//
// Empirical chain (Loki 2026-05-20 22:00-22:11Z on workspace
// 3fe84b89-eb65-42fc-ad1f-5c93582ca3e7, claude-code SEO Agent):
//
// 1. Canvas Save writes 10-17 files in a 30-60s window.
// 2. Each successful PUT /files at templates.go:575 / 591 / 607 / 662 /
// 682 / 697 (and template_import.go:239 / 275 / 297) fires
// `goAsync(func() { wh.RestartByID(wsID) })`.
// 3. RestartByID's existing 60s self-fire debounce catches calls 1-60s
// after the cycle starts. But writes at T+65s+ pass the debounce,
// set pending=true on the still-running coalesceRestart cycle, and
// drain IMMEDIATELY into cycle 2 — no re-debounce because the
// original drain loop re-uses the same restartStartedAt.
// 4. Cycle 2 DELETEs+recreates EC2 mid-burst → user sees
// EC2InstanceStateInvalidException 500 on the in-flight PUTs.
//
// Fix: two layers (both shipped in the same PR).
//
// Path A (call-site debounce): every file-write trigger goes through
// maybeRestartAfterFileWrite, which silently drops re-fires within 15s
// of the last fire for the same workspace.
//
// Path B (drain-loop re-stamp): coalesceRestart now re-stamps
// restartStartedAt at the top of each drained iteration, so any
// RestartByID arriving during a drained cycle hits a fresh 60s window
// and is dropped by shouldDebounceRestart instead of chaining further.
import (
"sync"
"sync/atomic"
"testing"
"time"
)
// resetFileWriteDebounceState wipes the package-level sync.Map + drop
// counter for the given workspace ID. Tests must call this between
// scenarios because fileWriteRestartLastFireAt is shared.
func resetFileWriteDebounceState(workspaceID string) {
fileWriteRestartLastFireAt.Delete(workspaceID)
fileWriteRestartDropCounter.Store(0)
}
// newFileWriteDebounceHandler constructs a minimal *WorkspaceHandler with
// no provisioner so RestartByID short-circuits at HasProvisioner()=false
// — we only care that maybeRestartAfterFileWrite reaches goAsync at all.
// The asyncWG inside goAsync lets us wait for the goroutine to finish so
// we can deterministically observe whether RestartByID was scheduled.
func newFileWriteDebounceHandler(t *testing.T) *WorkspaceHandler {
t.Helper()
return NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
}
// TestMaybeRestartAfterFileWrite_FirstWriteRestarts — the baseline case:
// the very first call for a workspace must actually fire goAsync (i.e.
// no debounce-drop on the first PUT). Without this the helper would
// silently swallow every legitimate single-file save.
func TestMaybeRestartAfterFileWrite_FirstWriteRestarts(t *testing.T) {
const wsID = "fw-debounce-first"
resetFileWriteDebounceState(wsID)
h := newFileWriteDebounceHandler(t)
h.maybeRestartAfterFileWrite(wsID)
// Drop counter must NOT have incremented — the call fired.
if got := fileWriteRestartDropCounter.Load(); got != 0 {
t.Errorf("first call to maybeRestartAfterFileWrite must fire (drop counter must stay 0), got %d", got)
}
// Last-fire timestamp must be populated (non-zero) so the next call
// will compare against it.
sv, ok := fileWriteRestartLastFireAt.Load(wsID)
if !ok {
t.Fatal("first call must register the workspace in fileWriteRestartLastFireAt")
}
stamp := sv.(*atomic.Int64).Load()
if stamp == 0 {
t.Error("first call must record a non-zero last-fire timestamp")
}
// Wait for the spawned goroutine to finish so it doesn't leak into
// the next test (RestartByID will short-circuit on no-provisioner).
h.waitAsyncForTest()
}
// TestMaybeRestartAfterFileWrite_SecondWriteWithin15sSkipped — the core
// fix: a second call within fileWriteRestartDebounceWindow of the first
// MUST NOT fire RestartByID. The drop counter must increment by exactly
// one and the last-fire timestamp must remain the FIRST call's stamp
// (proof that the second call did not overwrite it).
func TestMaybeRestartAfterFileWrite_SecondWriteWithin15sSkipped(t *testing.T) {
const wsID = "fw-debounce-second-within"
resetFileWriteDebounceState(wsID)
h := newFileWriteDebounceHandler(t)
// First call — fires.
h.maybeRestartAfterFileWrite(wsID)
h.waitAsyncForTest()
sv, _ := fileWriteRestartLastFireAt.Load(wsID)
firstStamp := sv.(*atomic.Int64).Load()
// Second call immediately — must be dropped.
h.maybeRestartAfterFileWrite(wsID)
if got := fileWriteRestartDropCounter.Load(); got != 1 {
t.Errorf("second call within 15s must increment drop counter by exactly 1, got %d", got)
}
// The CAS-loop must NOT have overwritten the first-call stamp — the
// debounce branch short-circuits before the CompareAndSwap.
stampAfter := sv.(*atomic.Int64).Load()
if stampAfter != firstStamp {
t.Errorf("dropped call must NOT update last-fire stamp (preserves debounce window); "+
"first=%d after=%d", firstStamp, stampAfter)
}
}
// TestMaybeRestartAfterFileWrite_ManyWritesInBurstCoalesceToOne — the
// "bonus" regression test called out in the issue: 10 simulated PUTs
// over 60s (compressed to a tight loop, all within 15s) must produce
// exactly 1 RestartByID schedule and 9 drops. Models the canvas Save
// burst shape that triggered the prod incident.
func TestMaybeRestartAfterFileWrite_ManyWritesInBurstCoalesceToOne(t *testing.T) {
const wsID = "fw-debounce-burst"
resetFileWriteDebounceState(wsID)
h := newFileWriteDebounceHandler(t)
// 10 rapid-fire calls — simulates 10 PUTs landing inside the canvas
// Save burst window.
const burstSize = 10
for i := 0; i < burstSize; i++ {
h.maybeRestartAfterFileWrite(wsID)
}
h.waitAsyncForTest()
// One fired (call #1) + 9 dropped.
if got := fileWriteRestartDropCounter.Load(); got != burstSize-1 {
t.Errorf("expected %d drops for a %d-call burst (only call #1 fires), got %d",
burstSize-1, burstSize, got)
}
}
// TestMaybeRestartAfterFileWrite_AfterWindowExpiresFiresAgain — outside
// the debounce window, the helper must release and fire again. Shrinks
// fileWriteRestartDebounceWindow to 5ms so we don't sleep 15s in CI.
// Important: without this, a legitimate "user edited, walked away for
// a minute, edited again" would never restart and config changes would
// never reach the agent.
func TestMaybeRestartAfterFileWrite_AfterWindowExpiresFiresAgain(t *testing.T) {
const wsID = "fw-debounce-window-expires"
resetFileWriteDebounceState(wsID)
orig := fileWriteRestartDebounceWindow
fileWriteRestartDebounceWindow = 5 * time.Millisecond
defer func() { fileWriteRestartDebounceWindow = orig }()
h := newFileWriteDebounceHandler(t)
h.maybeRestartAfterFileWrite(wsID) // fires
h.waitAsyncForTest()
// Wait past the window.
time.Sleep(20 * time.Millisecond)
h.maybeRestartAfterFileWrite(wsID) // must fire again
h.waitAsyncForTest()
// Drop counter must still be 0 — both calls fired.
if got := fileWriteRestartDropCounter.Load(); got != 0 {
t.Errorf("second call after window expiry must fire (not drop), got %d drops", got)
}
}
// TestMaybeRestartAfterFileWrite_DifferentWorkspacesIndependent — the
// per-workspace state map must isolate: a burst on workspace A must not
// affect workspace B's debounce. Pinning so a future "use a single
// global atomic" refactor breaks loudly.
func TestMaybeRestartAfterFileWrite_DifferentWorkspacesIndependent(t *testing.T) {
const wsA = "fw-debounce-ws-a"
const wsB = "fw-debounce-ws-b"
resetFileWriteDebounceState(wsA)
resetFileWriteDebounceState(wsB)
h := newFileWriteDebounceHandler(t)
// 5 calls on A, all but one drop.
for i := 0; i < 5; i++ {
h.maybeRestartAfterFileWrite(wsA)
}
h.waitAsyncForTest()
dropsAfterA := fileWriteRestartDropCounter.Load()
// First call on B — must fire (its own independent window).
h.maybeRestartAfterFileWrite(wsB)
h.waitAsyncForTest()
// B's call must not have incremented the drop counter — it fired.
if got := fileWriteRestartDropCounter.Load(); got != dropsAfterA {
t.Errorf("workspace B's first call must fire (not share workspace A's debounce); "+
"drops after A=%d, drops after B=%d", dropsAfterA, got)
}
// Both workspaces must have their own last-fire entries.
if _, ok := fileWriteRestartLastFireAt.Load(wsA); !ok {
t.Error("workspace A missing from fileWriteRestartLastFireAt")
}
if _, ok := fileWriteRestartLastFireAt.Load(wsB); !ok {
t.Error("workspace B missing from fileWriteRestartLastFireAt")
}
}
// TestMaybeRestartAfterFileWrite_ConcurrentCallsSafelyDebounced — the
// CAS-loop contract: many goroutines hitting the helper concurrently
// must still produce at most one fired call (drops = N-1). Pinning the
// "thousands of writes, one restart" performance shape called out in
// the helper's comment. Uses sync.WaitGroup to release all goroutines
// in a tight burst so the CAS is genuinely contended.
func TestMaybeRestartAfterFileWrite_ConcurrentCallsSafelyDebounced(t *testing.T) {
const wsID = "fw-debounce-concurrent"
resetFileWriteDebounceState(wsID)
h := newFileWriteDebounceHandler(t)
const goroutines = 50
start := make(chan struct{})
var wg sync.WaitGroup
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start // hold every goroutine at the gate
h.maybeRestartAfterFileWrite(wsID)
}()
}
close(start) // release the herd
wg.Wait()
h.waitAsyncForTest()
// Exactly N-1 drops: one goroutine wins the CAS and fires, all
// other N-1 see a fresh stamp and drop into the debounce branch.
if got := fileWriteRestartDropCounter.Load(); got != goroutines-1 {
t.Errorf("expected %d drops for %d concurrent callers (exactly one fires), got %d",
goroutines-1, goroutines, got)
}
}
// TestCoalesceRestart_DrainRespectsRestartedAtBetweenIterations —
// Path B regression: when coalesceRestart drains a pending request into
// a follow-up cycle, the restartStartedAt timestamp must be re-stamped
// for that follow-up iteration. Without this, a RestartByID arriving
// during cycle 2 would hit a stale 60s window (computed from cycle 1's
// start) and could pass the debounce just because cycle 1 + cycle 2's
// runtime exceeded 60s combined.
//
// The test fires cycle 1 → completes → sets pending=true to trigger
// cycle 2 → asserts that restartStartedAt was advanced for the drained
// iteration. The cycle function itself just records the wall-clock at
// which it observed restartStartedAt, so the test can compare cycle 1's
// stamp vs cycle 2's stamp.
func TestCoalesceRestart_DrainRespectsRestartedAtBetweenIterations(t *testing.T) {
const wsID = "fw-debounce-drain-restamp"
resetRestartStatesFor(wsID)
// Capture the restartStartedAt observed at the top of each cycle
// iteration. The cycle reads it directly from the state map so we
// see what coalesceRestart wrote.
var stamps []time.Time
var stampsMu sync.Mutex
cycleCount := 0
cycle := func() {
sv, _ := restartStates.Load(wsID)
state := sv.(*restartState)
state.mu.Lock()
stampsMu.Lock()
stamps = append(stamps, state.restartStartedAt)
stampsMu.Unlock()
state.mu.Unlock()
cycleCount++
if cycleCount == 1 {
// While inside cycle 1, set pending=true so the drain loop
// runs cycle 2 next iteration. Mirrors the prod shape: a
// PUT lands during cycle 1, sets pending=true via
// RestartByID → coalesceRestart's pending branch.
state.mu.Lock()
state.pending = true
state.mu.Unlock()
// Sleep briefly so cycle 2's stamp is observably later
// than cycle 1's. Without a real wall-clock gap the
// assertion can't tell re-stamp from no-op.
time.Sleep(20 * time.Millisecond)
}
}
coalesceRestart(wsID, cycle)
stampsMu.Lock()
defer stampsMu.Unlock()
if len(stamps) != 2 {
t.Fatalf("expected 2 cycle iterations (original + drained pending), got %d", len(stamps))
}
if !stamps[1].After(stamps[0]) {
t.Errorf("Path B regression: cycle 2's restartStartedAt (%v) must be AFTER "+
"cycle 1's (%v) — drained iterations must re-stamp so the self-fire "+
"debounce window resets per cycle. Without this, a RestartByID arriving "+
"during cycle 2 sees a stale window and can chain into cycle 3.",
stamps[1], stamps[0])
}
}
@@ -397,6 +397,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()