Compare commits

..

38 Commits

Author SHA1 Message Date
Molecule AI Dev Engineer A (Kimi) 76a3168671 fix(tests): add model to compute validation test to satisfy MODEL_REQUIRED gate
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 11s
Check migration collisions / Migration version collision check (pull_request) Successful in 11s
CI / Detect changes (pull_request) Successful in 10s
CI / Python Lint & Test (pull_request) Successful in 4s
E2E API Smoke Test / detect-changes (pull_request) Successful in 8s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 7s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 28s
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 3s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 3s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 4s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 6s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m28s
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
E2E Chat / detect-changes (pull_request) Successful in 9s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Failing after 1m8s
E2E Chat / E2E Chat (pull_request) Successful in 5s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 1m14s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m45s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 5s
review-check-tests / review-check.sh regression tests (pull_request) Successful in 10s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 15s
gate-check-v3 / gate-check (pull_request) Successful in 5s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m10s
qa-review / approved (pull_request) Failing after 4s
sop-checklist / na-declarations (pull_request) N/A: (none)
security-review / approved (pull_request) Failing after 5s
sop-checklist / review-refire (pull_request) Has been skipped
sop-checklist / all-items-acked (pull_request) Successful in 5s
sop-tier-check / tier-check (pull_request) Successful in 7s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m15s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m7s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m9s
audit-force-merge / audit (pull_request) Waiting to run
CI / all-required (pull_request) Failing after 40m12s
CI / Platform (Go) (pull_request) Has been cancelled
CI / Canvas (Next.js) (pull_request) Has been cancelled
CI / Shellcheck (E2E scripts) (pull_request) Has been cancelled
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E API Smoke Test / E2E API Smoke Test (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
TestWorkspaceCreate_WithInvalidCompute_ReturnsBadRequest was missing a
model field, so it hit the 422 MODEL_REQUIRED gate (added 2026-05-22)
before reaching compute validation. Adding \"model\":\"gpt-4\" lets the
test reach the intended 400 BadRequest from invalid instance_type.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 19:47:18 +00:00
Molecule AI Dev Engineer A (Kimi) e437e9eabe fix(github-token): add HTTP client timeout to prevent indefinite blocking
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 12s
CI / Python Lint & Test (pull_request) Successful in 5s
E2E API Smoke Test / detect-changes (pull_request) Successful in 6s
E2E Chat / detect-changes (pull_request) Successful in 6s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 5s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 3s
Harness Replays / 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 5s
gate-check-v3 / gate-check (pull_request) Successful in 4s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m5s
qa-review / approved (pull_request) Failing after 4s
sop-checklist / review-refire (pull_request) Has been skipped
sop-checklist / na-declarations (pull_request) N/A: (none)
security-review / approved (pull_request) Failing after 5s
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 3s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 4s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m18s
E2E Chat / E2E Chat (pull_request) Successful in 7s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 5s
Harness Replays / Harness Replays (pull_request) Successful in 2s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Platform (Go) (pull_request) Failing after 4m36s
CI / all-required (pull_request) Failing after 7m45s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2m4s
http.DefaultClient has no timeout, so a slow/unresponsive GitHub API
could block the handler goroutine forever. Use an http.Client with a
30-second timeout in generateAppInstallationToken.

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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 17:17:02 +00:00
hongming 01087ddbe7 Merge pull request #1678 from molecule-ai/fix/ci-path-scope-main-push
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 29s
CI / Python Lint & Test (push) Successful in 12s
CI / Detect changes (push) Successful in 18s
E2E API Smoke Test / detect-changes (push) Successful in 20s
publish-workspace-server-image / build-and-push (push) Successful in 3m24s
Handlers Postgres Integration / detect-changes (push) Successful in 7s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 5s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 5s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 21s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (push) Successful in 7s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 5s
CI / Platform (Go) (push) Successful in 2s
CI / Canvas (Next.js) (push) Successful in 2s
CI / Shellcheck (E2E scripts) (push) Successful in 2s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 1m20s
CI / all-required (push) Successful in 3m4s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 8s
E2E Staging SaaS (full lifecycle) / pr-validate (push) Successful in 42s
publish-workspace-server-image / Production auto-deploy (push) Successful in 4m14s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m27s
CI / Canvas Deploy Reminder (push) Successful in 5s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Successful in 12m51s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (push) Successful in 1m17s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (push) Has been skipped
E2E Staging External Runtime / E2E Staging External Runtime (push) Successful in 5m38s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 8s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 8m42s
E2E Chat / detect-changes (push) Successful in 10s
E2E Chat / E2E Chat (push) Successful in 4m32s
Railway pin audit (drift detection) / Audit Railway env vars for drift-prone pins (push) Failing after 5s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 1m19s
main-red-watchdog / watchdog (push) Successful in 2m5s
gate-check-v3 / gate-check (push) Successful in 23s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 10s
ci-required-drift / drift (push) Successful in 59s
Sweep stale AWS Secrets Manager secrets / Sweep AWS Secrets Manager (push) Successful in 10s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m34s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 4m57s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 7s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 7s
fix(ci): path-scope main push heavy checks
2026-05-22 06:56:41 +00:00
core-fe 3112f394eb fix(ci): path-scope main push heavy checks
audit-force-merge / audit (pull_request) Successful in 10s
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 11s
CI / Detect changes (pull_request) Successful in 15s
CI / Python Lint & Test (pull_request) Successful in 13s
E2E Chat / detect-changes (pull_request) Successful in 15s
E2E API Smoke Test / detect-changes (pull_request) Successful in 18s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 14s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 14s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 7s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 7s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 8s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m36s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 4s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m24s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 1m38s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
gate-check-v3 / gate-check (pull_request) Successful in 6s
sop-checklist / na-declarations (pull_request) N/A: (none)
qa-review / approved (pull_request) Failing after 6s
sop-checklist / all-items-acked (pull_request) Successful in 7s
security-review / approved (pull_request) Failing after 9s
sop-checklist / review-refire (pull_request) Has been skipped
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m43s
sop-tier-check / tier-check (pull_request) Successful in 5s
CI / Platform (Go) (pull_request) Successful in 5s
CI / Canvas (Next.js) (pull_request) Successful in 2s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 2s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 3s
E2E Chat / E2E Chat (pull_request) Successful in 7s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m15s
CI / all-required (pull_request) Successful in 2m57s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 18s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 6s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m24s
2026-05-21 23:51:16 -07:00
hongming 7fb0da3ed5 Merge pull request #1677 from molecule-ai/fix/e2e-wait-after-config-put
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 1m17s
CI / Python Lint & Test (push) Successful in 1m11s
CI / Detect changes (push) Successful in 1m15s
E2E API Smoke Test / detect-changes (push) Successful in 1m11s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 1m0s
E2E Chat / detect-changes (push) Successful in 1m13s
E2E Staging SaaS (full lifecycle) / pr-validate (push) Successful in 1m11s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 16s
Handlers Postgres Integration / detect-changes (push) Successful in 21s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 18s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 26s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 11s
Sweep stale AWS Secrets Manager secrets / Sweep AWS Secrets Manager (push) Successful in 11s
E2E Chat / E2E Chat (push) Successful in 31s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 1m44s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 1m45s
publish-workspace-server-image / build-and-push (push) Successful in 9m53s
CI / Canvas (Next.js) (push) Successful in 7m35s
CI / Canvas Deploy Reminder (push) Successful in 4s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 7m48s
CI / all-required (push) Failing after 16m7s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Failing after 11m32s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Failing after 14m53s
CI / Platform (Go) (push) Failing after 13m31s
publish-workspace-server-image / Production auto-deploy (push) Failing after 9m36s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 6s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 8s
fix(e2e): wait after config save restarts workspace
2026-05-22 06:24:54 +00:00
core-fe 805486e36e fix(e2e): wait after config save restarts workspace
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 16s
CI / Detect changes (pull_request) Successful in 14s
CI / Python Lint & Test (pull_request) Successful in 7s
E2E API Smoke Test / detect-changes (pull_request) Successful in 8s
E2E Chat / detect-changes (pull_request) Successful in 10s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 13s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
Handlers Postgres Integration / detect-changes (pull_request) Successful in 16s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 5s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 35s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 5s
gate-check-v3 / gate-check (pull_request) Successful in 8s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 9s
qa-review / approved (pull_request) Failing after 7s
sop-checklist / na-declarations (pull_request) N/A: (none)
security-review / approved (pull_request) Failing after 4s
sop-checklist / all-items-acked (pull_request) Successful in 4s
sop-checklist / review-refire (pull_request) Has been skipped
CI / Platform (Go) (pull_request) Successful in 2s
sop-tier-check / tier-check (pull_request) Successful in 5s
CI / Canvas (Next.js) (pull_request) Successful in 2s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 12s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 5s
E2E Chat / E2E Chat (pull_request) Successful in 9s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 3s
CI / all-required (pull_request) Successful in 1m35s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m9s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m14s
audit-force-merge / audit (pull_request) Successful in 1m44s
2026-05-21 23:20:24 -07:00
hongming bad6699320 Merge pull request #1672 from molecule-ai/fix/e2e-delegation-a2a-retry
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 9s
CI / Python Lint & Test (push) Successful in 7s
CI / Detect changes (push) Successful in 12s
E2E API Smoke Test / detect-changes (push) Successful in 14s
E2E Chat / detect-changes (push) Successful in 14s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 11s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 7s
Handlers Postgres Integration / detect-changes (push) Successful in 7s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 5s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 4s
CI / Shellcheck (E2E scripts) (push) Successful in 15s
E2E Staging SaaS (full lifecycle) / pr-validate (push) Successful in 36s
E2E Chat / E2E Chat (push) Successful in 26s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 7s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 1m39s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m12s
CI / Platform (Go) (push) Successful in 5m16s
CI / Canvas (Next.js) (push) Successful in 6m6s
CI / Canvas Deploy Reminder (push) Successful in 1s
CI / all-required (push) Successful in 6m44s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Successful in 7m35s
publish-workspace-server-image / build-and-push (push) Successful in 12m34s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m30s
main-red-watchdog / watchdog (push) Successful in 35s
publish-workspace-server-image / Production auto-deploy (push) Successful in 1m55s
gate-check-v3 / gate-check (push) Successful in 22s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Failing after 11m2s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 11s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 23s
ci-required-drift / drift (push) Successful in 1m9s
fix(e2e): retry delegation A2A cold starts
2026-05-22 05:51:26 +00:00
70 changed files with 4377 additions and 460 deletions
+34 -34
View File
@@ -98,10 +98,10 @@ jobs:
--base-ref "$PR_BASE_REF" \
--push-before "${GITHUB_EVENT_BEFORE:-$PUSH_BEFORE}"
# Platform (Go) — Go build/vet/test/lint + coverage gates. The always-run
# + per-step gating shape preserves the GitHub-side required-check name
# contract (so when this Gitea port becomes a required check in Phase 4,
# the name match works on PRs that don't touch workspace-server/).
# Platform (Go) — Go build/vet/test/lint + coverage gates. The job always
# emits the required context, but expensive steps are path-scoped on every
# event so docs/E2E/Canvas-only main pushes do not block deploy on unrelated
# Go bootstrap work.
platform-build:
name: Platform (Go)
needs: changes
@@ -125,29 +125,29 @@ jobs:
run:
working-directory: workspace-server
steps:
- if: ${{ github.event_name == 'pull_request' && needs.changes.outputs.platform != 'true' }}
- if: ${{ needs.changes.outputs.platform != 'true' }}
working-directory: .
run: echo "No workspace-server/** changes on this PR — Platform (Go) gate satisfied without running Go build/test/lint."
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }}
run: echo "No workspace-server/** changes — Platform (Go) gate satisfied without running Go build/test/lint."
- if: ${{ needs.changes.outputs.platform == 'true' }}
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }}
- if: ${{ needs.changes.outputs.platform == 'true' }}
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: 'stable'
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }}
- if: ${{ needs.changes.outputs.platform == 'true' }}
run: go mod download
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }}
- if: ${{ needs.changes.outputs.platform == 'true' }}
run: go build ./cmd/server
# CLI (molecli) moved to standalone repo: git.moleculesai.app/molecule-ai/molecule-cli
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }}
- if: ${{ needs.changes.outputs.platform == 'true' }}
run: go vet ./...
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }}
- if: ${{ needs.changes.outputs.platform == 'true' }}
name: Install golangci-lint
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }}
- if: ${{ needs.changes.outputs.platform == 'true' }}
name: Run golangci-lint
run: $(go env GOPATH)/bin/golangci-lint run --timeout 3m ./...
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }}
- if: ${{ needs.changes.outputs.platform == 'true' }}
name: Diagnostic — per-package verbose 60s
run: |
set +e
@@ -163,7 +163,7 @@ jobs:
echo "::endgroup::"
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
continue-on-error: true
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }}
- if: ${{ needs.changes.outputs.platform == 'true' }}
name: Run tests with race detection and coverage
# Explicit timeout: cold runner cache causes OOM kills at ~4m39s on the
# full ./... suite with race detection + coverage. A 10m per-step timeout
@@ -171,7 +171,7 @@ jobs:
# instead of OOM-killing. The job-level timeout (15m) is a backstop.
run: go test -race -timeout 10m -coverprofile=coverage.out ./...
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }}
- if: ${{ needs.changes.outputs.platform == 'true' }}
name: Per-file coverage report
# Advisory — lists every source file with its coverage so reviewers
# can see at-a-glance where gaps are. Sorted ascending so the worst
@@ -185,7 +185,7 @@ jobs:
END {for (f in s) printf "%6.1f%% %s\n", s[f]/c[f], f}' \
| sort -n
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }}
- if: ${{ needs.changes.outputs.platform == 'true' }}
name: Check coverage thresholds
# Enforces two gates from #1823 Layer 1:
# 1. Total floor (25% — ratchet plan in COVERAGE_FLOOR.md).
@@ -282,20 +282,20 @@ jobs:
run:
working-directory: canvas
steps:
- if: ${{ github.event_name == 'pull_request' && needs.changes.outputs.canvas != 'true' }}
- if: ${{ needs.changes.outputs.canvas != 'true' }}
working-directory: .
run: echo "No canvas/** changes on this PR — Canvas (Next.js) gate satisfied without running npm build/test."
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.canvas == 'true' }}
run: echo "No canvas/** changes — Canvas (Next.js) gate satisfied without running npm build/test."
- if: ${{ needs.changes.outputs.canvas == 'true' }}
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.canvas == 'true' }}
- if: ${{ needs.changes.outputs.canvas == 'true' }}
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '22'
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.canvas == 'true' }}
- if: ${{ needs.changes.outputs.canvas == 'true' }}
run: npm ci --include=optional --prefer-offline
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.canvas == 'true' }}
- if: ${{ needs.changes.outputs.canvas == 'true' }}
run: npm run build
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.canvas == 'true' }}
- if: ${{ needs.changes.outputs.canvas == 'true' }}
name: Run tests with coverage
# Coverage instrumentation is configured in canvas/vitest.config.ts
# (provider: v8, reporters: text + html + json-summary). Step 2 of
@@ -304,7 +304,7 @@ jobs:
# tracked in #1815) after the team sees what current coverage is.
run: npx vitest run --coverage
- name: Upload coverage summary as artifact
if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.canvas == 'true' }}
if: ${{ needs.changes.outputs.canvas == 'true' }}
# Pinned to v3 for Gitea act_runner v0.6 compatibility — v4+ uses
# the GHES 3.10+ artifact protocol that Gitea 1.22.x does NOT
# implement, surfacing as `GHESNotSupportedError: @actions/artifact
@@ -318,7 +318,7 @@ jobs:
retention-days: 7
if-no-files-found: warn
# Shellcheck (E2E scripts) — required check, always runs.
# Shellcheck (E2E scripts) — required context, path-scoped heavy steps.
shellcheck:
name: Shellcheck (E2E scripts)
needs: changes
@@ -326,11 +326,11 @@ jobs:
# Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12.
continue-on-error: false
steps:
- if: ${{ github.event_name == 'pull_request' && needs.changes.outputs.scripts != 'true' }}
run: echo "No tests/e2e, scripts, or infra/scripts changes on this PR — Shellcheck gate satisfied without running script checks."
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.scripts == 'true' }}
- if: ${{ needs.changes.outputs.scripts != 'true' }}
run: echo "No tests/e2e, scripts, or infra/scripts changes — Shellcheck gate satisfied without running script checks."
- if: ${{ needs.changes.outputs.scripts == 'true' }}
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.scripts == 'true' }}
- if: ${{ needs.changes.outputs.scripts == 'true' }}
name: Run shellcheck on tests/e2e/*.sh and infra/scripts/*.sh
# shellcheck is pre-installed on ubuntu-latest runners (via apt).
# infra/scripts/ is included because setup.sh + nuke.sh gate the
@@ -341,16 +341,16 @@ jobs:
find tests/e2e infra/scripts -type f -name '*.sh' -print0 \
| xargs -0 shellcheck --severity=warning
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.scripts == 'true' }}
- if: ${{ needs.changes.outputs.scripts == 'true' }}
name: Lint cleanup-trap hygiene (RFC #2873)
run: bash tests/e2e/lint_cleanup_traps.sh
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.scripts == 'true' }}
- if: ${{ needs.changes.outputs.scripts == 'true' }}
name: Run E2E bash unit tests (no live infra)
run: |
bash tests/e2e/test_model_slug.sh
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.scripts == 'true' }}
- if: ${{ needs.changes.outputs.scripts == 'true' }}
name: Test ECR promote-tenant-image script (mock-driven, no live infra)
# Covers scripts/promote-tenant-image.sh — the codified
# :staging-latest → :latest ECR promote + tenant fleet redeploy
@@ -360,7 +360,7 @@ jobs:
run: |
bash scripts/test-promote-tenant-image.sh
- if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.scripts == 'true' }}
- if: ${{ needs.changes.outputs.scripts == 'true' }}
name: Shellcheck promote-tenant-image script
# scripts/ is excluded from the bulk shellcheck pass above (legacy
# SC3040/SC3043 cleanup pending). Run shellcheck explicitly on
+21 -1
View File
@@ -4,7 +4,7 @@
# use this Makefile; CI calls docker compose / go test directly so the
# Makefile can evolve without breaking the build.
.PHONY: help dev up down logs build test e2e-peer-visibility
.PHONY: help dev up down logs build test e2e-peer-visibility openapi-spec openapi-spec-check
help: ## Show this help.
@grep -E '^[a-zA-Z0-9_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-22s\033[0m %s\n", $$1, $$2}'
@@ -36,3 +36,23 @@ test: ## Run Go unit tests in workspace-server/.
# env contract (CLAUDE_CODE_OAUTH_TOKEN / E2E_MINIMAX_API_KEY / etc).
e2e-peer-visibility: ## Run the LOCAL peer-visibility MCP gate vs the running stack (needs `make up` first).
bash tests/e2e/test_peer_visibility_mcp_local.sh
# ─── OpenAPI spec generation (RFC #1706, Phase 1) ─────────────────────
# Regenerate workspace-server/docs/openapi/swagger.{yaml,json} from
# swaggo annotations on the gin handlers. Commit the output. CI runs
# `make openapi-spec-check` to assert no drift between annotations and
# the committed file — if a PR changes a handler but forgets to
# regenerate, CI fails with a diff.
openapi-spec: ## Regenerate OpenAPI spec from workspace-server handler annotations.
@command -v swag >/dev/null 2>&1 || go install github.com/swaggo/swag/cmd/swag@v1.16.4
cd workspace-server && swag init \
--generalInfo cmd/server/main.go \
--output docs/openapi \
--outputTypes yaml,json \
--dir . \
--parseDependency=false \
--parseInternal=true
openapi-spec-check: openapi-spec ## CI gate — fail if openapi-spec produces a diff vs the committed file.
@git diff --exit-code -- workspace-server/docs/openapi/ \
|| (echo "openapi-spec is stale — run 'make openapi-spec' and commit the result" && exit 1)
+6
View File
@@ -9,6 +9,8 @@ import { DetailsTab } from "./tabs/DetailsTab";
import { SkillsTab } from "./tabs/SkillsTab";
import { ChatTab } from "./tabs/ChatTab";
import { ConfigTab } from "./tabs/ConfigTab";
import { ContainerConfigTab } from "./tabs/ContainerConfigTab";
import { DisplayTab } from "./tabs/DisplayTab";
import { TerminalTab } from "./tabs/TerminalTab";
import { FilesTab } from "./tabs/FilesTab";
import { MemoryInspectorPanel } from "./MemoryInspectorPanel";
@@ -31,6 +33,8 @@ const TABS: { id: PanelTab; label: string; icon: string }[] = [
{ id: "details", label: "Details", icon: "◉" },
{ id: "skills", label: "Plugins", icon: "✦" },
{ id: "terminal", label: "Terminal", icon: "▸" },
{ id: "display", label: "Display", icon: "▣" },
{ id: "container-config", label: "Container", icon: "▤" },
{ id: "config", label: "Config", icon: "⚙" },
{ id: "schedule", label: "Schedule", icon: "⏲" },
{ id: "channels", label: "Channels", icon: "⇌" },
@@ -300,6 +304,8 @@ export function SidePanel() {
{panelTab === "activity" && <ActivityTab key={selectedNodeId} workspaceId={selectedNodeId} />}
{panelTab === "chat" && <ChatTab key={selectedNodeId} workspaceId={selectedNodeId} data={node.data} />}
{panelTab === "terminal" && <TerminalTab key={selectedNodeId} workspaceId={selectedNodeId} data={node.data} />}
{panelTab === "display" && <DisplayTab key={selectedNodeId} workspaceId={selectedNodeId} />}
{panelTab === "container-config" && <ContainerConfigTab key={selectedNodeId} data={node.data} />}
{panelTab === "config" && <ConfigTab key={selectedNodeId} workspaceId={selectedNodeId} />}
{panelTab === "schedule" && <ScheduleTab key={selectedNodeId} workspaceId={selectedNodeId} />}
{panelTab === "channels" && <ChannelsTab key={selectedNodeId} workspaceId={selectedNodeId} />}
@@ -11,6 +11,8 @@ vi.mock("../tabs/DetailsTab", () => ({ DetailsTab: () => null }));
vi.mock("../tabs/SkillsTab", () => ({ SkillsTab: () => null }));
vi.mock("../tabs/ChatTab", () => ({ ChatTab: () => null }));
vi.mock("../tabs/ConfigTab", () => ({ ConfigTab: () => null }));
vi.mock("../tabs/ContainerConfigTab", () => ({ ContainerConfigTab: () => null }));
vi.mock("../tabs/DisplayTab", () => ({ DisplayTab: () => null }));
vi.mock("../tabs/TerminalTab", () => ({ TerminalTab: () => null }));
vi.mock("../tabs/FilesTab", () => ({ FilesTab: () => null }));
vi.mock("../MemoryInspectorPanel", () => ({ MemoryInspectorPanel: () => null }));
@@ -74,7 +76,7 @@ import { SidePanel } from "../SidePanel";
const TABS = [
"chat", "activity", "details", "skills", "terminal",
"config", "schedule", "channels", "files", "memory", "traces", "events", "audit",
"display", "container-config", "config", "schedule", "channels", "files", "memory", "traces", "events", "audit",
];
describe("SidePanel — ARIA tablist pattern", () => {
@@ -85,10 +87,20 @@ describe("SidePanel — ARIA tablist pattern", () => {
expect(tablist.getAttribute("aria-label")).toBe("Workspace panel tabs");
});
it("renders exactly 13 tab buttons", () => {
it("renders exactly 15 tab buttons", () => {
render(<SidePanel />);
const tabs = screen.getAllByRole("tab");
expect(tabs.length).toBe(13);
expect(tabs.length).toBe(15);
});
it("renders the Display tab", () => {
render(<SidePanel />);
expect(document.getElementById("tab-display")).toBeTruthy();
});
it("renders the Container Config tab", () => {
render(<SidePanel />);
expect(document.getElementById("tab-container-config")).toBeTruthy();
});
it("active tab (chat) has aria-selected='true'", () => {
@@ -99,11 +111,11 @@ describe("SidePanel — ARIA tablist pattern", () => {
expect(chatTab?.getAttribute("aria-selected")).toBe("true");
});
it("all other 12 tabs have aria-selected='false'", () => {
it("all other 14 tabs have aria-selected='false'", () => {
render(<SidePanel />);
const tabs = screen.getAllByRole("tab");
const inactive = tabs.filter((t) => t.id !== "tab-chat");
expect(inactive.length).toBe(12);
expect(inactive.length).toBe(14);
for (const tab of inactive) {
expect(tab.getAttribute("aria-selected")).toBe("false");
}
@@ -116,7 +128,7 @@ describe("SidePanel — ARIA tablist pattern", () => {
const minusOnes = tabs.filter((t) => t.getAttribute("tabindex") === "-1");
expect(zeros.length).toBe(1);
expect(zeros[0].id).toBe("tab-chat");
expect(minusOnes.length).toBe(12);
expect(minusOnes.length).toBe(14);
});
it("active tab has aria-controls='panel-chat' and id='tab-chat'", () => {
@@ -0,0 +1,96 @@
"use client";
import { runtimeDisplayName } from "@/lib/runtime-names";
import type { WorkspaceNodeData } from "@/store/canvas";
type Props = {
data: Pick<
WorkspaceNodeData,
"runtime" | "status" | "needsRestart" | "activeTasks" | "deliveryMode"
| "workspaceAccess" | "maxConcurrentTasks"
>;
};
export function ContainerConfigTab({ data }: Props) {
const runtime = data.runtime || "unknown";
const workspaceAccess = formatAccess(data.workspaceAccess);
const maxConcurrentTasks = data.maxConcurrentTasks ? String(data.maxConcurrentTasks) : "platform-managed";
const mountedPath = "/workspace";
const privilegeStatus = "standard";
const deliveryMode = data.deliveryMode || "push";
return (
<div className="p-4 space-y-4">
<section className="rounded-lg border border-line/50 bg-surface-card/40 p-4">
<div className="mb-3">
<h3 className="text-sm font-semibold text-ink">Container Config</h3>
</div>
<dl className="grid grid-cols-1 gap-2 text-[11px]">
<ConfigRow label="Runtime image" value={runtimeDisplayName(runtime)} detail={runtime} />
<ConfigRow label="Workspace access" value={workspaceAccess} />
<ConfigRow label="Max concurrent tasks" value={maxConcurrentTasks} />
<ConfigRow label="Mounted workspace path" value={mountedPath} />
<ConfigRow label="Container privileges" value={privilegeStatus} />
<ConfigRow label="Delivery mode" value={deliveryMode} />
</dl>
</section>
<section className="rounded-lg border border-line/50 bg-surface-card/40 p-4">
<h3 className="mb-3 text-sm font-semibold text-ink">Session Controls</h3>
<div className="grid grid-cols-2 gap-2">
<ReadOnlyAction label={data.needsRestart ? "Restart required" : "Restart"} />
<ReadOnlyAction label="Reset session" />
</div>
</section>
<section className="rounded-lg border border-line/50 bg-surface-card/40 p-4">
<h3 className="mb-3 text-sm font-semibold text-ink">Status</h3>
<dl className="grid grid-cols-1 gap-2 text-[11px]">
<ConfigRow label="Container status" value={data.status} />
<ConfigRow label="Active tasks" value={String(data.activeTasks ?? 0)} />
<ConfigRow label="Mounted path access" value="available" />
</dl>
</section>
</div>
);
}
function formatAccess(value: string | null | undefined): string {
if (!value) return "none";
return value.replace(/_/g, "-");
}
function ConfigRow({
label,
value,
detail,
}: {
label: string;
value: string;
detail?: string;
}) {
return (
<div className="flex items-start justify-between gap-3 rounded-md bg-surface-sunken/40 px-3 py-2">
<dt className="text-ink-mid">{label}</dt>
<dd className="min-w-0 text-right">
<div className="font-mono text-ink break-words">{value}</div>
{detail && detail !== value && (
<div className="mt-0.5 font-mono text-[10px] text-ink-mid break-words">{detail}</div>
)}
</dd>
</div>
);
}
function ReadOnlyAction({ label }: { label: string }) {
return (
<button
type="button"
disabled
className="rounded-md border border-line/50 bg-surface-sunken/40 px-3 py-2 text-[11px] text-ink-mid disabled:cursor-not-allowed disabled:opacity-70"
>
{label}
</button>
);
}
+196
View File
@@ -0,0 +1,196 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/api";
interface DisplayStatus {
available: boolean;
reason?: string;
mode?: string;
status?: string;
protocol?: string;
width?: number;
height?: number;
}
interface DisplayControlStatus {
controller: "none" | "user" | "agent";
controlled_by?: string;
expires_at?: string;
}
interface Props {
workspaceId: string;
}
export function DisplayTab({ workspaceId }: Props) {
const [status, setStatus] = useState<DisplayStatus | null>(null);
const [control, setControl] = useState<DisplayControlStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const [controlError, setControlError] = useState<string | null>(null);
const [controlBusy, setControlBusy] = useState(false);
const requestGeneration = useRef(0);
useEffect(() => {
const generation = requestGeneration.current + 1;
requestGeneration.current = generation;
let cancelled = false;
setStatus(null);
setControl(null);
setError(null);
setControlError(null);
setControlBusy(false);
async function load() {
try {
const displayStatus = await api.get<DisplayStatus>(`/workspaces/${workspaceId}/display`);
if (cancelled || requestGeneration.current !== generation) return;
setStatus(displayStatus);
if (displayStatus.reason === "display_not_enabled") return;
try {
const displayControl = await api.get<DisplayControlStatus>(`/workspaces/${workspaceId}/display/control`);
if (!cancelled && requestGeneration.current === generation) setControl(displayControl);
} catch (err) {
if (!cancelled && requestGeneration.current === generation) {
setControl(null);
setControlError("Display control unavailable");
}
}
} catch (err) {
if (!cancelled && requestGeneration.current === generation) setError("The display status could not be loaded.");
}
}
load();
return () => {
cancelled = true;
};
}, [workspaceId]);
const acquireControl = async () => {
const generation = requestGeneration.current;
const controlPath = `/workspaces/${workspaceId}/display/control`;
setControlBusy(true);
setControlError(null);
try {
const next = await api.post<DisplayControlStatus>(`${controlPath}/acquire`, {
controller: "user",
ttl_seconds: 300,
});
if (requestGeneration.current !== generation) return;
setControl(next);
} catch (err) {
if (requestGeneration.current !== generation) return;
setControlError("Failed to take control");
try {
const latest = await api.get<DisplayControlStatus>(controlPath);
if (requestGeneration.current !== generation) return;
setControl(latest);
} catch {
if (requestGeneration.current !== generation) return;
setControl(null);
}
} finally {
if (requestGeneration.current === generation) setControlBusy(false);
}
};
if (error) {
return (
<div className="p-5">
<div className="rounded-lg border border-red-500/20 bg-red-950/20 p-4">
<h3 className="text-sm font-medium text-red-200">Display status unavailable</h3>
<p className="mt-2 text-[11px] leading-relaxed text-red-200/75">{error}</p>
</div>
</div>
);
}
if (!status) {
return (
<div className="p-5">
<div className="h-24 rounded-lg border border-line/40 bg-surface-sunken/30 motion-safe:animate-pulse" />
</div>
);
}
if (!status.available) {
const isNotEnabled = status.reason === "display_not_enabled";
return (
<div className="flex min-h-full flex-col items-center justify-center bg-surface-sunken/30 p-8 text-center">
<svg
width="72"
height="72"
viewBox="0 0 72 72"
fill="none"
aria-hidden="true"
className="mb-4 text-ink-mid"
>
<rect x="12" y="14" width="48" height="36" rx="4" stroke="currentColor" strokeWidth="2.5" opacity="0.65" />
<path d="M28 58h16M36 50v8M16 16l40 40" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
</svg>
<h3 className="mb-1.5 text-sm font-medium text-ink">
{isNotEnabled ? "Display is not enabled for this workspace." : "Display session is not ready."}
</h3>
<p className="max-w-xs text-[11px] leading-relaxed text-ink-mid">
{isNotEnabled
? "Recreate this workspace with display enabled to view and take over its desktop."
: "This workspace has display configuration, but the desktop session infrastructure is not configured yet."}
</p>
{!isNotEnabled && (
<>
<dl className="mt-5 grid grid-cols-2 gap-x-4 gap-y-2 text-left text-[11px]">
<dt className="text-ink-mid">Mode</dt>
<dd className="font-mono text-ink">{status.mode || "unknown"}</dd>
<dt className="text-ink-mid">Status</dt>
<dd className="font-mono text-ink">{status.status || "unknown"}</dd>
</dl>
<div className="mt-5 w-full max-w-xs border-t border-line/50 pt-4">
{control ? (
<div className="flex items-center justify-between gap-3 text-left">
<div className="min-w-0">
<p className="text-[11px] font-medium text-ink">
{control.controller === "none"
? "No active controller"
: `Controlled by ${displayControlActorLabel(control)}`}
</p>
{control.expires_at && (
<p className="mt-1 truncate font-mono text-[10px] text-ink-mid">
Until {new Date(control.expires_at).toLocaleTimeString()}
</p>
)}
{controlError && <p className="mt-1 text-[10px] leading-snug text-red-200">{controlError}</p>}
</div>
{control.controller === "none" && (
<button
type="button"
onClick={acquireControl}
disabled={controlBusy}
className="h-8 shrink-0 rounded border border-line bg-surface px-3 text-[11px] font-medium text-ink hover:bg-surface-elevated disabled:cursor-not-allowed disabled:opacity-60"
>
Take control
</button>
)}
</div>
) : (
<div className="text-left">
{!controlError && (
<div className="h-8 rounded border border-line/40 bg-surface-sunken/30 motion-safe:animate-pulse" />
)}
{controlError && <p className="mt-2 text-[10px] leading-snug text-red-200">{controlError}</p>}
</div>
)}
</div>
</>
)}
</div>
);
}
return null;
}
function displayControlActorLabel(control: DisplayControlStatus): string {
if (control.controller === "agent") return "Agent";
if (control.controlled_by === "admin-token") return "Admin";
if (control.controlled_by?.startsWith("org-token:")) return "Automation";
return "User";
}
@@ -0,0 +1,42 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/runtime-names", () => ({
runtimeDisplayName: (runtime: string) => runtime,
}));
import { ContainerConfigTab } from "../ContainerConfigTab";
afterEach(() => {
cleanup();
});
describe("ContainerConfigTab", () => {
it("renders read-only runtime and container settings separate from compute shape", () => {
render(
<ContainerConfigTab
data={{
runtime: "claude-code",
status: "online",
needsRestart: false,
activeTasks: 2,
maxConcurrentTasks: 3,
workspaceAccess: "read_write",
deliveryMode: "poll",
}}
/>,
);
expect(screen.getByText("Runtime image")).toBeTruthy();
expect(screen.getByText("claude-code")).toBeTruthy();
expect(screen.getByText("Workspace access")).toBeTruthy();
expect(screen.getByText("read-write")).toBeTruthy();
expect(screen.getByText("Max concurrent tasks")).toBeTruthy();
expect(screen.getByText("3")).toBeTruthy();
expect(screen.getByText("/workspace")).toBeTruthy();
expect(screen.getByText("Container privileges")).toBeTruthy();
expect(screen.queryByText("Instance type")).toBeNull();
expect(screen.queryByText("Root volume")).toBeNull();
});
});
@@ -0,0 +1,250 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
const { mockGet, mockPost } = vi.hoisted(() => ({ mockGet: vi.fn(), mockPost: vi.fn() }));
vi.mock("@/lib/api", () => ({
api: {
get: mockGet,
post: mockPost,
},
}));
import { DisplayTab } from "../DisplayTab";
describe("DisplayTab", () => {
beforeEach(() => {
cleanup();
mockGet.mockReset();
mockPost.mockReset();
});
it("renders unavailable state for non-display workspaces", async () => {
mockGet.mockResolvedValueOnce({
available: false,
reason: "display_not_enabled",
});
render(<DisplayTab workspaceId="ws-no-display" />);
await waitFor(() => {
expect(screen.getByText("Display is not enabled for this workspace.")).toBeTruthy();
});
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-no-display/display");
expect(mockGet).not.toHaveBeenCalledWith("/workspaces/ws-no-display/display/control");
});
it("renders control acquisition for display-configured workspaces", async () => {
mockGet
.mockResolvedValueOnce({
available: false,
reason: "display_session_unavailable",
mode: "desktop-control",
status: "not_configured",
})
.mockResolvedValueOnce({
controller: "none",
});
mockPost.mockResolvedValueOnce({
controller: "user",
controlled_by: "admin-token",
expires_at: "2026-05-23T08:48:27Z",
});
render(<DisplayTab workspaceId="ws-display" />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
});
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-display/display");
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-display/display/control");
fireEvent.click(screen.getByRole("button", { name: "Take control" }));
await waitFor(() => {
expect(screen.getByText("Controlled by Admin")).toBeTruthy();
});
expect(mockPost).toHaveBeenCalledWith("/workspaces/ws-display/display/control/acquire", {
controller: "user",
ttl_seconds: 300,
});
});
it("renders active display control locks as observe-only", async () => {
mockGet
.mockResolvedValueOnce({
available: false,
reason: "display_session_unavailable",
mode: "desktop-control",
status: "not_configured",
})
.mockResolvedValueOnce({
controller: "agent",
controlled_by: "sidecar",
expires_at: "2026-05-23T08:48:27Z",
});
render(<DisplayTab workspaceId="ws-display" />);
await waitFor(() => {
expect(screen.getByText("Controlled by Agent")).toBeTruthy();
});
expect(screen.queryByRole("button", { name: "Release" })).toBeNull();
expect(screen.queryByRole("button", { name: "Take control" })).toBeNull();
expect(mockPost).not.toHaveBeenCalled();
});
it("labels org-token display control locks as automation", async () => {
mockGet
.mockResolvedValueOnce({
available: false,
reason: "display_session_unavailable",
mode: "desktop-control",
status: "not_configured",
})
.mockResolvedValueOnce({
controller: "user",
controlled_by: "org-token:abc123",
expires_at: "2026-05-23T08:48:27Z",
});
render(<DisplayTab workspaceId="ws-display" />);
await waitFor(() => {
expect(screen.getByText("Controlled by Automation")).toBeTruthy();
});
expect(screen.queryByText("org-token:abc123")).toBeNull();
expect(screen.queryByRole("button", { name: "Take control" })).toBeNull();
});
it("refreshes display control state after failed acquisition", async () => {
mockGet
.mockResolvedValueOnce({
available: false,
reason: "display_session_unavailable",
mode: "desktop-control",
status: "not_configured",
})
.mockResolvedValueOnce({
controller: "none",
})
.mockResolvedValueOnce({
controller: "agent",
controlled_by: "sidecar",
expires_at: "2026-05-23T08:48:27Z",
});
mockPost.mockRejectedValueOnce(new Error("API POST /workspaces/ws-display/display/control/acquire: 409 conflict"));
render(<DisplayTab workspaceId="ws-display" />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: "Take control" }));
await waitFor(() => {
expect(screen.getByText("Controlled by Agent")).toBeTruthy();
});
expect(screen.getByText("Failed to take control")).toBeTruthy();
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-display/display/control");
expect(mockGet).toHaveBeenCalledTimes(3);
expect(mockPost).toHaveBeenCalledWith("/workspaces/ws-display/display/control/acquire", {
controller: "user",
ttl_seconds: 300,
});
});
it("keeps display status visible without takeover actions when control status fails", async () => {
mockGet
.mockResolvedValueOnce({
available: false,
reason: "display_session_unavailable",
mode: "desktop-control",
status: "not_configured",
})
.mockRejectedValueOnce(new Error("API GET /workspaces/ws-display/display/control: 401 unauthorized"));
render(<DisplayTab workspaceId="ws-display" />);
await waitFor(() => {
expect(screen.getByText("Display session is not ready.")).toBeTruthy();
});
expect(screen.queryByRole("button", { name: "Take control" })).toBeNull();
expect(screen.getByText("Display control unavailable")).toBeTruthy();
});
it("does not render raw display status errors", async () => {
mockGet.mockRejectedValueOnce(new Error("API GET /workspaces/ws-display/display: 500 secret backend details"));
render(<DisplayTab workspaceId="ws-display" />);
await waitFor(() => {
expect(screen.getByText("Display status unavailable")).toBeTruthy();
});
expect(screen.queryByText(/secret backend details/)).toBeNull();
});
it("ignores stale acquire responses after workspace changes", async () => {
const acquire = deferred<{ controller: "user"; controlled_by: string; expires_at: string }>();
mockGet
.mockResolvedValueOnce({
available: false,
reason: "display_session_unavailable",
mode: "desktop-control",
status: "not_configured",
})
.mockResolvedValueOnce({
controller: "none",
})
.mockResolvedValueOnce({
available: false,
reason: "display_session_unavailable",
mode: "desktop-control",
status: "not_configured",
})
.mockResolvedValueOnce({
controller: "none",
});
mockPost.mockReturnValueOnce(acquire.promise);
const { rerender } = render(<DisplayTab workspaceId="ws-a" />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: "Take control" }));
rerender(<DisplayTab workspaceId="ws-b" />);
await waitFor(() => {
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-b/display/control");
});
await waitFor(() => {
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
});
acquire.resolve({
controller: "user",
controlled_by: "admin-token",
expires_at: "2026-05-23T08:48:27Z",
});
await acquire.promise;
await waitFor(() => {
expect(screen.queryByText("Controlled by Admin")).toBeNull();
});
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
});
});
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
+2
View File
@@ -513,6 +513,8 @@ export function buildNodesAndEdges(
parentId: ws.parent_id,
currentTask: ws.current_task || "",
runtime: ws.runtime || "",
workspaceAccess: ws.workspace_access,
maxConcurrentTasks: ws.max_concurrent_tasks ?? null,
needsRestart: false,
budgetLimit: ws.budget_limit ?? null,
budgetUsed: ws.budget_used ?? null,
+3 -1
View File
@@ -88,6 +88,8 @@ export interface WorkspaceNodeData extends Record<string, unknown> {
parentId: string | null;
currentTask: string;
runtime: string;
workspaceAccess?: string | null;
maxConcurrentTasks?: number | null;
needsRestart: boolean;
/** USD spend ceiling set by the user; null = unlimited. Added by issue #541. */
budgetLimit: number | null;
@@ -130,7 +132,7 @@ export interface WorkspaceNodeData extends Record<string, unknown> {
deliveryMode?: string;
}
export type PanelTab = "details" | "skills" | "chat" | "terminal" | "config" | "schedule" | "channels" | "files" | "memory" | "traces" | "events" | "activity" | "audit";
export type PanelTab = "details" | "skills" | "chat" | "terminal" | "display" | "container-config" | "config" | "schedule" | "channels" | "files" | "memory" | "traces" | "events" | "activity" | "audit";
export interface ContextMenuState {
x: number;
+2
View File
@@ -320,11 +320,13 @@ export interface WorkspaceData {
url: string;
parent_id: string | null;
active_tasks: number;
max_concurrent_tasks?: number | null;
last_error_rate: number;
last_sample_error: string;
uptime_seconds: number;
current_task: string;
runtime: string;
workspace_access?: string | null;
x: number;
y: number;
collapsed: boolean;
+5 -2
View File
@@ -50,13 +50,16 @@ docker rm $(docker ps -aq --filter "name=ws-") 2>/dev/null || true
echo ""
echo "--- Create Workspaces ---"
# model is required at the Create boundary (CTO 2026-05-22 SSOT —
# feedback_workspace_model_required_no_platform_default_dynamic_credential_intake).
# Pass the same value the deleted DefaultModel("claude-code") returned.
ROOT=$(curl -s -X POST $PLATFORM/workspaces -H "Content-Type: application/json" \
-d '{"name":"Root Agent","role":"Company coordinator","runtime":"claude-code","tier":3}' \
-d '{"name":"Root Agent","role":"Company coordinator","runtime":"claude-code","model":"sonnet","tier":3}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
check_contains "Create root workspace" "-" "$ROOT"
CHILD=$(curl -s -X POST $PLATFORM/workspaces -H "Content-Type: application/json" \
-d "{\"name\":\"Child Agent\",\"role\":\"Sub-team member\",\"runtime\":\"claude-code\",\"tier\":2,\"parent_id\":\"$ROOT\"}" \
-d "{\"name\":\"Child Agent\",\"role\":\"Sub-team member\",\"runtime\":\"claude-code\",\"model\":\"sonnet\",\"tier\":2,\"parent_id\":\"$ROOT\"}" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
check_contains "Create child workspace" "-" "$CHILD"
+5 -1
View File
@@ -92,8 +92,12 @@ for _wid in $PRIOR; do
curl -s -X DELETE "$BASE/workspaces/$_wid?confirm=true" > /dev/null || true
done
# model is required at the Create boundary (CTO 2026-05-22 SSOT — see
# feedback_workspace_model_required_no_platform_default_dynamic_credential_intake).
# Body had no runtime → defaults to langgraph; pass the langgraph-compatible
# default that the deleted DefaultModel("") would have returned.
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
-d '{"name":"Notify E2E","tier":1}')
-d '{"name":"Notify E2E","tier":1,"model":"anthropic:claude-opus-4-7"}')
WSID=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' 2>/dev/null || true)
[ -n "$WSID" ] || { echo "Failed to create workspace: $R"; exit 1; }
echo "Created workspace $WSID"
+19 -2
View File
@@ -241,8 +241,24 @@ else
fi
log "1/5 provisioning parent ($PARENT_RUNTIME, mode=$PV_LOCAL_PROVISION_MODE) + one sibling per runtime under test..."
# Map runtime → model per the CTO 2026-05-22 SSOT directive (model is
# required, no platform default). External runtimes are exempt by the
# Create-handler gate — for them the URL is the contract — but we still
# pass model="external:custom" defensively in case a downstream consumer
# of the create body asserts presence.
_model_for_runtime() {
case "$1" in
claude-code) echo "sonnet" ;;
codex) echo "gpt-5.5" ;;
kimi) echo "kimi-coding/kimi-k2-coding-6" ;;
minimax) echo "minimax/MiniMax-M2.7" ;;
external) echo "external:custom" ;;
*) echo "anthropic:claude-opus-4-7" ;;
esac
}
PARENT_MODEL=$(_model_for_runtime "$PARENT_RUNTIME")
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}")
-d "{\"name\":\"${NAME_PREFIX}-parent\",\"runtime\":\"$PARENT_RUNTIME\",\"model\":\"$PARENT_MODEL\",\"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
echo "::error::parent create failed: $(echo "$P_RESP" | head -c 300)" >&2
@@ -291,8 +307,9 @@ for rt in $PV_RUNTIMES; do
CREATE_RUNTIME="$rt"
CREATE_EXTRA=""
fi
CREATE_MODEL=$(_model_for_runtime "$CREATE_RUNTIME")
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}")
-d "{\"name\":\"${NAME_PREFIX}-$rt\",\"runtime\":\"$CREATE_RUNTIME\",\"model\":\"$CREATE_MODEL\",\"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
echo "::error::$rt workspace create failed: $(echo "$R" | head -c 300)" >&2
+4 -2
View File
@@ -188,8 +188,9 @@ import json, os
print(json.dumps({'CLAUDE_CODE_OAUTH_TOKEN': os.environ['CLAUDE_CODE_OAUTH_TOKEN']}))
")
local resp wsid
# model required (CTO 2026-05-22 SSOT) — pass the deleted DefaultModel("claude-code") value.
resp=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
-d "{\"name\":\"Priority E2E (claude-code)\",\"runtime\":\"claude-code\",\"tier\":1,\"secrets\":$secrets}")
-d "{\"name\":\"Priority E2E (claude-code)\",\"runtime\":\"claude-code\",\"model\":\"sonnet\",\"tier\":1,\"secrets\":$secrets}")
wsid=$(echo "$resp" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("id",""))') || true
if [ -z "$wsid" ]; then
fail "create claude-code workspace" "$resp"
@@ -380,8 +381,9 @@ import json, os
print(json.dumps({'GEMINI_API_KEY': os.environ['E2E_GEMINI_API_KEY']}))
")
local resp wsid
# model required (CTO 2026-05-22 SSOT) — gemini-cli routes via the gemini provider.
resp=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
-d "{\"name\":\"Priority E2E (gemini-cli)\",\"runtime\":\"gemini-cli\",\"tier\":1,\"secrets\":$secrets}")
-d "{\"name\":\"Priority E2E (gemini-cli)\",\"runtime\":\"gemini-cli\",\"model\":\"gemini-2.0-flash\",\"tier\":1,\"secrets\":$secrets}")
wsid=$(echo "$resp" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("id",""))') || true
if [ -z "$wsid" ]; then fail "create gemini-cli workspace" "$resp"; return 0; fi
CREATED_WSIDS+=("$wsid")
+78 -56
View File
@@ -25,6 +25,11 @@
# Optional env:
# E2E_RUNTIME hermes (default) | claude-code | langgraph
# E2E_PROVISION_TIMEOUT_SECS default 900 (15 min cold EC2 budget)
# E2E_WORKSPACE_ONLINE_TIMEOUT_SECS default 3600 (60 min — hermes
# cold-boot worst-case + slack). Raised from
# 1800 (#1646) because flaky tenant-provisioning
# latency (not a code regression) causes
# alternating pass/fail on identical SHAs.
# E2E_KEEP_ORG 1 → skip teardown (debugging only)
# E2E_RUN_ID Slug suffix; CI: ${GITHUB_RUN_ID}
# E2E_MODE full (default) | smoke
@@ -56,6 +61,7 @@ CP_URL="${MOLECULE_CP_URL:-https://staging-api.moleculesai.app}"
ADMIN_TOKEN="${MOLECULE_ADMIN_TOKEN:?MOLECULE_ADMIN_TOKEN required — Railway staging CP_ADMIN_API_TOKEN}"
RUNTIME="${E2E_RUNTIME:-hermes}"
PROVISION_TIMEOUT_SECS="${E2E_PROVISION_TIMEOUT_SECS:-900}"
WORKSPACE_ONLINE_TIMEOUT_SECS="${E2E_WORKSPACE_ONLINE_TIMEOUT_SECS:-3600}"
RUN_ID_SUFFIX="${E2E_RUN_ID:-$(date +%H%M%S)-$$}"
MODE="${E2E_MODE:-full}"
# `canary` is a legacy alias for `smoke` retained for back-compat with
@@ -361,6 +367,64 @@ print(s[:4000])
'
}
wait_workspaces_online_routable() {
local label="$1"; shift
local deadline=$(( $(date +%s) + WORKSPACE_ONLINE_TIMEOUT_SECS ))
local wid ws_last_status ws_last_url ws_url_missing_logged ws_failed_logged
local ws_json ws_status ws_url ws_last_err
log "$label"
for wid in "$@"; do
ws_last_status=""
ws_last_url=""
ws_url_missing_logged=0
ws_failed_logged=0
while true; do
if [ "$(date +%s)" -gt "$deadline" ]; then
ws_last_err=$(tenant_call GET "/workspaces/$wid" 2>/dev/null | \
python3 -c "import json,sys; print(json.load(sys.stdin).get('last_sample_error',''))" 2>/dev/null || echo "")
fail "Workspace $wid never reached online with a routable URL within ${WORKSPACE_ONLINE_TIMEOUT_SECS}s (~$((WORKSPACE_ONLINE_TIMEOUT_SECS/60)) min) (last status=$ws_last_status, url=$ws_last_url, err=$ws_last_err)"
fi
ws_json=$(tenant_call GET "/workspaces/$wid" 2>/dev/null || echo '{}')
ws_status=$(echo "$ws_json" | python3 -c "import json,sys; print(json.load(sys.stdin).get('status') or '')" 2>/dev/null)
ws_url=$(echo "$ws_json" | python3 -c "import json,sys; print(json.load(sys.stdin).get('url') or '')" 2>/dev/null)
if [ "$ws_status" != "$ws_last_status" ]; then
log " $wid$ws_status"
ws_last_status="$ws_status"
fi
if [ -n "$ws_url" ] && [ "$ws_url" != "$ws_last_url" ]; then
log " $wid url ready: $ws_url"
ws_last_url="$ws_url"
fi
case "$ws_status" in
online)
if [ -n "$ws_url" ]; then
break
fi
if [ "$ws_url_missing_logged" = "0" ]; then
log " $wid online but URL is not assigned yet — waiting for workspace routing readiness"
ws_url_missing_logged=1
fi
sleep 10
;;
failed)
# Not a hard fail — bootstrap-watcher frequently marks failed at
# 5 min on hermes, then heartbeat recovers to online around 10-13
# min when install.sh finishes. Log once per workspace so the CI
# output isn't spammy.
if [ "$ws_failed_logged" = "0" ]; then
log " $wid transiently failed — waiting for heartbeat recovery (bootstrap-watcher deadline, see cp#245)"
ws_failed_logged=1
fi
sleep 10
;;
*) sleep 10 ;;
esac
done
ok " $wid online and routable"
done
}
# ─── 5. Provision parent workspace ─────────────────────────────────────
# Inject the LLM provider key so the runtime can authenticate at boot.
# Branch by which secret is set so the script supports multiple paths
@@ -468,64 +532,16 @@ fi
# deadline fires at 5 min and sets status=failed prematurely; heartbeat
# then transitions failed → online after install.sh finishes. So:
#
# - 30 min deadline (hermes worst-case + slack)
# - ${WORKSPACE_ONLINE_TIMEOUT_SECS}s (~$((WORKSPACE_ONLINE_TIMEOUT_SECS/60)) min)
# deadline (hermes worst-case + slack). Configurable via
# E2E_WORKSPACE_ONLINE_TIMEOUT_SECS (#1646).
# - 'failed' is a TRANSIENT state we must tolerate — log and keep
# polling, only hard-fail at the deadline. Pre-bootstrap-watcher-fix
# (controlplane#245) this was a flake generator: workspace went
# failed→online inside our window but we bailed at the failed read.
log "7/11 Waiting for workspace(s) to reach status=online (up to 30 min — hermes cold boot)..."
WS_DEADLINE=$(( $(date +%s) + 1800 ))
WS_TO_CHECK="$PARENT_ID"
[ -n "$CHILD_ID" ] && WS_TO_CHECK="$WS_TO_CHECK $CHILD_ID"
for wid in $WS_TO_CHECK; do
WS_LAST_STATUS=""
WS_LAST_URL=""
WS_URL_MISSING_LOGGED=0
WS_FAILED_LOGGED=0
while true; do
if [ "$(date +%s)" -gt "$WS_DEADLINE" ]; then
WS_LAST_ERR=$(tenant_call GET "/workspaces/$wid" 2>/dev/null | \
python3 -c "import json,sys; print(json.load(sys.stdin).get('last_sample_error',''))" 2>/dev/null || echo "")
fail "Workspace $wid never reached online with a routable URL within 30 min (last status=$WS_LAST_STATUS, url=$WS_LAST_URL, err=$WS_LAST_ERR)"
fi
WS_JSON=$(tenant_call GET "/workspaces/$wid" 2>/dev/null || echo '{}')
WS_STATUS=$(echo "$WS_JSON" | python3 -c "import json,sys; print(json.load(sys.stdin).get('status') or '')" 2>/dev/null)
WS_URL=$(echo "$WS_JSON" | python3 -c "import json,sys; print(json.load(sys.stdin).get('url') or '')" 2>/dev/null)
if [ "$WS_STATUS" != "$WS_LAST_STATUS" ]; then
log " $wid$WS_STATUS"
WS_LAST_STATUS="$WS_STATUS"
fi
if [ -n "$WS_URL" ] && [ "$WS_URL" != "$WS_LAST_URL" ]; then
log " $wid url ready: $WS_URL"
WS_LAST_URL="$WS_URL"
fi
case "$WS_STATUS" in
online)
if [ -n "$WS_URL" ]; then
break
fi
if [ "$WS_URL_MISSING_LOGGED" = "0" ]; then
log " $wid online but URL is not assigned yet — waiting for workspace routing readiness"
WS_URL_MISSING_LOGGED=1
fi
sleep 10
;;
failed)
# Not a hard fail — bootstrap-watcher frequently marks failed at
# 5 min on hermes, then heartbeat recovers to online around 10-13
# min when install.sh finishes. Log once per workspace so the CI
# output isn't spammy.
if [ "$WS_FAILED_LOGGED" = "0" ]; then
log " $wid transiently failed — waiting for heartbeat recovery (bootstrap-watcher deadline, see cp#245)"
WS_FAILED_LOGGED=1
fi
sleep 10
;;
*) sleep 10 ;;
esac
done
ok " $wid online and routable"
done
WS_TO_CHECK=("$PARENT_ID")
[ -n "$CHILD_ID" ] && WS_TO_CHECK+=("$CHILD_ID")
wait_workspaces_online_routable "7/11 Waiting for workspace(s) to reach status=online (up to $((WORKSPACE_ONLINE_TIMEOUT_SECS/60)) min — hermes cold boot)..." "${WS_TO_CHECK[@]}"
# ─── 7b. Canvas-terminal diagnose (EIC chain probe) ────────────────────
# This step exists because the canvas-terminal failure of 2026-05-03
@@ -551,7 +567,7 @@ done
# probes docker.Ping + container exec; we still expect ok=true there
# since local-docker is the alternative production path.
log "7b/11 Canvas-terminal EIC diagnose probe..."
for wid in $WS_TO_CHECK; do
for wid in "${WS_TO_CHECK[@]}"; do
DIAG_JSON=$(tenant_call GET "/workspaces/$wid/terminal/diagnose" 2>/dev/null || echo '{}')
DIAG_OK=$(echo "$DIAG_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); print('true' if d.get('ok') else 'false')" 2>/dev/null || echo "false")
if [ "$DIAG_OK" = "true" ]; then
@@ -587,7 +603,7 @@ CONFIG_PAYLOAD="${CONFIG_MARKER}
name: synth-canary
runtime: ${RUNTIME}
"
for wid in $WS_TO_CHECK; do
for wid in "${WS_TO_CHECK[@]}"; do
PUT_BODY=$(python3 -c "import json,sys; print(json.dumps({'content': sys.stdin.read()}))" <<< "$CONFIG_PAYLOAD")
# Capture body to a tempfile so curl's -w '%{http_code}' is the only
# thing on stdout. The first version used `-w '\n%{http_code}\n'` and
@@ -620,6 +636,12 @@ for wid in $WS_TO_CHECK; do
ok " $wid config.yaml PUT OK (HTTP $PUT_CODE)"
done
# Saving config.yaml follows the same path as Canvas Config Save & Restart.
# The controlplane can briefly put the workspace back into provisioning and
# clear its route while the runtime restarts, so A2A must wait on the same
# externally routable readiness boundary again.
wait_workspaces_online_routable "7d/11 Waiting for workspace(s) to recover routing after config.yaml PUT..." "${WS_TO_CHECK[@]}"
# ─── 8. A2A round-trip on parent ───────────────────────────────────────
log "8/11 Sending A2A message to parent — expecting agent response..."
# Smoke prompt phrasing — DO NOT trim back to the bare "Reply with exactly: PONG"
+6 -6
View File
@@ -705,7 +705,7 @@ def test_ci_change_detector_docs_and_meta_scripts_do_not_trigger_surfaces():
}
def test_ci_platform_go_pr_steps_are_path_scoped():
def test_ci_platform_go_steps_are_path_scoped_on_all_events():
doc = yaml.safe_load(CI_WORKFLOW.read_text(encoding="utf-8"))
platform = doc["jobs"]["platform-build"]
assert platform.get("needs") == "changes"
@@ -720,11 +720,11 @@ def test_ci_platform_go_pr_steps_are_path_scoped():
assert expensive_steps
for step in expensive_steps:
expr = step.get("if", "")
assert "github.event_name != 'pull_request'" in expr
assert "needs.changes.outputs.platform == 'true'" in expr
assert "github.event_name != 'pull_request'" not in expr
def test_ci_canvas_nextjs_pr_steps_are_path_scoped():
def test_ci_canvas_nextjs_steps_are_path_scoped_on_all_events():
doc = yaml.safe_load(CI_WORKFLOW.read_text(encoding="utf-8"))
canvas = doc["jobs"]["canvas-build"]
assert canvas.get("needs") == "changes"
@@ -739,11 +739,11 @@ def test_ci_canvas_nextjs_pr_steps_are_path_scoped():
assert expensive_steps
for step in expensive_steps:
expr = step.get("if", "")
assert "github.event_name != 'pull_request'" in expr
assert "needs.changes.outputs.canvas == 'true'" in expr
assert "github.event_name != 'pull_request'" not in expr
def test_ci_shellcheck_pr_steps_are_path_scoped():
def test_ci_shellcheck_steps_are_path_scoped_on_all_events():
doc = yaml.safe_load(CI_WORKFLOW.read_text(encoding="utf-8"))
shellcheck = doc["jobs"]["shellcheck"]
assert shellcheck.get("needs") == "changes"
@@ -756,5 +756,5 @@ def test_ci_shellcheck_pr_steps_are_path_scoped():
assert expensive_steps
for step in expensive_steps:
expr = step.get("if", "")
assert "github.event_name != 'pull_request'" in expr
assert "needs.changes.outputs.scripts == 'true'" in expr
assert "github.event_name != 'pull_request'" not in expr
+26 -2
View File
@@ -1,3 +1,26 @@
// Package main runs the per-tenant workspace-server.
//
// @title Molecule AI Workspace Server API
// @version 1.0
// @description The per-tenant workspace-server HTTP API. Single source of truth for workspace/schedule/agent/secrets/files/memory CRUD. Hand-written clients (canvas, molecule-mcp-server, molecule-cli, molecule-sdk-python) should be replaced by clients generated from this spec — see RFC #1706.
// @host api.moleculesai.app
// @BasePath /
// @schemes https
//
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization
// @description Bearer token issued by Gitea (org-admin or persona PAT) or by the platform's signup/SSO flow.
//
// @securityDefinitions.apikey OrgSlugAuth
// @in header
// @name X-Molecule-Org-Slug
// @description Tenant routing header — required on every /workspaces/{id}/* request so the platform edge can route to the correct per-tenant workspace-server. Either X-Molecule-Org-Slug (human-readable, e.g. "agents-team") or X-Molecule-Org-Id (UUID) must be sent; slug is preferred for client code.
//
// @securityDefinitions.apikey OrgIdAuth
// @in header
// @name X-Molecule-Org-Id
// @description Tenant routing header (UUID form). Alternative to X-Molecule-Org-Slug. At least one of OrgSlugAuth or OrgIdAuth must be sent alongside BearerAuth.
package main
import (
@@ -370,8 +393,9 @@ func main() {
// See molecule-core#7.
bindHost := resolveBindHost()
srv := &http.Server{
Addr: fmt.Sprintf("%s:%s", bindHost, port),
Handler: r,
Addr: fmt.Sprintf("%s:%s", bindHost, port),
Handler: r,
ReadHeaderTimeout: 5 * time.Second,
}
// Start server in goroutine
+521
View File
@@ -0,0 +1,521 @@
{
"schemes": [
"https"
],
"swagger": "2.0",
"info": {
"description": "The per-tenant workspace-server HTTP API. Single source of truth for workspace/schedule/agent/secrets/files/memory CRUD. Hand-written clients (canvas, molecule-mcp-server, molecule-cli, molecule-sdk-python) should be replaced by clients generated from this spec — see RFC #1706.",
"title": "Molecule AI Workspace Server API",
"contact": {},
"version": "1.0"
},
"host": "api.moleculesai.app",
"basePath": "/",
"paths": {
"/workspaces/{id}/schedules": {
"get": {
"security": [
{
"BearerAuth": [],
"OrgSlugAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"schedules"
],
"summary": "List schedules for a workspace",
"parameters": [
{
"type": "string",
"description": "Workspace ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/handlers.ScheduleResponse"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
},
"post": {
"security": [
{
"BearerAuth": [],
"OrgSlugAuth": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"schedules"
],
"summary": "Create a schedule",
"parameters": [
{
"type": "string",
"description": "Workspace ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "Schedule fields",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/handlers.CreateScheduleRequest"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/handlers.CreateScheduleResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/workspaces/{id}/schedules/{scheduleId}": {
"delete": {
"security": [
{
"BearerAuth": [],
"OrgSlugAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"schedules"
],
"summary": "Delete a schedule",
"parameters": [
{
"type": "string",
"description": "Workspace ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Schedule ID",
"name": "scheduleId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handlers.StatusResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
},
"patch": {
"security": [
{
"BearerAuth": [],
"OrgSlugAuth": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"schedules"
],
"summary": "Update a schedule",
"parameters": [
{
"type": "string",
"description": "Workspace ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Schedule ID",
"name": "scheduleId",
"in": "path",
"required": true
},
{
"description": "Partial schedule fields (only provided keys are updated)",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/handlers.UpdateScheduleRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handlers.ScheduleResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/workspaces/{id}/schedules/{scheduleId}/history": {
"get": {
"security": [
{
"BearerAuth": [],
"OrgSlugAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"schedules"
],
"summary": "Get past runs of a schedule",
"parameters": [
{
"type": "string",
"description": "Workspace ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Schedule ID",
"name": "scheduleId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/handlers.HistoryEntry"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/workspaces/{id}/schedules/{scheduleId}/run": {
"post": {
"security": [
{
"BearerAuth": [],
"OrgSlugAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"schedules"
],
"summary": "Fire a schedule manually",
"parameters": [
{
"type": "string",
"description": "Workspace ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Schedule ID",
"name": "scheduleId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handlers.RunNowResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
}
},
"definitions": {
"handlers.CreateScheduleRequest": {
"type": "object",
"required": [
"cron_expr",
"prompt"
],
"properties": {
"cron_expr": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"name": {
"type": "string"
},
"prompt": {
"type": "string"
},
"timezone": {
"type": "string"
}
}
},
"handlers.CreateScheduleResponse": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"next_run_at": {
"type": "string"
},
"status": {
"type": "string"
}
}
},
"handlers.ErrorResponse": {
"type": "object",
"properties": {
"error": {
"type": "string"
}
}
},
"handlers.HistoryEntry": {
"type": "object",
"properties": {
"duration_ms": {
"type": "integer"
},
"error_detail": {
"type": "string"
},
"request": {
"type": "object"
},
"status": {
"type": "string"
},
"timestamp": {
"type": "string"
}
}
},
"handlers.RunNowResponse": {
"type": "object",
"properties": {
"prompt": {
"type": "string"
},
"status": {
"type": "string"
},
"workspace_id": {
"type": "string"
}
}
},
"handlers.ScheduleResponse": {
"type": "object",
"properties": {
"created_at": {
"type": "string"
},
"cron_expr": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"id": {
"type": "string"
},
"last_error": {
"type": "string"
},
"last_run_at": {
"type": "string"
},
"last_status": {
"type": "string"
},
"name": {
"type": "string"
},
"next_run_at": {
"type": "string"
},
"prompt": {
"type": "string"
},
"run_count": {
"type": "integer"
},
"source": {
"description": "'template' (seeded by org/import) | 'runtime' (created via Canvas/API). Issue #24.",
"type": "string"
},
"timezone": {
"type": "string"
},
"updated_at": {
"type": "string"
},
"workspace_id": {
"type": "string"
}
}
},
"handlers.StatusResponse": {
"type": "object",
"properties": {
"status": {
"type": "string"
}
}
},
"handlers.UpdateScheduleRequest": {
"type": "object",
"properties": {
"cron_expr": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"name": {
"type": "string"
},
"prompt": {
"type": "string"
},
"timezone": {
"type": "string"
}
}
}
},
"securityDefinitions": {
"BearerAuth": {
"description": "Bearer token issued by Gitea (org-admin or persona PAT) or by the platform's signup/SSO flow.",
"type": "apiKey",
"name": "Authorization",
"in": "header"
},
"OrgIdAuth": {
"description": "Tenant routing header (UUID form). Alternative to X-Molecule-Org-Slug. At least one of OrgSlugAuth or OrgIdAuth must be sent alongside BearerAuth.",
"type": "apiKey",
"name": "X-Molecule-Org-Id",
"in": "header"
},
"OrgSlugAuth": {
"description": "Tenant routing header — required on every /workspaces/{id}/* request so the platform edge can route to the correct per-tenant workspace-server. Either X-Molecule-Org-Slug (human-readable, e.g. \"agents-team\") or X-Molecule-Org-Id (UUID) must be sent; slug is preferred for client code.",
"type": "apiKey",
"name": "X-Molecule-Org-Slug",
"in": "header"
}
}
}
+349
View File
@@ -0,0 +1,349 @@
basePath: /
definitions:
handlers.CreateScheduleRequest:
properties:
cron_expr:
type: string
enabled:
type: boolean
name:
type: string
prompt:
type: string
timezone:
type: string
required:
- cron_expr
- prompt
type: object
handlers.CreateScheduleResponse:
properties:
id:
type: string
next_run_at:
type: string
status:
type: string
type: object
handlers.ErrorResponse:
properties:
error:
type: string
type: object
handlers.HistoryEntry:
properties:
duration_ms:
type: integer
error_detail:
type: string
request:
type: object
status:
type: string
timestamp:
type: string
type: object
handlers.RunNowResponse:
properties:
prompt:
type: string
status:
type: string
workspace_id:
type: string
type: object
handlers.ScheduleResponse:
properties:
created_at:
type: string
cron_expr:
type: string
enabled:
type: boolean
id:
type: string
last_error:
type: string
last_run_at:
type: string
last_status:
type: string
name:
type: string
next_run_at:
type: string
prompt:
type: string
run_count:
type: integer
source:
description: '''template'' (seeded by org/import) | ''runtime'' (created via
Canvas/API). Issue #24.'
type: string
timezone:
type: string
updated_at:
type: string
workspace_id:
type: string
type: object
handlers.StatusResponse:
properties:
status:
type: string
type: object
handlers.UpdateScheduleRequest:
properties:
cron_expr:
type: string
enabled:
type: boolean
name:
type: string
prompt:
type: string
timezone:
type: string
type: object
host: api.moleculesai.app
info:
contact: {}
description: 'The per-tenant workspace-server HTTP API. Single source of truth for
workspace/schedule/agent/secrets/files/memory CRUD. Hand-written clients (canvas,
molecule-mcp-server, molecule-cli, molecule-sdk-python) should be replaced by
clients generated from this spec — see RFC #1706.'
title: Molecule AI Workspace Server API
version: "1.0"
paths:
/workspaces/{id}/schedules:
get:
parameters:
- description: Workspace ID
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/handlers.ScheduleResponse'
type: array
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
security:
- BearerAuth: []
OrgSlugAuth: []
summary: List schedules for a workspace
tags:
- schedules
post:
consumes:
- application/json
parameters:
- description: Workspace ID
in: path
name: id
required: true
type: string
- description: Schedule fields
in: body
name: body
required: true
schema:
$ref: '#/definitions/handlers.CreateScheduleRequest'
produces:
- application/json
responses:
"201":
description: Created
schema:
$ref: '#/definitions/handlers.CreateScheduleResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
security:
- BearerAuth: []
OrgSlugAuth: []
summary: Create a schedule
tags:
- schedules
/workspaces/{id}/schedules/{scheduleId}:
delete:
parameters:
- description: Workspace ID
in: path
name: id
required: true
type: string
- description: Schedule ID
in: path
name: scheduleId
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handlers.StatusResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
security:
- BearerAuth: []
OrgSlugAuth: []
summary: Delete a schedule
tags:
- schedules
patch:
consumes:
- application/json
parameters:
- description: Workspace ID
in: path
name: id
required: true
type: string
- description: Schedule ID
in: path
name: scheduleId
required: true
type: string
- description: Partial schedule fields (only provided keys are updated)
in: body
name: body
required: true
schema:
$ref: '#/definitions/handlers.UpdateScheduleRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handlers.ScheduleResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
security:
- BearerAuth: []
OrgSlugAuth: []
summary: Update a schedule
tags:
- schedules
/workspaces/{id}/schedules/{scheduleId}/history:
get:
parameters:
- description: Workspace ID
in: path
name: id
required: true
type: string
- description: Schedule ID
in: path
name: scheduleId
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/handlers.HistoryEntry'
type: array
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
security:
- BearerAuth: []
OrgSlugAuth: []
summary: Get past runs of a schedule
tags:
- schedules
/workspaces/{id}/schedules/{scheduleId}/run:
post:
parameters:
- description: Workspace ID
in: path
name: id
required: true
type: string
- description: Schedule ID
in: path
name: scheduleId
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handlers.RunNowResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
security:
- BearerAuth: []
OrgSlugAuth: []
summary: Fire a schedule manually
tags:
- schedules
schemes:
- https
securityDefinitions:
BearerAuth:
description: Bearer token issued by Gitea (org-admin or persona PAT) or by the
platform's signup/SSO flow.
in: header
name: Authorization
type: apiKey
OrgIdAuth:
description: Tenant routing header (UUID form). Alternative to X-Molecule-Org-Slug.
At least one of OrgSlugAuth or OrgIdAuth must be sent alongside BearerAuth.
in: header
name: X-Molecule-Org-Id
type: apiKey
OrgSlugAuth:
description: Tenant routing header — required on every /workspaces/{id}/* request
so the platform edge can route to the correct per-tenant workspace-server. Either
X-Molecule-Org-Slug (human-readable, e.g. "agents-team") or X-Molecule-Org-Id
(UUID) must be sent; slug is preferred for client code.
in: header
name: X-Molecule-Org-Slug
type: apiKey
swagger: "2.0"
@@ -116,8 +116,11 @@ func (d *DiscordAdapter) SendMessage(ctx context.Context, config map[string]inte
// would propagate that token into logs and error responses (#659).
return fmt.Errorf("discord: HTTP request failed")
}
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))
_ = resp.Body.Close()
if readErr != nil {
return fmt.Errorf("discord: read response body: %w", readErr)
}
// Discord returns 204 No Content on success.
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
+4 -1
View File
@@ -119,7 +119,10 @@ func (l *LarkAdapter) SendMessage(ctx context.Context, config map[string]interfa
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
body, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return fmt.Errorf("lark: read response body: %w", readErr)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("lark: webhook returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
+27 -4
View File
@@ -156,6 +156,9 @@ func (m *Manager) PausePollersForToken(workspaceID, botToken string) func() {
}
}
}
if err := rows.Err(); err != nil {
log.Printf("Channels: pause-pollers rows.Err: %v", err)
}
m.mu.Unlock()
if len(pausedIDs) == 0 {
@@ -204,8 +207,16 @@ func (m *Manager) Reload(ctx context.Context) {
log.Printf("Channels: reload scan error: %v", err)
continue
}
_ = json.Unmarshal(configJSON, &ch.Config)
_ = json.Unmarshal(allowedJSON, &ch.AllowedUsers)
if err := json.Unmarshal(configJSON, &ch.Config); err != nil {
log.Printf("Channels: reload config unmarshal error for %s: %v", truncID(ch.ID), err)
continue
}
if len(allowedJSON) > 0 {
if err := json.Unmarshal(allowedJSON, &ch.AllowedUsers); err != nil {
log.Printf("Channels: reload allowed_users unmarshal error for %s: %v", truncID(ch.ID), err)
continue
}
}
// #319: decrypt at the boundary between DB (ciphertext) and the
// in-memory config adapters consume. A decrypt failure logs and
// skips the channel — downstream getUpdates would fail anyway
@@ -216,6 +227,9 @@ func (m *Manager) Reload(ctx context.Context) {
}
desired[ch.ID] = ch
}
if err := rows.Err(); err != nil {
log.Printf("Channels: reload rows.Err: %v", err)
}
m.mu.Lock()
defer m.mu.Unlock()
@@ -473,6 +487,9 @@ func (m *Manager) BroadcastToWorkspaceChannels(ctx context.Context, workspaceID,
}
}
}
if err := rows.Err(); err != nil {
log.Printf("Channels: broadcast rows.Err: %v", err)
}
}
// FetchWorkspaceChannelContext returns recent Slack channel messages formatted
@@ -555,8 +572,14 @@ func (m *Manager) loadChannel(ctx context.Context, channelID string) (ChannelRow
if err != nil {
return ch, fmt.Errorf("channel %s not found: %w", channelID, err)
}
json.Unmarshal(configJSON, &ch.Config)
json.Unmarshal(allowedJSON, &ch.AllowedUsers)
if err := json.Unmarshal(configJSON, &ch.Config); err != nil {
return ch, fmt.Errorf("channel %s config unmarshal: %w", channelID, err)
}
if len(allowedJSON) > 0 {
if err := json.Unmarshal(allowedJSON, &ch.AllowedUsers); err != nil {
return ch, fmt.Errorf("channel %s allowed_users unmarshal: %w", channelID, err)
}
}
// #319: decrypt bot_token / webhook_secret — SendOutbound and adapter
// methods downstream read them as plaintext strings.
if err := DecryptSensitiveFields(ch.Config); err != nil {
+13 -3
View File
@@ -171,8 +171,11 @@ func (s *SlackAdapter) sendBotMessage(ctx context.Context, config map[string]int
if err != nil {
return fmt.Errorf("slack: send: %w", err)
}
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))
_ = resp.Body.Close()
if readErr != nil {
return fmt.Errorf("slack: read response body: %w", readErr)
}
var result struct {
OK bool `json:"ok"`
Error string `json:"error"`
@@ -208,9 +211,13 @@ func (s *SlackAdapter) sendWebhookMessage(ctx context.Context, config map[string
if err != nil {
return fmt.Errorf("slack: send: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
body, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return fmt.Errorf("slack: webhook returned %d (read body failed: %v)", resp.StatusCode, readErr)
}
return fmt.Errorf("slack: webhook returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return nil
@@ -524,8 +531,11 @@ func FetchChannelHistory(ctx context.Context, botToken, channelID string, limit
if err != nil {
return nil, err
}
body, _ := io.ReadAll(io.LimitReader(resp.Body, 65536))
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 65536))
_ = resp.Body.Close()
if readErr != nil {
return nil, fmt.Errorf("slack: read history response: %w", readErr)
}
var result struct {
OK bool `json:"ok"`
@@ -71,35 +71,30 @@ func (h *WorkspaceHandler) handleA2ADispatchError(ctx context.Context, workspace
// with 202 status here was the original cycle 53 bug — callers saw
// proxyErr != nil and logged "delegation failed: proxy a2a error".
if isUpstreamBusyError(err) {
// Capability primitive #5 — see project memory
// `project_runtime_native_pluggable.md`. When the target workspace's
// adapter has declared provides_native_session=True, the SDK
// owns its own queue/session state (claude-agent-sdk's streaming
// session, hermes-agent's in-container event log, etc.). Adding
// the platform's a2a_queue layer on top would double-buffer the
// same in-flight state — and worse, the platform queue's drain
// timing has no relationship to the SDK's actual readiness, so
// the queued request might dispatch while the SDK is STILL busy.
// #1684 / Reno Stars: native_session adapters previously took a
// 503-no-enqueue path here, on the assumption that the SDK owned
// an inbound queue and the platform a2a_queue would double-buffer.
// In practice, the common native_session SDKs (claude-agent-sdk,
// codex app-server, hermes-agent) do NOT have an inbound queue —
// new turns can only be pushed via the same HTTP POST that just
// returned busy. So cron fires (and any A2A retry) bounce 503
// every tick until the SDK voluntarily yields. Reno Stars #1684
// observed 12 consecutive `*/30` cron fires lost over 6h while a
// single native_session held the slot.
//
// For native_session targets, return 503 + Retry-After directly.
// The caller's adapter handles retry on its own schedule, and
// the SDK's own queue absorbs the in-flight request when it does.
// Observability is preserved: logA2AFailure already ran above;
// activity_logs records the busy event; the broadcaster fires.
if runtimeOverrides.HasCapability(workspaceID, "session") {
log.Printf("ProxyA2A: target %s busy and declares native_session — skip enqueue, return 503", workspaceID)
return 0, nil, &proxyA2AError{
Status: http.StatusServiceUnavailable,
Headers: map[string]string{"Retry-After": strconv.Itoa(busyRetryAfterSeconds)},
Response: gin.H{
"error": "workspace agent busy — adapter handles retry (native_session)",
"busy": true,
"retry_after": busyRetryAfterSeconds,
"native_session": true,
},
}
}
// The original concern — "drain timing has no relationship to SDK
// readiness" — turns out to be unfounded: heartbeat→drain is
// gated by `payload.ActiveTasks < maxConcurrent` in
// registry.go:Heartbeat, so drain only fires when the workspace
// itself reports spare capacity. That IS the session-ended
// signal. The native_session SDK reports ActiveTasks=1 while in a
// turn, ActiveTasks=0 when idle, and the next heartbeat after
// idle triggers DrainQueueForWorkspace.
//
// So we collapse the two branches: both native_session and
// non-native callers enqueue here. The native_session SDK's own
// in-flight POST stays unaffected; the queued item drains on the
// next post-idle heartbeat.
idempotencyKey := extractIdempotencyKey(body)
// Honor params.expires_in_seconds when the caller specifies one. Zero
// (the unset default) → expiresAt = nil → infinite TTL preserved by
@@ -104,6 +104,9 @@ func (h *ChannelHandler) List(c *gin.Context) {
}
result = append(result, entry)
}
if err := rows.Err(); err != nil {
log.Printf("Channels: list rows.Err: %v", err)
}
c.JSON(http.StatusOK, result)
}
@@ -514,6 +517,9 @@ func (h *ChannelHandler) Webhook(c *gin.Context) {
candidates = append(candidates, row)
}
}
if err := rows.Err(); err != nil {
log.Printf("Channels: telegram webhook rows.Err: %v", err)
}
if targetSlug != "" {
// [slug] routing — match against config username (lowercased)
@@ -393,6 +393,9 @@ func queryPeerMaps(query string, args ...interface{}) ([]map[string]interface{},
result = append(result, peer)
}
if err := rows.Err(); err != nil {
log.Printf("queryPeerMaps rows.Err: %v", err)
}
return result, nil
}
@@ -49,6 +49,9 @@ func (h *EventsHandler) List(c *gin.Context) {
"created_at": createdAt,
})
}
if err := rows.Err(); err != nil {
log.Printf("Events list rows error: %v", err)
}
c.JSON(http.StatusOK, events)
}
@@ -87,5 +90,8 @@ func (h *EventsHandler) ListByWorkspace(c *gin.Context) {
"created_at": createdAt,
})
}
if err := rows.Err(); err != nil {
log.Printf("WorkspaceEvents list rows error: %v", err)
}
c.JSON(http.StatusOK, events)
}
@@ -159,7 +159,8 @@ func generateAppInstallationToken() (string, time.Time, error) {
req, _ := http.NewRequest("POST", fmt.Sprintf("https://api.github.com/app/installations/%d/access_tokens", installID), nil)
req.Header.Set("Authorization", "Bearer "+signed)
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := http.DefaultClient.Do(req)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", time.Time{}, err
}
@@ -44,7 +44,7 @@ func TestWorkspaceCreate_WithParentID(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Child Agent","parent_id":"parent-ws-123"}`
body := `{"name":"Child Agent","model":"anthropic:claude-opus-4-7","parent_id":"parent-ws-123"}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -80,7 +80,7 @@ func TestWorkspaceCreate_ExplicitClaudeCodeRuntime(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"CC Agent","tier":2,"runtime":"claude-code","canvas":{"x":10,"y":20}}`
body := `{"name":"CC Agent","tier":2,"runtime":"claude-code","model":"sonnet","canvas":{"x":10,"y":20}}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -230,7 +230,7 @@ func TestWorkspaceList_WithData(t *testing.T) {
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
// 23 cols — broadcast_enabled + talk_to_user_enabled added after monthly_spend
// 24 cols — compute added after talk_to_user_enabled.
// (migration 20260514). Column order must match scanWorkspaceRow exactly.
columns := []string{
"id", "name", "role", "tier", "status", "agent_card", "url",
@@ -238,13 +238,13 @@ func TestWorkspaceList_WithData(t *testing.T) {
"last_error_rate", "last_sample_error",
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
"budget_limit", "monthly_spend",
"broadcast_enabled", "talk_to_user_enabled",
"broadcast_enabled", "talk_to_user_enabled", "compute",
}
rows := sqlmock.NewRows(columns).
AddRow("ws-1", "Agent One", "worker", 1, "online", []byte(`{"name":"agent1"}`), "http://localhost:8001",
nil, 3, 1, 0.02, "", 7200, "processing", "langgraph", "", 10.0, 20.0, false, nil, int64(0), false, true).
nil, 3, 1, 0.02, "", 7200, "processing", "langgraph", "", 10.0, 20.0, false, nil, int64(0), false, true, []byte(`{}`)).
AddRow("ws-2", "Agent Two", "", 2, "degraded", []byte("null"), "",
nil, 0, 1, 0.6, "timeout", 100, "", "claude-code", "", 50.0, 60.0, true, nil, int64(0), false, true)
nil, 0, 1, 0.6, "timeout", 100, "", "claude-code", "", 50.0, 60.0, true, nil, int64(0), false, true, []byte(`{}`))
mock.ExpectQuery("SELECT w.id, w.name").
WillReturnRows(rows)
@@ -301,7 +301,7 @@ func TestWorkspaceCreate_MaxConcurrentTasksOverride(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Leader Agent","runtime":"claude-code","max_concurrent_tasks":3}`
body := `{"name":"Leader Agent","runtime":"claude-code","model":"sonnet","max_concurrent_tasks":3}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -777,6 +777,103 @@ func TestCreate_FieldValidation_Returns400(t *testing.T) {
}
}
// TestCreate_ModelRequired_Returns422 pins the CTO 2026-05-22 SSOT
// directive (feedback_workspace_model_required_no_platform_default_dynamic_credential_intake):
// model is required user input; the platform must not supply a default,
// the runtime must not fall back. Empirical trigger: Code Reviewer
// 5ba15d7e was created with `{"name":..., "runtime":"codex", ...}` (no
// model). The legacy DefaultModel fallback returned "anthropic:claude-opus-4-7"
// and codex adapter wedged forever — `picks provider='anthropic' but it
// is not in the providers registry`. The gate at the Create boundary
// turns that silent stuck-workspace failure into an immediate 422 the
// caller can react to.
//
// Three shapes covered:
// 1. bare name (no template, no runtime, no model) — formerly defaulted
// to langgraph + anthropic; now 422 because model is unspecified.
// 2. explicit runtime, no model — the Code Reviewer repro shape.
// 3. explicit runtime+template path, but template (when missing on
// disk or unreadable) would leave model empty — exercised here by
// pointing at a non-existent template under /tmp/configs.
func TestCreate_ModelRequired_Returns422(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", "/tmp/configs")
cases := []struct{ label, body string }{
{"bare_name_no_runtime_no_model", `{"name":"x"}`},
{"explicit_codex_no_model", `{"name":"Code Reviewer","role":"code reviewer","runtime":"codex","tier":4,"max_concurrent_tasks":1}`},
{"explicit_hermes_no_model", `{"name":"researcher","runtime":"hermes"}`},
}
for _, tc := range cases {
t.Run(tc.label, func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(tc.body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusUnprocessableEntity {
t.Errorf("Create(%s): want 422 MODEL_REQUIRED, got %d: %s", tc.label, w.Code, w.Body.String())
return
}
if !bytes.Contains(w.Body.Bytes(), []byte(`"code":"MODEL_REQUIRED"`)) {
t.Errorf("Create(%s): want body containing code=MODEL_REQUIRED, got %s", tc.label, w.Body.String())
}
})
}
}
// TestCreate_ExternalRuntime_NoModel_OK pins the external-runtime
// exemption from the MODEL_REQUIRED gate. External workspaces
// intentionally do not spawn a Docker container or run an adapter;
// they delegate to a registered URL (workspace_provision.go:497-498:
// "external is a first-class runtime that intentionally does NOT
// spawn a Docker container"). The model field has no meaning for
// them — the URL is the contract, and the gate would 422 every
// legitimate "register my agent at https://..." flow.
//
// Both spellings count as external:
// 1. payload.External == true (the canonical flag, e.g. with any runtime)
// 2. payload.Runtime == "external" (legacy shape some E2E scripts still use)
//
// The isExternalLikeRuntime() helper catches both "external" and any
// future external-like runtime alias.
func TestCreate_ExternalRuntime_NoModel_OK(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
// External=true with explicit runtime — the test_api.sh / Echo Agent shape.
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO workspaces").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
mock.ExpectExec("INSERT INTO canvas_layouts").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO structure_events").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`UPDATE workspaces SET status =`).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO workspace_auth_tokens").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO structure_events").
WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Echo Agent","tier":1,"runtime":"external","external":true}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusCreated {
t.Fatalf("external workspace without model: want 201, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations not met: %v", err)
}
}
func TestUpdate_FieldValidation_Returns400(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
@@ -386,7 +386,13 @@ func TestWorkspaceCreate(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Test Agent","canvas":{"x":100,"y":200}}`
// Note: model is now required at the Create boundary (CTO 2026-05-22
// SSOT directive — see feedback_workspace_model_required_no_platform_default_dynamic_credential_intake
// and TestCreate_ModelRequired_Returns422). This test happens to take
// the bare-defaults path (no template, no runtime → langgraph), so
// the body must declare an explicit model. Using a langgraph-compatible
// id; the test doesn't exercise model semantics beyond presence.
body := `{"name":"Test Agent","model":"anthropic:claude-opus-4-7","canvas":{"x":100,"y":200}}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -456,7 +462,7 @@ func TestWorkspaceList(t *testing.T) {
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", "/tmp/configs")
// 23 cols: broadcast_enabled + talk_to_user_enabled added after monthly_spend
// 24 cols: compute added after talk_to_user_enabled.
// (migration 20260514). Column order must match scanWorkspaceRow exactly.
columns := []string{
"id", "name", "role", "tier", "status", "agent_card", "url",
@@ -464,13 +470,13 @@ func TestWorkspaceList(t *testing.T) {
"last_error_rate", "last_sample_error",
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
"budget_limit", "monthly_spend",
"broadcast_enabled", "talk_to_user_enabled",
"broadcast_enabled", "talk_to_user_enabled", "compute",
}
rows := sqlmock.NewRows(columns).
AddRow("ws-1", "Agent One", "worker", 1, "online", []byte("null"), "http://localhost:8001",
nil, 0, 1, 0.0, "", 100, "", "claude-code", "", 10.0, 20.0, false, nil, int64(0), false, true).
nil, 0, 1, 0.0, "", 100, "", "claude-code", "", 10.0, 20.0, false, nil, int64(0), false, true, []byte(`{}`)).
AddRow("ws-2", "Agent Two", "manager", 2, "provisioning", []byte("null"), "",
nil, 0, 1, 0.0, "", 0, "", "langgraph", "", 50.0, 60.0, false, nil, int64(0), false, true)
nil, 0, 1, 0.0, "", 0, "", "langgraph", "", 50.0, 60.0, false, nil, int64(0), false, true, []byte(`{}`))
mock.ExpectQuery("SELECT w.id, w.name").
WillReturnRows(rows)
@@ -1184,14 +1190,14 @@ func TestWorkspaceGet_CurrentTask(t *testing.T) {
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
"budget_limit", "monthly_spend",
"broadcast_enabled", "talk_to_user_enabled",
"broadcast_enabled", "talk_to_user_enabled", "compute",
}
mock.ExpectQuery("SELECT w.id, w.name").
WithArgs("dddddddd-0004-0000-0000-000000000000").
WillReturnRows(sqlmock.NewRows(columns).AddRow(
"dddddddd-0004-0000-0000-000000000000", "Task Worker", "worker", 1, "online", []byte("null"), "http://localhost:9000",
nil, 2, 1, 0.0, "", 300, "Analyzing document", "langgraph", "", 10.0, 20.0, false,
nil, int64(0), false, true,
nil, int64(0), false, true, []byte(`{}`),
))
w := httptest.NewRecorder()
@@ -95,14 +95,18 @@ func (h *MCPHandler) toolListPeers(ctx context.Context, workspaceID string) (str
cols+` FROM workspaces w WHERE w.parent_id = $1 AND w.id != $2 AND w.status != 'removed'`,
parentID.String, workspaceID)
if err == nil {
_ = scanPeers(rows)
if scanErr := scanPeers(rows); scanErr != nil {
log.Printf("MCP toolListPeers: sibling scan error: %v", scanErr)
}
}
} else {
rows, err := h.database.QueryContext(ctx,
cols+` FROM workspaces w WHERE w.parent_id IS NULL AND w.id != $1 AND w.status != 'removed'`,
workspaceID)
if err == nil {
_ = scanPeers(rows)
if scanErr := scanPeers(rows); scanErr != nil {
log.Printf("MCP toolListPeers: sibling scan error: %v", scanErr)
}
}
}
@@ -112,7 +116,9 @@ func (h *MCPHandler) toolListPeers(ctx context.Context, workspaceID string) (str
cols+` FROM workspaces w WHERE w.parent_id = $1 AND w.status != 'removed'`,
workspaceID)
if err == nil {
_ = scanPeers(rows)
if scanErr := scanPeers(rows); scanErr != nil {
log.Printf("MCP toolListPeers: children scan error: %v", scanErr)
}
}
}
@@ -122,7 +128,9 @@ func (h *MCPHandler) toolListPeers(ctx context.Context, workspaceID string) (str
cols+` FROM workspaces w WHERE w.id = $1 AND w.status != 'removed'`,
parentID.String)
if err == nil {
_ = scanPeers(rows)
if scanErr := scanPeers(rows); scanErr != nil {
log.Printf("MCP toolListPeers: parent scan error: %v", scanErr)
}
}
}
@@ -6,18 +6,26 @@ import (
"testing"
)
// TestHandleA2ADispatchError_NativeSession_SkipsEnqueue validates capability
// primitive #5: when the target workspace has declared
// provides_native_session=True, a busy-shaped dispatch error MUST short-
// circuit straight to 503 + Retry-After. The platform's a2a_queue is
// skipped because the SDK owns its own queue/session state — double-
// buffering would cause spurious dispatches when the SDK is still busy.
// TestHandleA2ADispatchError_NativeSession_NowEnqueues validates the #1684
// fix: native_session adapters used to short-circuit to 503-no-queue here,
// on the assumption that the SDK owned an inbound queue. In practice the
// common native_session SDKs (claude-agent-sdk, codex app-server, hermes)
// don't — new turns arrive only via the same HTTP POST that returns busy.
// So cron fires bounced 503 every tick until the SDK voluntarily yielded;
// Reno Stars #1684 observed 12 consecutive `*/30` cron fires lost over 6h.
//
// Pin via sqlmock: we deliberately do NOT expect any INSERT INTO a2a_queue.
// If a future refactor re-introduces enqueueing under native_session,
// sqlmock fails the test on the unexpected query.
func TestHandleA2ADispatchError_NativeSession_SkipsEnqueue(t *testing.T) {
setupTestDB(t)
// Post-fix: native_session and non-native both enqueue. Drain timing is
// gated by registry.go:Heartbeat (`payload.ActiveTasks < maxConcurrent`)
// so the queued item only dispatches when the SDK reports spare capacity
// — i.e. the next heartbeat after the in-flight turn returns.
//
// This test pins the new behavior: native_session capability DOES NOT
// bypass EnqueueA2A. We expect the INSERT INTO a2a_queue query to fire,
// here arranged to fail so we can observe the legacy 503 fallback (and
// thereby confirm the INSERT was attempted; sqlmock fails the test if
// the expected query never runs).
func TestHandleA2ADispatchError_NativeSession_NowEnqueues(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
@@ -25,10 +33,15 @@ func TestHandleA2ADispatchError_NativeSession_SkipsEnqueue(t *testing.T) {
runtimeOverrides.SetCapabilities("ws-native", map[string]bool{"session": true})
defer runtimeOverrides.Reset()
// DeadlineExceeded triggers isUpstreamBusyError. Without the native
// gate, this would fire EnqueueA2A → INSERT INTO a2a_queue. With
// the gate, it short-circuits to 503. We expect ZERO queue queries;
// sqlmock's ExpectationsWereMet implicitly enforces that on teardown.
// We now EXPECT the INSERT to fire even with native_session=true. Make
// it fail so the handler falls through to the legacy 503 path — that
// lets us assert (1) enqueue was attempted, (2) the response on
// queue-failure does NOT carry native_session=true marker (that field
// was removed alongside the gate).
mock.ExpectQuery(`INSERT INTO a2a_queue`).
WithArgs("ws-native", nil, PriorityTask, "{}", "message/send", nil).
WillReturnError(errTestQueueUnavailable)
_, _, perr := handler.handleA2ADispatchError(
context.Background(), "ws-native", "", []byte("{}"), "message/send",
context.DeadlineExceeded, 1, false,
@@ -37,28 +50,27 @@ func TestHandleA2ADispatchError_NativeSession_SkipsEnqueue(t *testing.T) {
t.Fatal("expected proxy error, got nil")
}
if perr.Status != http.StatusServiceUnavailable {
t.Errorf("got status %d, want 503 (native_session bypasses queue but still 503s)", perr.Status)
t.Errorf("got status %d, want 503 (enqueue failed → legacy 503 fallback)", perr.Status)
}
if perr.Headers["Retry-After"] == "" {
t.Error("expected Retry-After header on native-session 503")
t.Error("expected Retry-After header on busy-503")
}
// Pin the marker so callers' adapters can distinguish this from a
// queue-failure 503: the body has native_session=true.
if got, _ := perr.Response["native_session"].(bool); !got {
t.Errorf("expected native_session=true in response body; got %+v", perr.Response)
// The native_session marker was removed from the response body — the
// platform queues both kinds now, callers no longer distinguish. Pin
// its absence so a future revert is caught.
if got, ok := perr.Response["native_session"].(bool); ok && got {
t.Errorf("native_session marker should be gone after #1684 fix; got %+v", perr.Response)
}
// And busy=true stays so existing busy-handling code paths still trigger.
if got, _ := perr.Response["busy"].(bool); !got {
t.Errorf("expected busy=true in response body; got %+v", perr.Response)
t.Errorf("expected busy=true; got %+v", perr.Response)
}
}
// TestHandleA2ADispatchError_NoNativeSession_StillEnqueues is the negative
// pin: a workspace WITHOUT the capability flag falls through to the
// existing EnqueueA2A path (and 503 if that fails). Same shape as
// TestHandleA2ADispatchError_ContextDeadline; we duplicate it here so
// the native_session gate change is bracketed by both positive and
// negative tests in the same file.
// TestHandleA2ADispatchError_NoNativeSession_StillEnqueues — non-native
// behavior is unchanged: enqueue is attempted, fail-fallback to 503. This
// negative pin guards against accidentally reverting the unification by
// re-introducing a `if HasCapability(...)` gate that would short-circuit
// the enqueue path.
func TestHandleA2ADispatchError_NoNativeSession_StillEnqueues(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
@@ -79,13 +91,11 @@ func TestHandleA2ADispatchError_NoNativeSession_StillEnqueues(t *testing.T) {
if perr == nil {
t.Fatal("expected proxy error, got nil")
}
// Queue insert failed → falls through to legacy 503 (without
// native_session marker).
if perr.Status != http.StatusServiceUnavailable {
t.Errorf("got status %d, want 503", perr.Status)
}
if got, _ := perr.Response["native_session"].(bool); got {
t.Errorf("non-native workspace should NOT carry native_session=true in response; got %+v", perr.Response)
t.Errorf("non-native workspace should NOT carry native_session=true; got %+v", perr.Response)
}
}
@@ -69,10 +69,15 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
model = defaults.Model
}
if model == "" {
// SSOT: per-runtime defaults live in models/runtime_defaults.go
// (see RFC #2873). Consolidated from a duplicate of the same
// branch in workspace_provision.go.
model = models.DefaultModel(runtime)
// SSOT (CTO 2026-05-22, feedback_workspace_model_required_no_platform_default_dynamic_credential_intake):
// model is REQUIRED. The org-import template MUST declare a
// model — either per-workspace (`ws.Model`) or via the org
// defaults block (`defaults.Model`). If neither is present
// the template is malformed and the import must fail-closed
// rather than silently provisioning a workspace with a
// runtime-incompatible default (the prior `anthropic:claude-opus-4-7`
// fallback wedged every codex workspace at adapter init).
return fmt.Errorf("org import: workspace %q has no model and the org defaults block does not provide one (runtime=%s) — model is a required field per the workspace-creation contract; either set `model:` on the workspace or under `defaults:`", ws.Name, runtime)
}
tier := ws.Tier
if tier == 0 {
@@ -133,24 +133,30 @@ func loadRestartContextData(ctx context.Context, workspaceID string) restartCont
// message bus.
keySet := map[string]struct{}{}
if rows, err := db.DB.QueryContext(ctx, `SELECT key FROM global_secrets`); err == nil {
defer rows.Close()
for rows.Next() {
var k string
if rows.Scan(&k) == nil {
keySet[k] = struct{}{}
}
}
rows.Close()
if err := rows.Err(); err != nil {
log.Printf("loadRestartContextData: global_secrets rows.Err: %v", err)
}
}
if rows, err := db.DB.QueryContext(ctx,
`SELECT key FROM workspace_secrets WHERE workspace_id = $1`, workspaceID,
); err == nil {
defer rows.Close()
for rows.Next() {
var k string
if rows.Scan(&k) == nil {
keySet[k] = struct{}{}
}
}
rows.Close()
if err := rows.Err(); err != nil {
log.Printf("loadRestartContextData: workspace_secrets rows.Err: %v", err)
}
}
for k := range keySet {
d.EnvKeys = append(d.EnvKeys, k)
+116 -21
View File
@@ -15,13 +15,46 @@ import (
"github.com/Molecule-AI/molecule-monorepo/platform/internal/scheduler"
)
// ErrorResponse is returned for 4xx/5xx errors. (OpenAPI doc shape — used by swaggo.)
type ErrorResponse struct {
Error string `json:"error"`
}
// StatusResponse is returned by mutating endpoints that only echo a status verb.
type StatusResponse struct {
Status string `json:"status"`
}
// CreateScheduleResponse is returned by POST /workspaces/{id}/schedules.
type CreateScheduleResponse struct {
ID string `json:"id"`
Status string `json:"status"`
NextRunAt time.Time `json:"next_run_at"`
}
// RunNowResponse is returned by POST /workspaces/{id}/schedules/{scheduleId}/run.
type RunNowResponse struct {
Status string `json:"status"`
WorkspaceID string `json:"workspace_id"`
Prompt string `json:"prompt"`
}
// HistoryEntry is one row of /workspaces/{id}/schedules/{scheduleId}/history.
type HistoryEntry struct {
Timestamp time.Time `json:"timestamp"`
DurationMs *int `json:"duration_ms"`
Status *string `json:"status"`
ErrorDetail string `json:"error_detail"`
Request json.RawMessage `json:"request" swaggertype:"object"`
}
type ScheduleHandler struct{}
func NewScheduleHandler() *ScheduleHandler {
return &ScheduleHandler{}
}
type scheduleResponse struct {
type ScheduleResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
@@ -40,6 +73,15 @@ type scheduleResponse struct {
}
// List returns all schedules for a workspace.
//
// @Summary List schedules for a workspace
// @Tags schedules
// @Produce json
// @Param id path string true "Workspace ID"
// @Success 200 {array} ScheduleResponse
// @Failure 500 {object} ErrorResponse
// @Router /workspaces/{id}/schedules [get]
// @Security BearerAuth && OrgSlugAuth
func (h *ScheduleHandler) List(c *gin.Context) {
workspaceID := c.Param("id")
ctx := c.Request.Context()
@@ -58,9 +100,9 @@ func (h *ScheduleHandler) List(c *gin.Context) {
}
defer rows.Close()
schedules := make([]scheduleResponse, 0)
schedules := make([]ScheduleResponse, 0)
for rows.Next() {
var s scheduleResponse
var s ScheduleResponse
if err := rows.Scan(
&s.ID, &s.WorkspaceID, &s.Name, &s.CronExpr, &s.Timezone,
&s.Prompt, &s.Enabled, &s.LastRunAt, &s.NextRunAt, &s.RunCount,
@@ -78,7 +120,7 @@ func (h *ScheduleHandler) List(c *gin.Context) {
c.JSON(http.StatusOK, schedules)
}
type createScheduleRequest struct {
type CreateScheduleRequest struct {
Name string `json:"name"`
CronExpr string `json:"cron_expr" binding:"required"`
Timezone string `json:"timezone"`
@@ -87,11 +129,23 @@ type createScheduleRequest struct {
}
// Create adds a new schedule for a workspace.
//
// @Summary Create a schedule
// @Tags schedules
// @Accept json
// @Produce json
// @Param id path string true "Workspace ID"
// @Param body body CreateScheduleRequest true "Schedule fields"
// @Success 201 {object} CreateScheduleResponse
// @Failure 400 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /workspaces/{id}/schedules [post]
// @Security BearerAuth && OrgSlugAuth
func (h *ScheduleHandler) Create(c *gin.Context) {
workspaceID := c.Param("id")
ctx := c.Request.Context()
var body createScheduleRequest
var body CreateScheduleRequest
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "cron_expr and prompt are required"})
return
@@ -145,7 +199,7 @@ func (h *ScheduleHandler) Create(c *gin.Context) {
})
}
type updateScheduleRequest struct {
type UpdateScheduleRequest struct {
Name *string `json:"name"`
CronExpr *string `json:"cron_expr"`
Timezone *string `json:"timezone"`
@@ -155,12 +209,26 @@ type updateScheduleRequest struct {
// Update modifies a schedule. Uses a fixed UPDATE with COALESCE so only
// provided fields are changed — no dynamic SQL construction.
//
// @Summary Update a schedule
// @Tags schedules
// @Accept json
// @Produce json
// @Param id path string true "Workspace ID"
// @Param scheduleId path string true "Schedule ID"
// @Param body body UpdateScheduleRequest true "Partial schedule fields (only provided keys are updated)"
// @Success 200 {object} ScheduleResponse
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /workspaces/{id}/schedules/{scheduleId} [patch]
// @Security BearerAuth && OrgSlugAuth
func (h *ScheduleHandler) Update(c *gin.Context) {
scheduleID := c.Param("scheduleId")
workspaceID := c.Param("id") // #113: bind to owning workspace to prevent IDOR
ctx := c.Request.Context()
var body updateScheduleRequest
var body UpdateScheduleRequest
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"})
return
@@ -230,6 +298,17 @@ func (h *ScheduleHandler) Update(c *gin.Context) {
}
// Delete removes a schedule.
//
// @Summary Delete a schedule
// @Tags schedules
// @Produce json
// @Param id path string true "Workspace ID"
// @Param scheduleId path string true "Schedule ID"
// @Success 200 {object} StatusResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /workspaces/{id}/schedules/{scheduleId} [delete]
// @Security BearerAuth && OrgSlugAuth
func (h *ScheduleHandler) Delete(c *gin.Context) {
scheduleID := c.Param("scheduleId")
workspaceID := c.Param("id") // #113: bind to owning workspace to prevent IDOR
@@ -252,6 +331,17 @@ func (h *ScheduleHandler) Delete(c *gin.Context) {
}
// RunNow manually fires a schedule immediately.
//
// @Summary Fire a schedule manually
// @Tags schedules
// @Produce json
// @Param id path string true "Workspace ID"
// @Param scheduleId path string true "Schedule ID"
// @Success 200 {object} RunNowResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /workspaces/{id}/schedules/{scheduleId}/run [post]
// @Security BearerAuth && OrgSlugAuth
func (h *ScheduleHandler) RunNow(c *gin.Context) {
scheduleID := c.Param("scheduleId")
workspaceID := c.Param("id")
@@ -282,6 +372,16 @@ func (h *ScheduleHandler) RunNow(c *gin.Context) {
}
// History returns recent runs for a schedule from activity_logs.
//
// @Summary Get past runs of a schedule
// @Tags schedules
// @Produce json
// @Param id path string true "Workspace ID"
// @Param scheduleId path string true "Schedule ID"
// @Success 200 {array} HistoryEntry
// @Failure 500 {object} ErrorResponse
// @Router /workspaces/{id}/schedules/{scheduleId}/history [get]
// @Security BearerAuth && OrgSlugAuth
func (h *ScheduleHandler) History(c *gin.Context) {
scheduleID := c.Param("scheduleId")
workspaceID := c.Param("id")
@@ -307,17 +407,9 @@ func (h *ScheduleHandler) History(c *gin.Context) {
}
defer rows.Close()
type historyEntry struct {
Timestamp time.Time `json:"timestamp"`
DurationMs *int `json:"duration_ms"`
Status *string `json:"status"`
ErrorDetail string `json:"error_detail"`
Request json.RawMessage `json:"request"`
}
entries := make([]historyEntry, 0)
entries := make([]HistoryEntry, 0)
for rows.Next() {
var e historyEntry
var e HistoryEntry
var reqStr string
if err := rows.Scan(&e.Timestamp, &e.DurationMs, &e.Status, &e.ErrorDetail, &reqStr); err != nil {
continue
@@ -325,15 +417,18 @@ func (h *ScheduleHandler) History(c *gin.Context) {
e.Request = json.RawMessage(reqStr)
entries = append(entries, e)
}
if err := rows.Err(); err != nil {
log.Printf("ScheduleHistory: rows error: %v", err)
}
c.JSON(http.StatusOK, entries)
}
// scheduleHealthResponse is the read-only health view of a schedule.
// ScheduleHealthResponse is the read-only health view of a schedule.
// It deliberately omits prompt and cron_expr so sensitive task content is
// never exposed to peer workspaces — only execution-state fields needed to
// detect silent cron failures are returned (issue #249).
type scheduleHealthResponse struct {
type ScheduleHealthResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
@@ -402,9 +497,9 @@ func (h *ScheduleHandler) Health(c *gin.Context) {
}
defer rows.Close()
schedules := make([]scheduleHealthResponse, 0)
schedules := make([]ScheduleHealthResponse, 0)
for rows.Next() {
var s scheduleHealthResponse
var s ScheduleHealthResponse
if err := rows.Scan(
&s.ID, &s.Name, &s.Enabled, &s.LastRunAt, &s.NextRunAt,
&s.RunCount, &s.LastStatus, &s.LastError,
@@ -234,7 +234,7 @@ func TestScheduleHealth_SelfCall_Allowed(t *testing.T) {
t.Fatalf("expected 200 for self-call, got %d: %s", w.Code, w.Body.String())
}
var resp []scheduleHealthResponse
var resp []ScheduleHealthResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
@@ -284,7 +284,7 @@ func TestScheduleHealth_CanCommunicatePeer_LegacyNoToken(t *testing.T) {
t.Fatalf("expected 200 for peer with no tokens, got %d: %s", w.Code, w.Body.String())
}
var resp []scheduleHealthResponse
var resp []ScheduleHealthResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
+5 -1
View File
@@ -51,6 +51,10 @@ func (h *TracesHandler) List(c *gin.Context) {
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
body, err := io.ReadAll(resp.Body)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read response body"})
return
}
c.Data(resp.StatusCode, "application/json", body)
}
@@ -321,6 +321,51 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
payload.Runtime = "langgraph"
}
// SSOT (CTO 2026-05-22, feedback_workspace_model_required_no_platform_default_dynamic_credential_intake):
// model is REQUIRED user input for SPAWNED-runtime workspaces. The
// platform must not provide a default; the runtime must not fall back.
// The decision belongs to the user (or to the agent acting on the
// user's behalf), never to the platform.
//
// Empirical trigger: Code Reviewer 5ba15d7e was created with
// `{"name":"Code Reviewer","role":"...","runtime":"codex",...}` (no
// model). The legacy `DefaultModel(runtime)` fallback in
// provisionWorkspace returned `"anthropic:claude-opus-4-7"`. Codex
// adapter only supports openai-* providers — it wedged forever with
// `codex adapter: workspace config picks provider='anthropic' but
// it is not in the providers registry`. PATCH /workspaces/:id
// explicitly disallows updating model (the comment literally reads
// `model not patchable`), so the only recovery path was SQL UPDATE
// or delete+recreate.
//
// External workspaces are EXEMPT — they intentionally do not spawn
// a Docker container or run an adapter; they delegate to a registered
// URL (see provision.go: "external is a first-class runtime that
// intentionally does NOT spawn a Docker container"). The MODEL_REQUIRED
// gate is meaningful for spawned-runtime workspaces where the model
// id drives provider selection at adapter init. For external workspaces
// the contract is the URL, not the model — requiring it would be
// ceremony with no payoff, and would 422 every legitimate "register
// my agent at https://..." flow. The SSOT directive concerns
// platform-side defaults; an external workspace genuinely has no
// "model decision" for the user to make.
//
// Fail-closed at the Create boundary so the caller learns the
// contract immediately — same shape as the controlplane#188
// runtime-unresolved gate above. Caller fixes the request, no
// EC2 launched, no stuck workspace, no operator paging.
isExternal := payload.External || isExternalLikeRuntime(payload.Runtime)
if payload.Model == "" && !isExternal {
log.Printf("Create: FAIL-CLOSED — model is required (runtime=%q template=%q); refusing the silent DefaultModel fallback per CTO 2026-05-22 SSOT directive", payload.Runtime, payload.Template)
c.JSON(http.StatusUnprocessableEntity, gin.H{
"error": "model is required and has no platform-side default — pass an explicit \"model\" in the request body, or use a \"template\" whose config.yaml declares one. See feedback_workspace_model_required_no_platform_default_dynamic_credential_intake for the contract.",
"runtime": payload.Runtime,
"template": payload.Template,
"code": "MODEL_REQUIRED",
})
return
}
ctx := c.Request.Context()
// Convert empty role to NULL
@@ -348,6 +393,10 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace access"})
return
}
if err := validateWorkspaceCompute(payload.Compute); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Begin a transaction so the workspace row and any initial secrets are
// committed atomically. A secret-encrypt or DB error rolls back the
@@ -435,6 +484,24 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
payload.Name = persistedName
}
if !workspaceComputeIsZero(payload.Compute) {
computeJSON, encErr := workspaceComputeJSON(payload.Compute)
if encErr != nil {
tx.Rollback() //nolint:errcheck
log.Printf("Create workspace %s: failed to encode compute config: %v", id, encErr)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode compute config"})
return
}
if _, dbErr := tx.ExecContext(ctx,
`UPDATE workspaces SET compute = $2::jsonb, updated_at = now() WHERE id = $1`,
id, computeJSON); dbErr != nil {
tx.Rollback() //nolint:errcheck
log.Printf("Create workspace %s: failed to persist compute config: %v", id, dbErr)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save compute config"})
return
}
}
// Persist initial secrets from the create payload (inside same transaction).
// nil/empty map is a no-op. Any failure rolls back the workspace insert
// so we never have a workspace row without its intended secrets.
@@ -679,6 +746,7 @@ func scanWorkspaceRow(rows interface {
Scan(dest ...interface{}) error
}) (map[string]interface{}, error) {
var id, name, role, status, url, sampleError, currentTask, runtime, workspaceDir string
var computeRaw []byte
var tier, activeTasks, maxConcurrentTasks, uptimeSeconds int
var errorRate, x, y float64
var collapsed, broadcastEnabled, talkToUserEnabled bool
@@ -690,7 +758,7 @@ func scanWorkspaceRow(rows interface {
err := rows.Scan(&id, &name, &role, &tier, &status, &agentCard, &url,
&parentID, &activeTasks, &maxConcurrentTasks, &errorRate, &sampleError, &uptimeSeconds,
&currentTask, &runtime, &workspaceDir, &x, &y, &collapsed,
&budgetLimit, &monthlySpend, &broadcastEnabled, &talkToUserEnabled)
&budgetLimit, &monthlySpend, &broadcastEnabled, &talkToUserEnabled, &computeRaw)
if err != nil {
return nil, err
}
@@ -717,6 +785,11 @@ func scanWorkspaceRow(rows interface {
"broadcast_enabled": broadcastEnabled,
"talk_to_user_enabled": talkToUserEnabled,
}
if len(computeRaw) > 0 && string(computeRaw) != "null" {
ws["compute"] = json.RawMessage(computeRaw)
} else {
ws["compute"] = json.RawMessage(`{}`)
}
// budget_limit: nil when no limit set, int64 otherwise
if budgetLimit.Valid {
@@ -752,7 +825,8 @@ const workspaceListQuery = `
COALESCE(w.workspace_dir, ''),
COALESCE(cl.x, 0), COALESCE(cl.y, 0), COALESCE(cl.collapsed, false),
w.budget_limit, COALESCE(w.monthly_spend, 0),
w.broadcast_enabled, w.talk_to_user_enabled
w.broadcast_enabled, w.talk_to_user_enabled,
COALESCE(w.compute, '{}'::jsonb)
FROM workspaces w
LEFT JOIN canvas_layouts cl ON cl.workspace_id = w.id
WHERE w.status != 'removed'
@@ -813,7 +887,8 @@ func (h *WorkspaceHandler) Get(c *gin.Context) {
COALESCE(w.workspace_dir, ''),
COALESCE(cl.x, 0), COALESCE(cl.y, 0), COALESCE(cl.collapsed, false),
w.budget_limit, COALESCE(w.monthly_spend, 0),
w.broadcast_enabled, w.talk_to_user_enabled
w.broadcast_enabled, w.talk_to_user_enabled,
COALESCE(w.compute, '{}'::jsonb)
FROM workspaces w
LEFT JOIN canvas_layouts cl ON cl.workspace_id = w.id
WHERE w.id = $1
@@ -33,7 +33,7 @@ var wsColumns = []string{
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
"budget_limit", "monthly_spend",
"broadcast_enabled", "talk_to_user_enabled",
"broadcast_enabled", "talk_to_user_enabled", "compute",
}
// ==================== GET — financial fields stripped from open endpoint ====================
@@ -56,7 +56,8 @@ func TestWorkspaceBudget_Get_NilLimit(t *testing.T) {
nil, // budget_limit NULL
0, // monthly_spend 0
false, // broadcast_enabled
true)) // talk_to_user_enabled
true, // talk_to_user_enabled
[]byte(`{}`)))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -100,7 +101,8 @@ func TestWorkspaceBudget_Get_WithLimit(t *testing.T) {
0.0, 0.0, false,
int64(500), // budget_limit = $5.00 in DB
int64(123), // monthly_spend = $1.23 in DB
false, true)) // broadcast_enabled, talk_to_user_enabled
false, true, // broadcast_enabled, talk_to_user_enabled
[]byte(`{}`)))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -145,18 +147,18 @@ func TestWorkspaceBudget_Create_WithLimit(t *testing.T) {
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO workspaces").
WithArgs(
sqlmock.AnyArg(), // id
"Budgeted Agent", // name
nil, // role
3, // tier (default, workspace.go create-handler)
"langgraph", // runtime
sqlmock.AnyArg(), // awareness_namespace
(*string)(nil), // parent_id
nil, // workspace_dir
"none", // workspace_access
&budgetVal, // budget_limit ($10)
sqlmock.AnyArg(), // id
"Budgeted Agent", // name
nil, // role
3, // tier (default, workspace.go create-handler)
"langgraph", // runtime
sqlmock.AnyArg(), // awareness_namespace
(*string)(nil), // parent_id
nil, // workspace_dir
"none", // workspace_access
&budgetVal, // budget_limit ($10)
models.DefaultMaxConcurrentTasks, // max_concurrent_tasks default
"push", // delivery_mode default (#2339)
"push", // delivery_mode default (#2339)
).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
@@ -168,7 +170,7 @@ func TestWorkspaceBudget_Create_WithLimit(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Budgeted Agent","budget_limit":1000}`
body := `{"name":"Budgeted Agent","model":"anthropic:claude-opus-4-7","budget_limit":1000}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
@@ -0,0 +1,208 @@
package handlers
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
"github.com/gin-gonic/gin"
)
const (
workspaceComputeDiskFloorGB = 30
workspaceComputeDiskCeilingGB = 500
)
type workspaceDisplayResponse struct {
Available bool `json:"available"`
Reason string `json:"reason,omitempty"`
Mode string `json:"mode,omitempty"`
Protocol string `json:"protocol,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
Status string `json:"status,omitempty"`
}
var workspaceComputeInstanceAllowlist = map[string]struct{}{
"t3.medium": {},
"t3.large": {},
"t3.xlarge": {},
"t3.2xlarge": {},
"m6i.large": {},
"m6i.xlarge": {},
"c6i.xlarge": {},
}
func validateWorkspaceCompute(compute models.WorkspaceCompute) error {
if compute.InstanceType != "" {
if _, ok := workspaceComputeInstanceAllowlist[compute.InstanceType]; !ok {
return fmt.Errorf("unsupported compute.instance_type")
}
}
if compute.Volume.RootGB != 0 {
if compute.Volume.RootGB < workspaceComputeDiskFloorGB || compute.Volume.RootGB > workspaceComputeDiskCeilingGB {
return fmt.Errorf("compute.volume.root_gb must be between %d and %d", workspaceComputeDiskFloorGB, workspaceComputeDiskCeilingGB)
}
}
switch compute.Display.Mode {
case "", "none", "desktop-control", "gpu-desktop-control":
default:
return fmt.Errorf("unsupported compute.display.mode")
}
switch compute.Display.Protocol {
case "", "dcv":
default:
return fmt.Errorf("unsupported compute.display.protocol")
}
if compute.Display.Width < 0 || compute.Display.Height < 0 {
return fmt.Errorf("compute.display width/height must be non-negative")
}
return nil
}
func validateWorkspaceDisplayConfig(display models.WorkspaceComputeDisplay) error {
switch display.Mode {
case "", "none", "desktop-control", "gpu-desktop-control":
default:
return fmt.Errorf("unsupported compute.display.mode")
}
switch display.Protocol {
case "", "dcv":
default:
return fmt.Errorf("unsupported compute.display.protocol")
}
if display.Width < 0 || display.Height < 0 {
return fmt.Errorf("compute.display width/height must be non-negative")
}
return nil
}
func workspaceComputeIsZero(compute models.WorkspaceCompute) bool {
return compute.InstanceType == "" &&
compute.Volume.RootGB == 0 &&
compute.Display.Mode == "" &&
compute.Display.Width == 0 &&
compute.Display.Height == 0 &&
compute.Display.Protocol == ""
}
func workspaceComputeJSON(compute models.WorkspaceCompute) (string, error) {
if workspaceComputeIsZero(compute) {
return "{}", nil
}
out := map[string]interface{}{}
if compute.InstanceType != "" {
out["instance_type"] = compute.InstanceType
}
if compute.Volume.RootGB != 0 {
out["volume"] = map[string]interface{}{"root_gb": compute.Volume.RootGB}
}
display := map[string]interface{}{}
if compute.Display.Mode != "" {
display["mode"] = compute.Display.Mode
}
if compute.Display.Width != 0 {
display["width"] = compute.Display.Width
}
if compute.Display.Height != 0 {
display["height"] = compute.Display.Height
}
if compute.Display.Protocol != "" {
display["protocol"] = compute.Display.Protocol
}
if len(display) > 0 {
out["display"] = display
}
b, err := json.Marshal(out)
if err != nil {
return "", err
}
return string(b), nil
}
func withStoredCompute(ctx context.Context, workspaceID string, payload models.CreateWorkspacePayload) models.CreateWorkspacePayload {
if !workspaceComputeIsZero(payload.Compute) || db.DB == nil {
return payload
}
var raw string
err := db.DB.QueryRowContext(ctx,
`SELECT COALESCE(compute, '{}'::jsonb) FROM workspaces WHERE id = $1`,
workspaceID,
).Scan(&raw)
if err != nil {
if err != sql.ErrNoRows {
log.Printf("withStoredCompute: load compute for %s failed: %v", workspaceID, err)
}
return payload
}
if raw == "" || raw == "{}" {
return payload
}
var compute models.WorkspaceCompute
if err := json.Unmarshal([]byte(raw), &compute); err != nil {
log.Printf("withStoredCompute: invalid compute JSON for %s: %v", workspaceID, err)
return payload
}
if err := validateWorkspaceCompute(compute); err != nil {
log.Printf("withStoredCompute: stored compute for %s failed validation: %v", workspaceID, err)
return payload
}
payload.Compute = compute
return payload
}
// Display handles GET /workspaces/:id/display.
//
// Phase 1 only exposes the product contract and the non-display unavailable
// state. Future desktop-control work will replace the display-enabled branch
// with short-lived proxied DCV session details.
func (h *WorkspaceHandler) Display(c *gin.Context) {
workspaceID := c.Param("id")
var raw string
err := db.DB.QueryRowContext(c.Request.Context(),
`SELECT COALESCE(compute, '{}'::jsonb) FROM workspaces WHERE id = $1`,
workspaceID,
).Scan(&raw)
if err != nil {
if err == sql.ErrNoRows {
c.JSON(404, gin.H{"error": "workspace not found"})
return
}
log.Printf("Display: load compute for %s failed: %v", workspaceID, err)
c.JSON(500, gin.H{"error": "failed to load display config"})
return
}
var compute models.WorkspaceCompute
if raw != "" && raw != "{}" {
if err := json.Unmarshal([]byte(raw), &compute); err != nil {
log.Printf("Display: invalid compute JSON for %s: %v", workspaceID, err)
c.JSON(500, gin.H{"error": "invalid display config"})
return
}
if err := validateWorkspaceDisplayConfig(compute.Display); err != nil {
log.Printf("Display: invalid stored compute for %s: %v", workspaceID, err)
c.JSON(500, gin.H{"error": "invalid display config"})
return
}
}
if compute.Display.Mode == "" || compute.Display.Mode == "none" {
c.JSON(200, workspaceDisplayResponse{
Available: false,
Reason: "display_not_enabled",
})
return
}
c.JSON(200, workspaceDisplayResponse{
Available: false,
Reason: "display_session_unavailable",
Mode: compute.Display.Mode,
Protocol: compute.Display.Protocol,
Width: compute.Display.Width,
Height: compute.Display.Height,
Status: "not_configured",
})
}
@@ -0,0 +1,319 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
"github.com/gin-gonic/gin"
)
func TestValidateWorkspaceCompute_AcceptsPhase1SizingAndDisplayNone(t *testing.T) {
compute := models.WorkspaceCompute{
InstanceType: "m6i.xlarge",
Volume: models.WorkspaceComputeVolume{RootGB: 100},
Display: models.WorkspaceComputeDisplay{Mode: "none"},
}
if err := validateWorkspaceCompute(compute); err != nil {
t.Fatalf("validateWorkspaceCompute returned error for valid compute: %v", err)
}
}
func TestValidateWorkspaceCompute_RejectsUnknownInstanceType(t *testing.T) {
compute := models.WorkspaceCompute{InstanceType: "p4d.24xlarge"}
if err := validateWorkspaceCompute(compute); err == nil {
t.Fatal("validateWorkspaceCompute accepted unsupported instance type")
}
}
func TestValidateWorkspaceCompute_RejectsOutOfRangeRootVolume(t *testing.T) {
for _, rootGB := range []int{29, 501} {
compute := models.WorkspaceCompute{Volume: models.WorkspaceComputeVolume{RootGB: rootGB}}
if err := validateWorkspaceCompute(compute); err == nil {
t.Fatalf("validateWorkspaceCompute accepted root_gb=%d", rootGB)
}
}
}
func TestWorkspaceComputeJSON_OmitsEmptyNestedSections(t *testing.T) {
got, err := workspaceComputeJSON(models.WorkspaceCompute{
InstanceType: "m6i.xlarge",
Volume: models.WorkspaceComputeVolume{RootGB: 100},
})
if err != nil {
t.Fatalf("workspaceComputeJSON returned error: %v", err)
}
if strings.Contains(got, `"display"`) {
t.Fatalf("workspaceComputeJSON included empty display section: %s", got)
}
if got != `{"instance_type":"m6i.xlarge","volume":{"root_gb":100}}` {
t.Fatalf("workspaceComputeJSON = %s", got)
}
}
func TestWorkspaceCreate_WithCompute_PersistsComputeJSON(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO workspaces").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`UPDATE workspaces SET compute = \$2::jsonb`).
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
mock.ExpectExec("INSERT INTO canvas_layouts").
WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{
"name":"Sized Agent",
"external":true,
"runtime":"external",
"compute":{
"instance_type":"m6i.xlarge",
"volume":{"root_gb":100},
"display":{"mode":"none"}
}
}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceCreate_WithInvalidCompute_ReturnsBadRequest(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{
"name":"Oversized Agent",
"model":"gpt-4",
"compute":{"instance_type":"p4d.24xlarge"}
}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestBuildProvisionerConfig_CopiesComputeSizingFromPayload(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectQuery(`SELECT COALESCE\(workspace_dir`).
WithArgs("ws-compute").
WillReturnRows(sqlmock.NewRows([]string{"workspace_dir", "workspace_access"}).AddRow("", "none"))
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
cfg := handler.buildProvisionerConfig(
context.Background(),
"ws-compute",
"",
nil,
models.CreateWorkspacePayload{
Tier: 4,
Runtime: "claude-code",
Compute: models.WorkspaceCompute{
InstanceType: "m6i.xlarge",
Volume: models.WorkspaceComputeVolume{RootGB: 100},
},
},
nil,
t.TempDir(),
"workspace:ws-compute",
)
if cfg.InstanceType != "m6i.xlarge" {
t.Errorf("cfg.InstanceType = %q, want m6i.xlarge", cfg.InstanceType)
}
if cfg.DiskGB != 100 {
t.Errorf("cfg.DiskGB = %d, want 100", cfg.DiskGB)
}
}
func TestWithStoredCompute_LoadsComputeForRestartPayloads(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-restart-compute").
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"instance_type":"m6i.xlarge","volume":{"root_gb":100}}`))
payload := models.CreateWorkspacePayload{Name: "Restart Me", Tier: 4, Runtime: "claude-code"}
got := withStoredCompute(context.Background(), "ws-restart-compute", payload)
if got.Compute.InstanceType != "m6i.xlarge" {
t.Errorf("stored compute instance_type = %q, want m6i.xlarge", got.Compute.InstanceType)
}
if got.Compute.Volume.RootGB != 100 {
t.Errorf("stored compute root_gb = %d, want 100", got.Compute.Volume.RootGB)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplay_NonDisplayWorkspaceReturnsUnavailable(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-no-display").
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{}`))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-no-display"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-no-display/display", nil)
handler.Display(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse display response: %v", err)
}
if resp["available"] != false {
t.Fatalf("available = %v, want false", resp["available"])
}
if resp["reason"] != "display_not_enabled" {
t.Fatalf("reason = %v, want display_not_enabled", resp["reason"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplay_DisplayConfiguredReturnsSessionUnavailableContract(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-display").
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"dcv","width":1920,"height":1080}}`))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-display/display", nil)
handler.Display(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse display response: %v", err)
}
if resp["available"] != false {
t.Fatalf("available = %v, want false", resp["available"])
}
if resp["reason"] != "display_session_unavailable" {
t.Fatalf("reason = %v, want display_session_unavailable", resp["reason"])
}
if resp["status"] != "not_configured" {
t.Fatalf("status = %v, want not_configured", resp["status"])
}
if resp["mode"] != "desktop-control" || resp["protocol"] != "dcv" {
t.Fatalf("mode/protocol = %v/%v, want desktop-control/dcv", resp["mode"], resp["protocol"])
}
if resp["width"] != float64(1920) || resp["height"] != float64(1080) {
t.Fatalf("width/height = %v/%v, want 1920/1080", resp["width"], resp["height"])
}
if _, ok := resp["url"]; ok {
t.Fatalf("display response exposed url before session infra exists: %v", resp["url"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplay_IgnoresUnrelatedStoredComputeSizingDrift(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-display-sizing-drift").
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"instance_type":"old.large","display":{"mode":"desktop-control","protocol":"dcv","width":1920,"height":1080}}`))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-display-sizing-drift"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-display-sizing-drift/display", nil)
handler.Display(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse display response: %v", err)
}
if resp["reason"] != "display_session_unavailable" {
t.Fatalf("reason = %v, want display_session_unavailable", resp["reason"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplay_InvalidStoredDisplayConfigReturnsServerError(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-invalid-display").
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"vnc"}}`))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-invalid-display"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-invalid-display/display", nil)
handler.Display(c)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected status 500, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse display response: %v", err)
}
if resp["error"] != "invalid display config" {
t.Fatalf("error = %v, want invalid display config", resp["error"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
@@ -0,0 +1,360 @@
package handlers
import (
"context"
"crypto/subtle"
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"time"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
"github.com/gin-gonic/gin"
)
const (
displayControlDefaultTTLSeconds = 300
displayControlMinTTLSeconds = 30
displayControlMaxTTLSeconds = 3600
)
type workspaceDisplayControlResponse struct {
Controller string `json:"controller"`
ControlledBy string `json:"controlled_by,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
}
type workspaceDisplayControlNoneResponse struct {
Controller string `json:"controller"`
}
type acquireDisplayControlRequest struct {
Controller string `json:"controller"`
TTLSeconds int `json:"ttl_seconds"`
}
type releaseDisplayControlRequest struct {
Force bool `json:"force"`
}
// DisplayControl handles GET /workspaces/:id/display/control.
func (h *WorkspaceHandler) DisplayControl(c *gin.Context) {
lock, found, err := h.loadActiveDisplayControl(c, c.Param("id"))
if err != nil {
log.Printf("DisplayControl: load lock for %s failed: %v", c.Param("id"), err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load display control"})
return
}
if !found {
c.JSON(http.StatusOK, workspaceDisplayControlNoneResponse{Controller: "none"})
return
}
c.JSON(http.StatusOK, lock)
}
// AcquireDisplayControl handles POST /workspaces/:id/display/control/acquire.
func (h *WorkspaceHandler) AcquireDisplayControl(c *gin.Context) {
var req acquireDisplayControlRequest
if c.Request.Body != nil && c.Request.ContentLength != 0 {
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid display control request"})
return
}
}
if req.Controller == "" {
req.Controller = "user"
}
if req.Controller != "user" {
c.JSON(http.StatusBadRequest, gin.H{"error": "browser callers may only acquire user display control"})
return
}
if req.TTLSeconds == 0 {
req.TTLSeconds = displayControlDefaultTTLSeconds
}
if req.TTLSeconds < displayControlMinTTLSeconds || req.TTLSeconds > displayControlMaxTTLSeconds {
c.JSON(http.StatusBadRequest, gin.H{"error": "ttl_seconds must be between 30 and 3600"})
return
}
if ok := h.displayControlEnabled(c, c.Param("id")); !ok {
return
}
controlledBy, ok := displayControlActor(c)
if !ok {
c.JSON(http.StatusForbidden, gin.H{"error": "display control requires admin-token or org-token auth"})
return
}
workspaceID := c.Param("id")
startedAt := time.Now()
emitDisplayControlEvent(c.Request.Context(), "display.control.acquire.started", workspaceID, map[string]any{
"controller": req.Controller,
"controlled_by": controlledBy,
"ttl_seconds": req.TTLSeconds,
})
var lock workspaceDisplayControlResponse
err := db.DB.QueryRowContext(c.Request.Context(), `
INSERT INTO workspace_display_control_locks
(workspace_id, controller, controlled_by, expires_at)
VALUES
($1, $2, $3, now() + ($4 * interval '1 second'))
ON CONFLICT (workspace_id) DO UPDATE
SET controller = EXCLUDED.controller,
controlled_by = EXCLUDED.controlled_by,
expires_at = EXCLUDED.expires_at,
updated_at = now()
WHERE workspace_display_control_locks.expires_at <= now()
OR workspace_display_control_locks.controlled_by = EXCLUDED.controlled_by
RETURNING controller, controlled_by, expires_at`,
workspaceID, req.Controller, controlledBy, req.TTLSeconds,
).Scan(&lock.Controller, &lock.ControlledBy, &lock.ExpiresAt)
if err == nil {
emitDisplayControlEvent(c.Request.Context(), "display.control.acquire.completed", workspaceID, map[string]any{
"controller": lock.Controller,
"controlled_by": lock.ControlledBy,
"ttl_seconds": req.TTLSeconds,
"duration_ms": time.Since(startedAt).Milliseconds(),
})
c.JSON(http.StatusOK, lock)
return
}
if err == sql.ErrNoRows {
current, found, loadErr := h.loadActiveDisplayControl(c, workspaceID)
if loadErr != nil {
log.Printf("AcquireDisplayControl: load active lock for %s failed: %v", workspaceID, loadErr)
emitDisplayControlEvent(c.Request.Context(), "display.control.acquire.failed", workspaceID, map[string]any{
"controlled_by": controlledBy,
"duration_ms": time.Since(startedAt).Milliseconds(),
"error": loadErr.Error(),
})
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load display control"})
return
}
emitDisplayControlEvent(c.Request.Context(), "display.control.acquire.failed", workspaceID, map[string]any{
"controlled_by": controlledBy,
"duration_ms": time.Since(startedAt).Milliseconds(),
"error": "display control already held",
})
if !found {
c.JSON(http.StatusConflict, gin.H{
"error": "display control already held",
"current": workspaceDisplayControlNoneResponse{Controller: "none"},
})
return
}
c.JSON(http.StatusConflict, gin.H{
"error": "display control already held",
"current": current,
})
return
}
log.Printf("AcquireDisplayControl: acquire lock for %s failed: %v", workspaceID, err)
emitDisplayControlEvent(c.Request.Context(), "display.control.acquire.failed", workspaceID, map[string]any{
"controlled_by": controlledBy,
"duration_ms": time.Since(startedAt).Milliseconds(),
"error": err.Error(),
})
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to acquire display control"})
}
// ReleaseDisplayControl handles POST /workspaces/:id/display/control/release.
func (h *WorkspaceHandler) ReleaseDisplayControl(c *gin.Context) {
var req releaseDisplayControlRequest
if c.Request.Body != nil && c.Request.ContentLength != 0 {
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid display control release request"})
return
}
}
if req.Force {
if !displayControlIsAdminToken(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "force release requires admin-token auth"})
return
}
}
controlledBy, ok := displayControlActor(c)
if !ok {
c.JSON(http.StatusForbidden, gin.H{"error": "display control requires admin-token or org-token auth"})
return
}
workspaceID := c.Param("id")
startedAt := time.Now()
emitDisplayControlEvent(c.Request.Context(), "display.control.release.started", workspaceID, map[string]any{
"controlled_by": controlledBy,
"force": req.Force,
})
query := `DELETE FROM workspace_display_control_locks WHERE workspace_id = $1 AND controlled_by = $2`
args := []interface{}{workspaceID, controlledBy}
if req.Force {
query = `DELETE FROM workspace_display_control_locks WHERE workspace_id = $1`
args = []interface{}{workspaceID}
}
result, err := db.DB.ExecContext(c.Request.Context(), query, args...)
if err != nil {
log.Printf("ReleaseDisplayControl: release lock for %s failed: %v", workspaceID, err)
emitDisplayControlEvent(c.Request.Context(), "display.control.release.failed", workspaceID, map[string]any{
"controlled_by": controlledBy,
"duration_ms": time.Since(startedAt).Milliseconds(),
"error": err.Error(),
"force": req.Force,
})
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to release display control"})
return
}
rowsAffected, err := result.RowsAffected()
if err != nil {
log.Printf("ReleaseDisplayControl: rows affected for %s failed: %v", workspaceID, err)
emitDisplayControlEvent(c.Request.Context(), "display.control.release.failed", workspaceID, map[string]any{
"controlled_by": controlledBy,
"duration_ms": time.Since(startedAt).Milliseconds(),
"error": err.Error(),
"force": req.Force,
})
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to release display control"})
return
}
if rowsAffected == 0 {
current, found, loadErr := h.loadActiveDisplayControl(c, workspaceID)
if loadErr != nil {
log.Printf("ReleaseDisplayControl: load active lock for %s failed: %v", workspaceID, loadErr)
emitDisplayControlEvent(c.Request.Context(), "display.control.release.failed", workspaceID, map[string]any{
"controlled_by": controlledBy,
"duration_ms": time.Since(startedAt).Milliseconds(),
"error": loadErr.Error(),
"force": req.Force,
})
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load display control"})
return
}
if !found {
emitDisplayControlEvent(c.Request.Context(), "display.control.release.completed", workspaceID, map[string]any{
"controlled_by": controlledBy,
"duration_ms": time.Since(startedAt).Milliseconds(),
"force": req.Force,
"rows_affected": rowsAffected,
})
c.JSON(http.StatusOK, workspaceDisplayControlNoneResponse{Controller: "none"})
return
}
emitDisplayControlEvent(c.Request.Context(), "display.control.release.failed", workspaceID, map[string]any{
"controlled_by": controlledBy,
"duration_ms": time.Since(startedAt).Milliseconds(),
"error": "display control held by another caller",
"force": req.Force,
})
c.JSON(http.StatusConflict, gin.H{
"error": "display control held by another caller",
"current": current,
})
return
}
emitDisplayControlEvent(c.Request.Context(), "display.control.release.completed", workspaceID, map[string]any{
"controlled_by": controlledBy,
"duration_ms": time.Since(startedAt).Milliseconds(),
"force": req.Force,
"rows_affected": rowsAffected,
})
c.JSON(http.StatusOK, workspaceDisplayControlNoneResponse{Controller: "none"})
}
func (h *WorkspaceHandler) loadActiveDisplayControl(c *gin.Context, workspaceID string) (workspaceDisplayControlResponse, bool, error) {
var lock workspaceDisplayControlResponse
err := db.DB.QueryRowContext(c.Request.Context(),
`SELECT controller, controlled_by, expires_at FROM workspace_display_control_locks WHERE workspace_id = $1 AND expires_at > now()`,
workspaceID,
).Scan(&lock.Controller, &lock.ControlledBy, &lock.ExpiresAt)
if err == nil {
return lock, true, nil
}
if err == sql.ErrNoRows {
return workspaceDisplayControlResponse{}, false, nil
}
return workspaceDisplayControlResponse{}, false, err
}
func (h *WorkspaceHandler) displayControlEnabled(c *gin.Context, workspaceID string) bool {
var raw string
err := db.DB.QueryRowContext(c.Request.Context(),
`SELECT COALESCE(compute, '{}'::jsonb) FROM workspaces WHERE id = $1`,
workspaceID,
).Scan(&raw)
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
return false
}
log.Printf("displayControlEnabled: load compute for %s failed: %v", workspaceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load display config"})
return false
}
compute, err := parseWorkspaceDisplayCompute(workspaceID, raw)
if err != nil {
log.Printf("displayControlEnabled: invalid display config for %s: %v", workspaceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid display config"})
return false
}
if compute.Display.Mode == "" || compute.Display.Mode == "none" {
c.JSON(http.StatusBadRequest, gin.H{"error": "display not enabled"})
return false
}
return true
}
func parseWorkspaceDisplayCompute(workspaceID, raw string) (models.WorkspaceCompute, error) {
var compute models.WorkspaceCompute
if raw == "" || raw == "{}" {
return compute, nil
}
if err := json.Unmarshal([]byte(raw), &compute); err != nil {
return models.WorkspaceCompute{}, fmt.Errorf("invalid compute JSON for %s: %w", workspaceID, err)
}
if err := validateWorkspaceDisplayConfig(compute.Display); err != nil {
return models.WorkspaceCompute{}, err
}
return compute, nil
}
func displayControlActor(c *gin.Context) (string, bool) {
if v, ok := c.Get("org_token_prefix"); ok {
if s, ok := v.(string); ok && s != "" {
return actorOrgTokenPrefix + s, true
}
}
if displayControlIsAdminToken(c) {
return actorAdminToken, true
}
// Browser session auth is intentionally observe-only until AdminAuth
// exposes a stable per-user or per-session identity in gin.Context.
return "", false
}
func displayControlIsAdminToken(c *gin.Context) bool {
adminSecret := os.Getenv("ADMIN_TOKEN")
if adminSecret == "" {
return false
}
tok := wsauth.BearerTokenFromHeader(c.GetHeader("Authorization"))
return subtle.ConstantTimeCompare([]byte(tok), []byte(adminSecret)) == 1
}
func emitDisplayControlEvent(ctx context.Context, eventType string, workspaceID string, payload map[string]any) {
if payload == nil {
payload = map[string]any{}
}
payloadJSON, err := json.Marshal(payload)
if err != nil {
log.Printf("emitDisplayControlEvent: marshal %s payload failed: %v", eventType, err)
return
}
if _, err := db.DB.ExecContext(ctx, `
INSERT INTO structure_events (event_type, workspace_id, payload, created_at)
VALUES ($1, $2, $3::jsonb, now())
`, eventType, workspaceID, string(payloadJSON)); err != nil {
log.Printf("emitDisplayControlEvent: insert %s failed: %v", eventType, err)
}
}
@@ -0,0 +1,321 @@
package handlers
import (
"bytes"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
)
func attachDisplayControlAdminToken(t *testing.T, c *gin.Context) {
t.Helper()
t.Setenv("ADMIN_TOKEN", "test-admin-secret")
c.Request.Header.Set("Authorization", "Bearer test-admin-secret")
}
func TestWorkspaceDisplayControl_NoActiveLockReturnsNone(t *testing.T) {
mock := setupTestDB(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectQuery(`SELECT controller, controlled_by, expires_at FROM workspace_display_control_locks WHERE workspace_id = \$1 AND expires_at > now\(\)`).
WithArgs("ws-display").
WillReturnError(sql.ErrNoRows)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-display/display/control", nil)
handler.DisplayControl(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["controller"] != "none" {
t.Fatalf("controller = %v, want none", resp["controller"])
}
if _, ok := resp["expires_at"]; ok {
t.Fatalf("none response included expires_at: %#v", resp)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplayControlAcquire_ClaimsUnlockedDisplay(t *testing.T) {
mock := setupTestDB(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
expiresAt := time.Date(2026, 5, 23, 18, 30, 0, 0, time.UTC)
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-display").
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"dcv","width":1920,"height":1080}}`))
mock.ExpectQuery(`INSERT INTO workspace_display_control_locks`).
WithArgs("ws-display", "user", "admin-token", 300).
WillReturnRows(sqlmock.NewRows([]string{"controller", "controlled_by", "expires_at"}).
AddRow("user", "admin-token", expiresAt))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
c.Request = httptest.NewRequest("POST", "/workspaces/ws-display/display/control/acquire", bytes.NewBufferString(`{"controller":"user","ttl_seconds":300}`))
c.Request.Header.Set("Content-Type", "application/json")
attachDisplayControlAdminToken(t, c)
handler.AcquireDisplayControl(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["controller"] != "user" || resp["controlled_by"] != "admin-token" {
t.Fatalf("lock response = %#v, want user/admin-token", resp)
}
if resp["expires_at"] == "" {
t.Fatalf("expires_at missing in response: %#v", resp)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplayControlAcquire_ActiveLockReturnsConflict(t *testing.T) {
mock := setupTestDB(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
expiresAt := time.Date(2026, 5, 23, 18, 30, 0, 0, time.UTC)
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-display").
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"dcv","width":1920,"height":1080}}`))
mock.ExpectQuery(`INSERT INTO workspace_display_control_locks`).
WithArgs("ws-display", "user", "admin-token", 300).
WillReturnError(sql.ErrNoRows)
mock.ExpectQuery(`SELECT controller, controlled_by, expires_at FROM workspace_display_control_locks WHERE workspace_id = \$1 AND expires_at > now\(\)`).
WithArgs("ws-display").
WillReturnRows(sqlmock.NewRows([]string{"controller", "controlled_by", "expires_at"}).
AddRow("agent", "sidecar", expiresAt))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
c.Request = httptest.NewRequest("POST", "/workspaces/ws-display/display/control/acquire", bytes.NewBufferString(`{"controller":"user","ttl_seconds":300}`))
c.Request.Header.Set("Content-Type", "application/json")
attachDisplayControlAdminToken(t, c)
handler.AcquireDisplayControl(c)
if w.Code != http.StatusConflict {
t.Fatalf("expected status 409, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["error"] != "display control already held" {
t.Fatalf("error = %v, want display control already held", resp["error"])
}
current, ok := resp["current"].(map[string]interface{})
if !ok || current["controller"] != "agent" || current["controlled_by"] != "sidecar" {
t.Fatalf("current lock = %#v, want agent/sidecar", resp["current"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplayControlAcquire_RejectsDisplayDisabledWorkspace(t *testing.T) {
mock := setupTestDB(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-no-display").
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{}`))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-no-display"}}
c.Request = httptest.NewRequest("POST", "/workspaces/ws-no-display/display/control/acquire", bytes.NewBufferString(`{"controller":"user","ttl_seconds":300}`))
c.Request.Header.Set("Content-Type", "application/json")
attachDisplayControlAdminToken(t, c)
handler.AcquireDisplayControl(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["error"] != "display not enabled" {
t.Fatalf("error = %v, want display not enabled", resp["error"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplayControlAcquire_RejectsCoarseSessionActor(t *testing.T) {
mock := setupTestDB(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-display").
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"dcv","width":1920,"height":1080}}`))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
c.Request = httptest.NewRequest("POST", "/workspaces/ws-display/display/control/acquire", bytes.NewBufferString(`{"controller":"user","ttl_seconds":300}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request.Header.Set("Cookie", "molecule_session=present")
handler.AcquireDisplayControl(c)
if w.Code != http.StatusForbidden {
t.Fatalf("expected status 403, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["error"] != "display control requires admin-token or org-token auth" {
t.Fatalf("error = %v, want display control requires admin-token or org-token auth", resp["error"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplayControlRelease_RemovesCallerLock(t *testing.T) {
mock := setupTestDB(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectExec(`DELETE FROM workspace_display_control_locks WHERE workspace_id = \$1 AND controlled_by = \$2`).
WithArgs("ws-display", "admin-token").
WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
c.Request = httptest.NewRequest("POST", "/workspaces/ws-display/display/control/release", nil)
attachDisplayControlAdminToken(t, c)
handler.ReleaseDisplayControl(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["controller"] != "none" {
t.Fatalf("controller = %v, want none", resp["controller"])
}
if _, ok := resp["expires_at"]; ok {
t.Fatalf("none response included expires_at: %#v", resp)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplayControlRelease_ConflictWhenCallerDoesNotOwnLock(t *testing.T) {
mock := setupTestDB(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
expiresAt := time.Date(2026, 5, 23, 18, 30, 0, 0, time.UTC)
mock.ExpectExec(`DELETE FROM workspace_display_control_locks WHERE workspace_id = \$1 AND controlled_by = \$2`).
WithArgs("ws-display", "admin-token").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectQuery(`SELECT controller, controlled_by, expires_at FROM workspace_display_control_locks WHERE workspace_id = \$1 AND expires_at > now\(\)`).
WithArgs("ws-display").
WillReturnRows(sqlmock.NewRows([]string{"controller", "controlled_by", "expires_at"}).
AddRow("user", "org-token:abcd1234", expiresAt))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
c.Request = httptest.NewRequest("POST", "/workspaces/ws-display/display/control/release", nil)
attachDisplayControlAdminToken(t, c)
handler.ReleaseDisplayControl(c)
if w.Code != http.StatusConflict {
t.Fatalf("expected status 409, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["error"] != "display control held by another caller" {
t.Fatalf("error = %v, want display control held by another caller", resp["error"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplayControlRelease_RejectsOrgTokenForceRelease(t *testing.T) {
setupTestDB(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
c.Set("org_token_prefix", "abcd1234")
c.Request = httptest.NewRequest("POST", "/workspaces/ws-display/display/control/release", bytes.NewBufferString(`{"force":true}`))
c.Request.Header.Set("Content-Type", "application/json")
handler.ReleaseDisplayControl(c)
if w.Code != http.StatusForbidden {
t.Fatalf("expected status 403, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["error"] != "force release requires admin-token auth" {
t.Fatalf("error = %v, want force release requires admin-token auth", resp["error"])
}
}
func TestWorkspaceDisplayControlAcquire_RejectsAgentImpersonation(t *testing.T) {
setupTestDB(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
c.Request = httptest.NewRequest("POST", "/workspaces/ws-display/display/control/acquire", bytes.NewBufferString(`{"controller":"agent","ttl_seconds":300}`))
c.Request.Header.Set("Content-Type", "application/json")
attachDisplayControlAdminToken(t, c)
handler.AcquireDisplayControl(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["error"] != "browser callers may only acquire user display control" {
t.Fatalf("error = %v, want browser callers may only acquire user display control", resp["error"])
}
}
@@ -296,6 +296,8 @@ func (h *WorkspaceHandler) buildProvisionerConfig(
WorkspaceAccess: workspaceAccess,
Tier: payload.Tier,
Runtime: payload.Runtime,
InstanceType: payload.Compute.InstanceType,
DiskGB: int32(payload.Compute.Volume.RootGB),
EnvVars: envVars,
PlatformURL: h.platformURL,
AwarenessURL: os.Getenv("AWARENESS_URL"),
@@ -548,13 +550,22 @@ func (h *WorkspaceHandler) ensureDefaultConfig(workspaceID string, payload model
// via a crafted runtime string (#241).
runtime := sanitizeRuntime(payload.Runtime)
// Generate a minimal config.yaml
// Generate a minimal config.yaml.
//
// SSOT (CTO 2026-05-22): model is REQUIRED user input. The platform
// must not provide a default; the runtime must not fall back. The
// Create handler is responsible for rejecting empty model BEFORE
// reaching provisionWorkspace; this is a defence-in-depth assertion.
// If we hit here with an empty model the YAML below would still
// render a `model: ""` line — which renders all downstream provider
// derivation undefined. Log loudly and let the workspace boot into
// not_configured rather than masking the contract violation with a
// silently-broken default (the prior `anthropic:claude-opus-4-7`
// fallback was the canonical example — every codex workspace
// created without an explicit model wedged).
model := payload.Model
if model == "" {
// SSOT: per-runtime defaults live in models/runtime_defaults.go
// (see RFC #2873). Was previously duplicated here AND in
// org_import.go; consolidating prevents silent drift.
model = models.DefaultModel(runtime)
log.Printf("ensureDefaultConfig: workspace %s reached provisioning with empty model — Create handler should have rejected this; rendering empty model: \"\" in config.yaml (workspace will boot not_configured)", workspaceID)
}
if runtime == "claude-code" {
model = normalizeClaudeCodeModel(model)
@@ -1009,4 +1020,3 @@ func (h *WorkspaceHandler) provisionWorkspaceCP(workspaceID, templatePath string
log.Printf("CPProvisioner: workspace %s started as machine %s via control plane", workspaceID, machineID)
}
@@ -756,47 +756,55 @@ func TestWorkspaceCreate_FirstDeploy_PersistsModelAndProvider(t *testing.T) {
}
}
// TestWorkspaceCreate_FirstDeploy_NoModel_NoSecretWritten asserts that
// when payload.Model is empty, NEITHER MODEL nor LLM_PROVIDER is
// written. Important: the canvas can omit `model` (template inherits
// the runtime default later); we must not poison workspace_secrets with
// empty rows in that case.
func TestWorkspaceCreate_FirstDeploy_NoModel_NoSecretWritten(t *testing.T) {
// TestWorkspaceCreate_FirstDeploy_NoModel_Returns422 inverts the prior
// premise (CTO 2026-05-22 SSOT directive — see
// feedback_workspace_model_required_no_platform_default_dynamic_credential_intake
// and TestCreate_ModelRequired_Returns422 in handlers_extended_test.go).
//
// Pre-2026-05-22 the canvas was allowed to omit `model` and the workspace
// would 201 with no workspace_secrets rows for MODEL/LLM_PROVIDER (the
// thinking being that templates inherit the runtime default later). That
// "soft fallback" was the load-bearing bug magnet — `DefaultModel(runtime)`
// would later return `anthropic:claude-opus-4-7`, and codex workspaces
// wedged forever at adapter init.
//
// New contract: empty model is a 422 MODEL_REQUIRED, with NO DB writes
// at all. The gate fires at the Create boundary before INSERT INTO
// workspaces. The follow-on workspace_secrets gate (which the original
// test pinned) is therefore unreachable on the empty-model path — there
// is no row to mint secrets for.
func TestWorkspaceCreate_FirstDeploy_NoModel_Returns422(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO workspaces").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
// NO INSERT INTO workspace_secrets here — the gate is payload.Model != "".
mock.ExpectExec("INSERT INTO canvas_layouts").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO structure_events").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`UPDATE workspaces SET status =`).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO workspace_auth_tokens").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO structure_events").
WillReturnResult(sqlmock.NewResult(0, 1))
// NO mock.ExpectBegin / INSERT INTO workspaces — the Create gate
// MUST fire before any DB write. If the gate fires late, sqlmock
// will surface "call to ExecQuery 'INSERT INTO workspaces' was not
// expected" — which is exactly the failure mode we want to flag.
// Body: hermes runtime WITHOUT external:true (the external-runtime
// exemption — see TestCreate_ExternalRuntime_NoModel_OK — does NOT
// apply here; hermes spawns a real adapter and model selection
// matters at adapter init). This is exactly the shape the old
// "no-model-no-secret-write" test pinned, minus the external flag.
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"No Model Agent","runtime":"hermes","external":true}`
body := `{"name":"No Model Agent","runtime":"hermes"}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d: %s", w.Code, w.Body.String())
if w.Code != http.StatusUnprocessableEntity {
t.Fatalf("expected 422 MODEL_REQUIRED for empty model, got %d: %s", w.Code, w.Body.String())
}
if !bytes.Contains(w.Body.Bytes(), []byte(`"code":"MODEL_REQUIRED"`)) {
t.Errorf("expected code=MODEL_REQUIRED in body, got %s", w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations not met — empty payload.Model should NOT trigger workspace_secrets writes: %v", err)
t.Errorf("sqlmock saw an unexpected DB write — the MODEL_REQUIRED gate fired too late: %v", err)
}
}
@@ -193,10 +193,17 @@ func TestEnsureDefaultConfig_Hermes(t *testing.T) {
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
// Post-CTO-SSOT-directive (2026-05-22): model is required user input;
// ensureDefaultConfig no longer fills in a runtime default. The Create
// handler gates on empty model and 422s before reaching here, so this
// test now passes the model explicitly to exercise the YAML rendering
// path — same model value the prior implicit DefaultModel("hermes")
// returned.
payload := models.CreateWorkspacePayload{
Name: "Test Agent",
Tier: 1,
Runtime: "hermes",
Model: "anthropic:claude-opus-4-7",
}
files := handler.ensureDefaultConfig("ws-test-123", payload)
@@ -219,7 +226,7 @@ func TestEnsureDefaultConfig_Hermes(t *testing.T) {
t.Errorf("config.yaml missing tier, got:\n%s", content)
}
if !contains(content, `model: "anthropic:claude-opus-4-7"`) {
t.Errorf("config.yaml should use default non-claude model, got:\n%s", content)
t.Errorf("config.yaml should render the supplied model, got:\n%s", content)
}
}
@@ -227,10 +234,14 @@ func TestEnsureDefaultConfig_ClaudeCode(t *testing.T) {
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
// Post-CTO-SSOT-directive (2026-05-22): model is supplied explicitly
// instead of relying on the deleted DefaultModel("claude-code") =
// "sonnet" fallback. The Create handler 422s on empty model upstream.
payload := models.CreateWorkspacePayload{
Name: "Code Agent",
Tier: 2,
Runtime: "claude-code",
Model: "sonnet",
}
files := handler.ensureDefaultConfig("ws-code-123", payload)
@@ -407,9 +418,16 @@ func TestEnsureDefaultConfig_EmptyRuntimeDefaultsToClaudeCode(t *testing.T) {
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
// Post-CTO-SSOT-directive (2026-05-22): ensureDefaultConfig is no
// longer the source of the model default — it just renders whatever
// the Create handler decided. The "empty runtime → claude-code"
// fallback inside sanitizeRuntime() is still in effect; this test
// continues to pin that behaviour by supplying the explicit
// claude-code model that the Create handler would have required.
payload := models.CreateWorkspacePayload{
Name: "Default Agent",
Tier: 1,
Name: "Default Agent",
Tier: 1,
Model: "sonnet",
}
files := handler.ensureDefaultConfig("ws-empty-rt", payload)
@@ -418,7 +436,7 @@ func TestEnsureDefaultConfig_EmptyRuntimeDefaultsToClaudeCode(t *testing.T) {
t.Errorf("empty runtime should default to claude-code, got:\n%s", configYAML)
}
if !contains(configYAML, `model: "sonnet"`) {
t.Errorf("claude-code default model should be sonnet (quoted), got:\n%s", configYAML)
t.Errorf("claude-code workspace should render the supplied model (quoted), got:\n%s", configYAML)
}
}
@@ -164,7 +164,7 @@ func (h *WorkspaceHandler) maybeRestartAfterFileWrite(workspaceID string) {
// isRestarting reports whether a restart cycle is currently in flight for
// the workspace. Callers that have their own "container looks dead" probe
// MUST consult this before triggering a restart, because during the
// 20-30s EC2-pending window the workspace's url='' and IsRunning()=false
// 20-30s EC2-pending window the workspace's url= and IsRunning()=false
// looks identical to a dead container — and any restart-triggering probe
// (maybeMarkContainerDead from canvas /delegations poll, or the trailing
// restart-context probe at the end of runRestartCycle) will set
@@ -337,7 +337,7 @@ func (h *WorkspaceHandler) Restart(c *gin.Context) {
}
var configFiles map[string][]byte
payload := models.CreateWorkspacePayload{Name: wsName, Tier: tier, Runtime: containerRuntime}
payload := withStoredCompute(ctx, id, models.CreateWorkspacePayload{Name: wsName, Tier: tier, Runtime: containerRuntime})
log.Printf("Restart: workspace %s (%s) runtime=%q", wsName, id, containerRuntime)
// #12: ?reset=true (or body.Reset) discards the claude-sessions volume
@@ -791,7 +791,7 @@ func (h *WorkspaceHandler) runRestartCycle(workspaceID string) {
})
// Runtime from DB — no more config file parsing
payload := models.CreateWorkspacePayload{Name: wsName, Tier: tier, Runtime: dbRuntime}
payload := withStoredCompute(ctx, workspaceID, models.CreateWorkspacePayload{Name: wsName, Tier: tier, Runtime: dbRuntime})
// Snapshot restart-context data before the new session overwrites
// last_heartbeat_at. Issue #19 Layer 1.
@@ -858,6 +858,9 @@ func (h *WorkspaceHandler) Pause(c *gin.Context) {
toPause = append(toPause, struct{ id, name string }{cid, cname})
}
}
if err := rows.Err(); err != nil {
log.Printf("Pause: descendant query rows.Err: %v", err)
}
}
// Stop containers and mark all as paused. StopWorkspaceAuto routes
@@ -939,6 +942,9 @@ func (h *WorkspaceHandler) Resume(c *gin.Context) {
toResume = append(toResume, ws)
}
}
if err := rows.Err(); err != nil {
log.Printf("Resume: descendant query rows.Err: %v", err)
}
}
// Re-provision all
@@ -948,7 +954,7 @@ func (h *WorkspaceHandler) Resume(c *gin.Context) {
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceProvisioning), ws.id, map[string]interface{}{
"name": ws.name, "tier": ws.tier, "runtime": ws.runtime,
})
payload := models.CreateWorkspacePayload{Name: ws.name, Tier: ws.tier, Runtime: ws.runtime}
payload := withStoredCompute(ctx, ws.id, models.CreateWorkspacePayload{Name: ws.name, Tier: ws.tier, Runtime: ws.runtime})
// Resume is provision-only (workspace is paused, no live container
// to stop). provisionWorkspaceAuto handles backend routing and the
// no-backend mark-failed fallback identically to Create. Pre-
@@ -29,7 +29,7 @@ func TestWorkspaceGet_Success(t *testing.T) {
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
"budget_limit", "monthly_spend",
"broadcast_enabled", "talk_to_user_enabled",
"broadcast_enabled", "talk_to_user_enabled", "compute",
}
mock.ExpectQuery("SELECT w.id, w.name").
WithArgs("cccccccc-0001-0000-0000-000000000000").
@@ -37,7 +37,7 @@ func TestWorkspaceGet_Success(t *testing.T) {
AddRow("cccccccc-0001-0000-0000-000000000000", "My Agent", "worker", 1, "online", []byte(`{"name":"test"}`),
"http://localhost:8001", nil, 2, 1, 0.05, "", 3600, "working", "langgraph",
"", 10.0, 20.0, false,
nil, 0, false, true))
nil, 0, false, true, []byte(`{}`)))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -119,7 +119,7 @@ func TestWorkspaceGet_RemovedReturns410(t *testing.T) {
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
"budget_limit", "monthly_spend",
"broadcast_enabled", "talk_to_user_enabled",
"broadcast_enabled", "talk_to_user_enabled", "compute",
}
mock.ExpectQuery("SELECT w.id, w.name").
WithArgs(id).
@@ -127,7 +127,7 @@ func TestWorkspaceGet_RemovedReturns410(t *testing.T) {
AddRow(id, "Old Agent", "worker", 1, string(models.StatusRemoved), []byte(`null`),
"", nil, 0, 1, 0.0, "", 0, "", "langgraph",
"", 0.0, 0.0, false,
nil, 0, false, true))
nil, 0, false, true, []byte(`{}`)))
mock.ExpectQuery(`SELECT updated_at FROM workspaces`).
WithArgs(id).
WillReturnRows(sqlmock.NewRows([]string{"updated_at"}).AddRow(removedAt))
@@ -183,7 +183,7 @@ func TestWorkspaceGet_RemovedReturns410WithNullRemovedAtOnTimestampFetchFailure(
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
"budget_limit", "monthly_spend",
"broadcast_enabled", "talk_to_user_enabled",
"broadcast_enabled", "talk_to_user_enabled", "compute",
}
mock.ExpectQuery("SELECT w.id, w.name").
WithArgs(id).
@@ -191,7 +191,7 @@ func TestWorkspaceGet_RemovedReturns410WithNullRemovedAtOnTimestampFetchFailure(
AddRow(id, "Vanished", "worker", 1, string(models.StatusRemoved), []byte(`null`),
"", nil, 0, 1, 0.0, "", 0, "", "langgraph",
"", 0.0, 0.0, false,
nil, 0, false, true))
nil, 0, false, true, []byte(`{}`)))
// Simulate the row vanishing between the two queries.
mock.ExpectQuery(`SELECT updated_at FROM workspaces`).
WithArgs(id).
@@ -246,7 +246,7 @@ func TestWorkspaceGet_RemovedWithIncludeQueryReturns200(t *testing.T) {
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
"budget_limit", "monthly_spend",
"broadcast_enabled", "talk_to_user_enabled",
"broadcast_enabled", "talk_to_user_enabled", "compute",
}
mock.ExpectQuery("SELECT w.id, w.name").
WithArgs(id).
@@ -254,7 +254,7 @@ func TestWorkspaceGet_RemovedWithIncludeQueryReturns200(t *testing.T) {
AddRow(id, "Audit Agent", "worker", 1, string(models.StatusRemoved), []byte(`null`),
"", nil, 0, 1, 0.0, "", 0, "", "langgraph",
"", 0.0, 0.0, false,
nil, 0, false, true))
nil, 0, false, true, []byte(`{}`)))
// last_outbound_at follow-up query (existing path)
mock.ExpectQuery(`SELECT last_outbound_at FROM workspaces`).
WithArgs(id).
@@ -349,7 +349,7 @@ func TestWorkspaceCreate_DBInsertError(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Failing Agent"}`
body := `{"name":"Failing Agent","model":"anthropic:claude-opus-4-7"}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -391,7 +391,7 @@ func TestWorkspaceCreate_DefaultsApplied(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Default Agent"}`
body := `{"name":"Default Agent","model":"anthropic:claude-opus-4-7"}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -438,7 +438,7 @@ func TestWorkspaceCreate_SaaSHardForcesTier4(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"SaaS External Agent","runtime":"external","external":true,"url":"https://example.com/agent","tier":2}`
body := `{"name":"SaaS External Agent","runtime":"external","model":"external:custom","external":true,"url":"https://example.com/agent","tier":2}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -479,7 +479,7 @@ func TestWorkspaceCreate_WithSecrets_Persists(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Hermes Agent","runtime":"hermes","external":true,"secrets":{"HERMES_API_KEY":"sk-test-123"}}`
body := `{"name":"Hermes Agent","runtime":"hermes","model":"anthropic:claude-opus-4-7","external":true,"secrets":{"HERMES_API_KEY":"sk-test-123"}}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -513,7 +513,7 @@ func TestWorkspaceCreate_SecretPersistFails_RollsBack(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Rollback Agent","secrets":{"OPENAI_API_KEY":"sk-fail"}}`
body := `{"name":"Rollback Agent","model":"anthropic:claude-opus-4-7","secrets":{"OPENAI_API_KEY":"sk-fail"}}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -548,7 +548,7 @@ func TestWorkspaceCreate_EmptySecrets_OK(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"No Secrets Agent","external":true,"secrets":{}}`
body := `{"name":"No Secrets Agent","model":"anthropic:claude-opus-4-7","external":true,"secrets":{}}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -587,7 +587,7 @@ func TestWorkspaceCreate_ExternalURL_SSRFSafe(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Ext Agent","runtime":"external","external":true,"url":"http://localhost:8000"}`
body := `{"name":"Ext Agent","runtime":"external","model":"external:custom","external":true,"url":"http://localhost:8000"}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -629,7 +629,7 @@ func TestWorkspaceCreate_KimiRuntime_PreservesLabel(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Kimi Agent","runtime":"kimi","tier":3,"canvas":{"x":100,"y":100}}`
body := `{"name":"Kimi Agent","runtime":"kimi","model":"kimi-coding/kimi-k2-coding-6","tier":3,"canvas":{"x":100,"y":100}}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -659,7 +659,7 @@ func TestWorkspaceCreate_ExternalURL_SSRFMetadataBlocked(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Bad Agent","runtime":"external","external":true,"url":"http://169.254.169.254/latest/meta-data/"}`
body := `{"name":"Bad Agent","runtime":"external","model":"external:custom","external":true,"url":"http://169.254.169.254/latest/meta-data/"}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -690,7 +690,7 @@ func TestWorkspaceCreate_ExternalURL_SSRFLoopbackBlocked(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Bad Loopback","runtime":"external","external":true,"url":"http://127.0.0.1:9000/a2a"}`
body := `{"name":"Bad Loopback","runtime":"external","model":"external:custom","external":true,"url":"http://127.0.0.1:9000/a2a"}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -718,7 +718,7 @@ func TestWorkspaceList_Empty(t *testing.T) {
"parent_id", "active_tasks", "last_error_rate", "last_sample_error",
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
"budget_limit", "monthly_spend",
"broadcast_enabled", "talk_to_user_enabled",
"broadcast_enabled", "talk_to_user_enabled", "compute",
}))
w := httptest.NewRecorder()
@@ -1422,7 +1422,7 @@ func TestWorkspaceGet_FinancialFieldsStripped(t *testing.T) {
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
"budget_limit", "monthly_spend",
"broadcast_enabled", "talk_to_user_enabled",
"broadcast_enabled", "talk_to_user_enabled", "compute",
}
// Populate with non-zero financial values to confirm they are stripped.
mock.ExpectQuery("SELECT w.id, w.name").
@@ -1431,7 +1431,7 @@ func TestWorkspaceGet_FinancialFieldsStripped(t *testing.T) {
AddRow("cccccccc-0010-0000-0000-000000000000", "Finance Test", "worker", 1, "online", []byte(`{}`),
"http://localhost:9001", nil, 0, 1, 0.0, "", 0, "", "langgraph",
"", 0.0, 0.0, false,
int64(50000), int64(12500), false, true)) // budget_limit=500 USD, spend=125 USD
int64(50000), int64(12500), false, true, []byte(`{}`))) // budget_limit=500 USD, spend=125 USD
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -1479,7 +1479,7 @@ func TestWorkspaceGet_SensitiveFieldsStripped(t *testing.T) {
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
"budget_limit", "monthly_spend",
"broadcast_enabled", "talk_to_user_enabled",
"broadcast_enabled", "talk_to_user_enabled", "compute",
}
mock.ExpectQuery("SELECT w.id, w.name").
WithArgs("cccccccc-0955-0000-0000-000000000000").
@@ -1492,7 +1492,7 @@ func TestWorkspaceGet_SensitiveFieldsStripped(t *testing.T) {
"langgraph",
"/home/user/secret-projects/client-work",
0.0, 0.0, false,
nil, 0, false, true))
nil, 0, false, true, []byte(`{}`)))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -1844,39 +1844,43 @@ func TestWorkspaceCreate_188_TemplateConfigNoRuntimeKey_FailsClosed(t *testing.T
}
}
// Regression guard: the legitimate default path (no template, no runtime —
// bare {"name":...}) MUST still default to langgraph and return 201. The
// #188 fix must not break this.
func TestWorkspaceCreate_188_NoTemplateNoRuntime_StillDefaultsLanggraph(t *testing.T) {
mock := setupTestDB(t)
// Pre-2026-05-22 this test guarded "bare {name} → langgraph 201" — the
// regression check for controlplane#188 (where an explicit runtime that
// failed to resolve must NOT silently substitute langgraph) had a sibling
// to ensure the LEGITIMATE bare default still landed on langgraph.
//
// Post-CTO-SSOT-directive (2026-05-22) bare body is 422 MODEL_REQUIRED
// before reaching the langgraph branch — the gate runs AFTER the
// langgraph-default assignment so the error body still surfaces
// runtime=langgraph (helps the caller see "ok, langgraph WOULD have
// been the runtime, but you still owe me a model"). The bare-body
// langgraph 201 path no longer exists; what we guard now is the
// 422-shape diagnostic.
//
// Bare-body-with-explicit-model 201 (the new "legitimate default" path)
// is covered by TestWorkspaceCreate in handlers_test.go — no need to
// duplicate the mock dance here.
func TestWorkspaceCreate_188_NoTemplateNoRuntime_NowMODEL_REQUIRED(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO workspaces").
WithArgs(sqlmock.AnyArg(), "Plain Default", nil, 3, "langgraph", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
mock.ExpectExec("INSERT INTO canvas_layouts").
WithArgs(sqlmock.AnyArg(), float64(0), float64(0)).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO structure_events").
WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Plain Default"}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201 (legitimate default path), got %d: %s", w.Code, w.Body.String())
if w.Code != http.StatusUnprocessableEntity {
t.Fatalf("bare-body create: expected 422 MODEL_REQUIRED, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
if !bytes.Contains(w.Body.Bytes(), []byte(`"code":"MODEL_REQUIRED"`)) {
t.Errorf("bare-body create: expected code=MODEL_REQUIRED in body, got %s", w.Body.String())
}
if !bytes.Contains(w.Body.Bytes(), []byte(`"runtime":"langgraph"`)) {
t.Errorf("bare-body create: expected runtime=\"langgraph\" in 422 body (the gate runs AFTER the langgraph-default assignment so the diagnostic surfaces what runtime WOULD have been used), got %s", w.Body.String())
}
}
@@ -1901,7 +1905,7 @@ func TestWorkspaceCreate_188_ExplicitRuntimeNoTemplate_OK(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Explicit Codex","runtime":"codex"}`
body := `{"name":"Explicit Codex","runtime":"codex","model":"gpt-5.5"}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -329,7 +329,13 @@ func (c *Client) doJSON(ctx context.Context, method, path string, reqBody interf
func decodeError(resp *http.Response) error {
var e contract.Error
body, _ := io.ReadAll(resp.Body)
body, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return &contract.Error{
Code: httpStatusToCode(resp.StatusCode),
Message: fmt.Sprintf("status %d (read body failed: %v)", resp.StatusCode, readErr),
}
}
if len(body) == 0 {
return &contract.Error{
Code: httpStatusToCode(resp.StatusCode),
@@ -3,6 +3,7 @@ package pgplugin
import (
"encoding/json"
"errors"
"log"
"net/http"
"strings"
@@ -246,7 +247,9 @@ func (h *Handler) forget(w http.ResponseWriter, r *http.Request, id string) {
func writeJSON(w http.ResponseWriter, status int, body interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
if err := json.NewEncoder(w).Encode(body); err != nil {
log.Printf("pgplugin: JSON encode error: %v", err)
}
}
func writeError(w http.ResponseWriter, status int, code contract.ErrorCode, message string, details map[string]interface{}) {
@@ -1,39 +1,31 @@
package models
// runtime_defaults.go — single source of truth for per-runtime defaults
// the platform applies when the operator/agent didn't supply a value.
// runtime_defaults.go — DELETED helper. Intentionally empty.
//
// Why this lives in models/ (not handlers/): default selection is a
// pure data fact about the runtime, not handler logic. Multiple
// callers (Create-workspace handler, org-import handler, future
// auto-provision paths) need the same answer; concentrating the
// rule here means one edit when a runtime's default changes.
// Previously held `DefaultModel(runtime string) string` which returned
// "sonnet" for claude-code and "anthropic:claude-opus-4-7" for everything
// else. That function was a SOFT-FALLBACK bug magnet:
//
// Related work (RFC #2873): this is the seed for a future
// `RuntimeConfig` interface that will also expose `ProvisioningTimeout()`,
// `CapabilitiesSupported()`, and other per-runtime facts. For now the
// surface is one helper — extracted from the duplicate branch in
// workspace_provision.go:537 and org_import.go:54 that diverged silently
// during refactors before this consolidation.
// DefaultModel returns the model slug to use when a workspace is
// created without an explicit model and the runtime can't infer one
// from its own config.
// - codex workspaces created without an explicit `model` silently
// received `anthropic:claude-opus-4-7`. Codex adapter only supports
// openai-* providers, so they wedged in `not_configured` with
// `codex adapter: workspace config picks provider='anthropic' but
// it is not in the providers registry`. The fallback never matched
// a runtime that could actually use it (only langgraph + hermes
// could even partially execute anthropic:claude-opus-4-7 without
// extra credential plumbing). It existed as a "must return
// something" placeholder that turned every silent miss into a
// prod incident.
//
// - claude-code: "sonnet" — Anthropic's CLI accepts the short
// name and resolves it via the operator's anthropic-oauth or
// ANTHROPIC_API_KEY chain.
// - everything else (hermes, langgraph, autogen, codex, openclaw,
// external, ""): a fully-qualified
// vendor:model slug that the universal MODEL_PROVIDER chain in
// molecule-core PR #247 can route via per-vendor required_env.
// - The fallback hid the contract bug at every callsite: Create
// handler, org_import, anywhere a stale CreateWorkspacePayload
// bubbled through to provisionWorkspace.
//
// The function never returns an empty string; an unknown runtime
// gets the universal default rather than failing closed (matches the
// pre-refactor behavior — both call sites used the same fallback).
func DefaultModel(runtime string) string {
if runtime == "claude-code" {
return "sonnet"
}
return "anthropic:claude-opus-4-7"
}
// SSOT principle (CTO 2026-05-22T03:42Z, feedback_workspace_model_required_no_platform_default_dynamic_credential_intake):
// model / provider / provider-credential are REQUIRED user input at
// create time. The platform must not provide a default. The runtime
// must not fall back. Decision belongs to the user (or to the agent
// acting on the user's behalf), never to the platform.
//
// Callers that previously fell back to DefaultModel must now fail-closed
// when model is empty after template-resolution.
@@ -1,59 +1,11 @@
package models
import "testing"
// TestDefaultModel pins the contract: known runtimes return their
// expected default; unknowns and the empty string fall through to the
// universal default. Add new runtimes here as `case` entries — pre-fix
// adding a runtime required two source edits + an audit; post-SSOT it
// requires one entry in DefaultModel + one assertion here.
func TestDefaultModel(t *testing.T) {
cases := []struct {
runtime string
want string
}{
// Known runtimes.
{"claude-code", "sonnet"},
// Universal fallback for everything else. Each runtime is named
// explicitly so a future drift (e.g., adding a hermes-specific
// branch) shows up as a failure on the runtime that drifted, not
// as a generic "unknown" failure.
{"hermes", "anthropic:claude-opus-4-7"},
{"langgraph", "anthropic:claude-opus-4-7"},
{"autogen", "anthropic:claude-opus-4-7"},
{"codex", "anthropic:claude-opus-4-7"},
{"openclaw", "anthropic:claude-opus-4-7"},
{"external", "anthropic:claude-opus-4-7"},
// Unknown / empty — fall through to universal default rather
// than failing closed. Pre-refactor both call sites also fell
// through; pinning the existing behavior, not changing it.
{"", "anthropic:claude-opus-4-7"},
{"some-future-runtime", "anthropic:claude-opus-4-7"},
{"CLAUDE-CODE", "anthropic:claude-opus-4-7"}, // case-sensitive — matches prior behavior
}
for _, tc := range cases {
t.Run(tc.runtime, func(t *testing.T) {
got := DefaultModel(tc.runtime)
if got != tc.want {
t.Errorf("DefaultModel(%q) = %q, want %q", tc.runtime, got, tc.want)
}
})
}
}
// TestDefaultModel_NeverEmpty — invariant: no input produces an empty
// string. The handlers that consume this would write empty into
// config.yaml, which the runtime then can't dispatch — pinning the
// non-empty contract here protects against a future "return early on
// unknown runtime" change that would silently break workspace creation.
func TestDefaultModel_NeverEmpty(t *testing.T) {
for _, runtime := range []string{
"", "claude-code", "hermes", "unknown-runtime",
} {
if got := DefaultModel(runtime); got == "" {
t.Errorf("DefaultModel(%q) returned empty string", runtime)
}
}
}
// runtime_defaults_test.go — previously pinned DefaultModel's contract
// (claude-code → "sonnet", everything else → "anthropic:claude-opus-4-7").
//
// DefaultModel was removed as a soft-fallback bug magnet (CTO 2026-05-22):
// model is REQUIRED user input; the platform must not provide a default.
// See runtime_defaults.go for the deletion rationale, and the new
// fail-closed gate in `handlers.WorkspaceHandler.Create` for the boundary
// enforcement. No test stub here — the contract is "this function does
// not exist", which the type-checker enforces at compile time.
+37 -16
View File
@@ -35,16 +35,16 @@ type Workspace struct {
// DeliveryMode: "push" (synchronous to URL — default) or "poll" (logged
// to activity_logs, agent reads via GET /activity?since_id=). See
// migration 045 + RFC #2339.
DeliveryMode string `json:"delivery_mode" db:"delivery_mode"`
DeliveryMode string `json:"delivery_mode" db:"delivery_mode"`
// BroadcastEnabled: when true the workspace may call POST /broadcast to
// deliver a message to all non-removed agent workspaces in the org.
// Default false — only privileged orchestrators should hold this ability.
BroadcastEnabled bool `json:"broadcast_enabled" db:"broadcast_enabled"`
BroadcastEnabled bool `json:"broadcast_enabled" db:"broadcast_enabled"`
// TalkToUserEnabled: when false the workspace's send_message_to_user calls
// and POST /notify requests are rejected with HTTP 403 so the agent is
// forced to route updates through a parent workspace. Default true
// (preserves existing behaviour for all workspaces).
TalkToUserEnabled bool `json:"talk_to_user_enabled" db:"talk_to_user_enabled"`
TalkToUserEnabled bool `json:"talk_to_user_enabled" db:"talk_to_user_enabled"`
// Canvas layout fields (from JOIN)
X float64 `json:"x"`
Y float64 `json:"y"`
@@ -71,12 +71,12 @@ type RegisterPayload struct {
// enforces the conditional requirement based on the resolved
// delivery mode (payload value, falling back to the row's existing
// value, falling back to "push").
URL string `json:"url"`
AgentCard json.RawMessage `json:"agent_card" binding:"required"`
URL string `json:"url"`
AgentCard json.RawMessage `json:"agent_card" binding:"required"`
// DeliveryMode is optional. Empty string means "keep the existing
// value on the workspace row, or default to push for new rows".
// When set, must be one of DeliveryModePush / DeliveryModePoll.
DeliveryMode string `json:"delivery_mode,omitempty"`
DeliveryMode string `json:"delivery_mode,omitempty"`
}
type HeartbeatPayload struct {
@@ -154,19 +154,36 @@ type MemorySeed struct {
Scope string `json:"scope" yaml:"scope"` // LOCAL, TEAM, GLOBAL
}
type WorkspaceComputeVolume struct {
RootGB int `json:"root_gb,omitempty"`
}
type WorkspaceComputeDisplay struct {
Mode string `json:"mode,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
Protocol string `json:"protocol,omitempty"`
}
type WorkspaceCompute struct {
InstanceType string `json:"instance_type,omitempty"`
Volume WorkspaceComputeVolume `json:"volume,omitempty"`
Display WorkspaceComputeDisplay `json:"display,omitempty"`
}
type CreateWorkspacePayload struct {
Name string `json:"name" binding:"required"`
Role string `json:"role"`
Template string `json:"template"` // workspace-configs-templates folder name
Tier int `json:"tier"`
Model string `json:"model"`
Runtime string `json:"runtime"` // "langgraph" (default), "claude-code", etc.
External bool `json:"external"` // true = no Docker container, just a registered URL
URL string `json:"url"` // for external workspaces: the A2A endpoint URL (push mode only — omit for poll)
Name string `json:"name" binding:"required"`
Role string `json:"role"`
Template string `json:"template"` // workspace-configs-templates folder name
Tier int `json:"tier"`
Model string `json:"model"`
Runtime string `json:"runtime"` // "langgraph" (default), "claude-code", etc.
External bool `json:"external"` // true = no Docker container, just a registered URL
URL string `json:"url"` // for external workspaces: the A2A endpoint URL (push mode only — omit for poll)
// DeliveryMode: "push" (default) sends inbound A2A to URL synchronously;
// "poll" records inbound to activity_logs for the agent to consume via
// GET /activity?since_id=. Poll mode does not require a URL. See #2339.
DeliveryMode string `json:"delivery_mode,omitempty"`
DeliveryMode string `json:"delivery_mode,omitempty"`
WorkspaceDir string `json:"workspace_dir"` // host path to mount as /workspace (empty = isolated volume)
WorkspaceAccess string `json:"workspace_access"` // "none" (default), "read_only", or "read_write" — see #65
ParentID *string `json:"parent_id"`
@@ -180,7 +197,11 @@ type CreateWorkspacePayload struct {
// MaxConcurrentTasks caps parallel A2A + cron dispatch. 0 means use
// DefaultMaxConcurrentTasks. Leaders typically set 3.
MaxConcurrentTasks int `json:"max_concurrent_tasks"`
Canvas struct {
// Compute is the product-facing per-workspace EC2 shape/display
// contract. Phase 1 uses instance_type + volume.root_gb and persists
// display for future desktop-control workspaces.
Compute WorkspaceCompute `json:"compute,omitempty"`
Canvas struct {
X float64 `json:"x"`
Y float64 `json:"y"`
} `json:"canvas"`
@@ -152,12 +152,14 @@ func (p *CPProvisioner) adminAuthHeaders(req *http.Request) {
}
type cpProvisionRequest struct {
OrgID string `json:"org_id"`
WorkspaceID string `json:"workspace_id"`
Runtime string `json:"runtime"`
Tier int `json:"tier"`
PlatformURL string `json:"platform_url"`
Env map[string]string `json:"env"`
OrgID string `json:"org_id"`
WorkspaceID string `json:"workspace_id"`
Runtime string `json:"runtime"`
Tier int `json:"tier"`
InstanceType string `json:"instance_type,omitempty"`
DiskGB int32 `json:"disk_gb,omitempty"`
PlatformURL string `json:"platform_url"`
Env map[string]string `json:"env"`
// ConfigFiles are template + generated config files to write into the
// EC2 instance's /configs directory. OFFSEC-010: collected by
// collectCPConfigFiles which rejects symlinks and non-regular files
@@ -206,13 +208,15 @@ func (p *CPProvisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string,
}
req := cpProvisionRequest{
OrgID: p.orgID,
WorkspaceID: cfg.WorkspaceID,
Runtime: cfg.Runtime,
Tier: cfg.Tier,
PlatformURL: cfg.PlatformURL,
Env: env,
ConfigFiles: configFiles,
OrgID: p.orgID,
WorkspaceID: cfg.WorkspaceID,
Runtime: cfg.Runtime,
Tier: cfg.Tier,
InstanceType: cfg.InstanceType,
DiskGB: cfg.DiskGB,
PlatformURL: cfg.PlatformURL,
Env: env,
ConfigFiles: configFiles,
}
body, err := json.Marshal(req)
@@ -191,6 +191,12 @@ func TestStart_HappyPath(t *testing.T) {
if body.WorkspaceID != "ws-1" || body.Runtime != "python" {
t.Errorf("body mismatch: %+v", body)
}
if body.InstanceType != "m6i.xlarge" {
t.Errorf("instance_type = %q, want m6i.xlarge", body.InstanceType)
}
if body.DiskGB != 100 {
t.Errorf("disk_gb = %d, want 100", body.DiskGB)
}
w.WriteHeader(http.StatusCreated)
_, _ = io.WriteString(w, `{"instance_id":"i-abc123","state":"pending"}`)
}))
@@ -205,6 +211,7 @@ func TestStart_HappyPath(t *testing.T) {
id, err := p.Start(context.Background(), WorkspaceConfig{
WorkspaceID: "ws-1", Runtime: "python", Tier: 1, PlatformURL: "http://tenant",
InstanceType: "m6i.xlarge", DiskGB: 100,
})
if err != nil {
t.Fatalf("Start: %v", err)
@@ -362,7 +369,7 @@ func TestStart_CollectsConfigFiles(t *testing.T) {
p := &CPProvisioner{baseURL: srv.URL, orgID: "org-1", httpClient: srv.Client()}
_, err := p.Start(context.Background(), WorkspaceConfig{
WorkspaceID: "ws-1",
Runtime: "python",
Runtime: "python",
Tier: 1,
PlatformURL: "http://tenant",
TemplatePath: tmpl,
@@ -424,7 +431,7 @@ func TestStart_SymlinkTemplatePathError(t *testing.T) {
p := &CPProvisioner{baseURL: "http://unused", orgID: "org-1", httpClient: &http.Client{Timeout: time.Second}}
_, err := p.Start(context.Background(), WorkspaceConfig{
WorkspaceID: "ws-1",
Runtime: "python",
Runtime: "python",
TemplatePath: symlink, // symlink root → OFFSEC-010 guard should fire
})
if err == nil {
@@ -98,6 +98,8 @@ type WorkspaceConfig struct {
WorkspacePath string // Host path to bind-mount as /workspace (if empty, uses Docker named volume)
Tier int
Runtime string // "langgraph" (default) or "claude-code", "codex", "ollama", "custom"
InstanceType string // Optional CP EC2 instance type override (SaaS only)
DiskGB int32 // Optional CP root volume size override in GiB (SaaS only)
EnvVars map[string]string // Additional env vars (API keys, etc.)
PlatformURL string
AwarenessURL string
@@ -1605,4 +1607,3 @@ func parseOCIPlatform(s string) *ocispec.Platform {
}
return &ocispec.Platform{OS: parts[0], Architecture: parts[1]}
}
@@ -93,6 +93,9 @@ func sweepOnlineWorkspaces(ctx context.Context, checker ContainerChecker, onOffl
ids = append(ids, id)
}
}
if err := rows.Err(); err != nil {
log.Printf("Health sweep: rows error: %v", err)
}
for _, id := range ids {
running, err := checker.IsRunning(ctx, id)
@@ -159,6 +162,9 @@ func sweepStaleRemoteWorkspaces(ctx context.Context, onOffline OfflineHandler) {
ids = append(ids, id)
}
}
if err := rows.Err(); err != nil {
log.Printf("Health sweep: rows error: %v", err)
}
for _, id := range ids {
// External workspaces flip to 'awaiting_agent' (re-registrable
@@ -166,6 +166,9 @@ func sweepStuckProvisioning(ctx context.Context, emitter ProvisionTimeoutEmitter
ids = append(ids, c)
}
}
if err := rows.Err(); err != nil {
log.Printf("Provision-timeout sweep: rows error: %v", err)
}
for _, c := range ids {
timeout := provisioningTimeoutFor(c.runtime, lookup)
@@ -178,6 +178,13 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
// the tenant AWS credentials. Admin-gated because console output
// can include user-data snippets we treat as semi-sensitive.
wsAdmin.GET("/workspaces/:id/console", wh.Console)
// Display sessions will eventually return short-lived proxied DCV
// URLs, so keep the endpoint admin-gated from the first unavailable
// state rather than widening it later.
wsAdmin.GET("/workspaces/:id/display", wh.Display)
wsAdmin.GET("/workspaces/:id/display/control", wh.DisplayControl)
wsAdmin.POST("/workspaces/:id/display/control/acquire", wh.AcquireDisplayControl)
wsAdmin.POST("/workspaces/:id/display/control/release", wh.ReleaseDisplayControl)
// Admin memory backup/restore (#1051) — bulk export/import of agent
// memories for safe Docker rebuilds. Matches workspaces by name on import.
@@ -0,0 +1,61 @@
package router
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/handlers"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/middleware"
"github.com/gin-gonic/gin"
)
func buildWorkspaceDisplayEngine(t *testing.T) *gin.Engine {
t.Helper()
gin.SetMode(gin.TestMode)
r := gin.New()
wh := handlers.NewWorkspaceHandler(nil, nil, "http://localhost:8080", t.TempDir())
r.GET("/workspaces/:id/display", middleware.AdminAuth(db.DB), wh.Display)
r.POST("/workspaces/:id/display/control/acquire", middleware.AdminAuth(db.DB), wh.AcquireDisplayControl)
return r
}
func TestWorkspaceDisplayRoute_RequiresAdminAuth(t *testing.T) {
t.Setenv("ADMIN_TOKEN", "test-admin-secret-not-presented-by-caller")
mock := setupRouterTestDB(t)
mock.ExpectQuery("SELECT COUNT.*FROM workspace_auth_tokens").
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
r := buildWorkspaceDisplayEngine(t)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/workspaces/ws-display/display", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401 for unauthenticated request, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock unmet: %v", err)
}
}
func TestWorkspaceDisplayControlRoute_RequiresAdminAuth(t *testing.T) {
t.Setenv("ADMIN_TOKEN", "test-admin-secret-not-presented-by-caller")
mock := setupRouterTestDB(t)
mock.ExpectQuery("SELECT COUNT.*FROM workspace_auth_tokens").
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
r := buildWorkspaceDisplayEngine(t)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/workspaces/ws-display/display/control/acquire", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401 for unauthenticated request, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock unmet: %v", err)
}
}
@@ -425,6 +425,7 @@ func (s *Scheduler) fireSchedule(ctx context.Context, sched scheduleRow) {
lastStatus := "ok"
lastError := ""
resultKind := ""
if proxyErr != nil {
lastStatus = "error"
lastError = fmt.Sprintf("%v", proxyErr)
@@ -433,8 +434,26 @@ func (s *Scheduler) fireSchedule(ctx context.Context, sched scheduleRow) {
lastStatus = "error"
lastError = fmt.Sprintf("HTTP %d", statusCode)
log.Printf("Scheduler: '%s' non-2xx: %d", sched.Name, statusCode)
} else if a2aErr := a2aErrorFromBody(respBody); a2aErr != "" {
lastStatus = "error"
lastError = fmt.Sprintf("A2A adapter error: %s", a2aErr)
log.Printf("Scheduler: '%s' A2A adapter error (HTTP %d): %s", sched.Name, statusCode, a2aErr)
} else {
log.Printf("Scheduler: '%s' completed (HTTP %d)", sched.Name, statusCode)
// HTTP 200 — inspect response body for SDK-layer errors.
// The claude-code-sdk adapter returns HTTP 200 even when the inner
// LLM call throws (e.g. Max-plan rate-limit, quota exhaustion, SDK
// internal errors). Without this check those failures surface as
// "completed (HTTP 200)" in last_status while the agent chat shows
// errors — a silent failure that hides schedule outages.
// See: #1696.
resultKind = detectResultKind(respBody)
if resultKind != "" && resultKind != "ok" {
lastStatus = resultKind
lastError = fmt.Sprintf("SDK error: result_kind=%s", resultKind)
log.Printf("Scheduler: '%s' SDK error detected — result_kind=%s", sched.Name, resultKind)
} else {
log.Printf("Scheduler: '%s' completed (HTTP %d)", sched.Name, statusCode)
}
}
// #795: detect phantom-producing schedules — cron fires successfully
@@ -479,6 +498,54 @@ func (s *Scheduler) fireSchedule(ctx context.Context, sched scheduleRow) {
resetCancel()
}
// #1696: track consecutive SDK errors. When the adapter returns HTTP 200
// but the response body signals a non-ok result_kind (rate_limited,
// sdk_error, quota_exhausted), we increment a counter. After 3 consecutive
// SDK errors we auto-disable the schedule and log it — the schedule is
// suffering a persistent LLM-layer failure and firing it again will keep
// producing the same errors while burning tokens.
//
// Only apply when the current lastStatus is a non-ok resultKind (not when
// we already have 'error' from proxyErr or non-2xx HTTP status — those have
// their own failure semantics). Also skip when lastStatus is 'stale' (the
// empty-response escalation path takes priority).
var consecSDK int
if resultKind != "" && resultKind != "ok" {
sdkCtx, sdkCancel := context.WithTimeout(context.Background(), dbQueryTimeout)
if err := db.DB.QueryRowContext(sdkCtx, `
UPDATE workspace_schedules
SET consecutive_sdk_errors = consecutive_sdk_errors + 1,
updated_at = now()
WHERE id = $1
RETURNING consecutive_sdk_errors`, sched.ID).Scan(&consecSDK); err != nil {
log.Printf("Scheduler: '%s' SDK-error bump failed: %v", sched.Name, err)
}
sdkCancel()
if consecSDK >= 3 {
log.Printf("Scheduler: '%s' AUTO-DISABLING after %d consecutive SDK errors (workspace %s)",
sched.Name, consecSDK, short(sched.WorkspaceID, 12))
autoDisableCtx, autoDisableCancel := context.WithTimeout(context.Background(), dbQueryTimeout)
_, _ = db.DB.ExecContext(autoDisableCtx, `
UPDATE workspace_schedules SET enabled = false, updated_at = now() WHERE id = $1 AND enabled = true`,
sched.ID)
autoDisableCancel()
}
} else {
// Non-SDK-error run — reset the counter.
// Guard: only reset when lastStatus is a clean ok (not 'stale', not
// 'error', not resultKind). An 'ok' resultKind means the SDK is fine
// and we should clear the streak.
if lastStatus == "ok" {
resetCtx, resetCancel := context.WithTimeout(context.Background(), dbQueryTimeout)
_, _ = db.DB.ExecContext(resetCtx, `
UPDATE workspace_schedules
SET consecutive_sdk_errors = 0,
updated_at = now()
WHERE id = $1`, sched.ID)
resetCancel()
}
}
nextRun, nextErr := ComputeNextRun(sched.CronExpr, sched.Timezone, time.Now())
var nextRunPtr *time.Time
if nextErr == nil {
@@ -759,6 +826,149 @@ func (s *Scheduler) sweepPhantomBusy(ctx context.Context) {
}
}
// detectResultKind inspects an A2A response body for SDK-layer error signals
// that are invisible at the HTTP level. The claude-code-sdk adapter returns
// HTTP 200 even when the inner LLM call throws (Max-plan rate-limit, quota
// exhaustion, SDK internal errors) — the error surfaces only in the response
// body under result.kind or result.result_kind.
//
// Returns an empty string when the response is clean (result_kind is "ok",
// "message" — the A2A-SDK canonical successful Message envelope — or absent).
// Returns the result_kind value when it is a non-ok signal, so callers can
// propagate it as the schedule's last_status.
//
// Known successful (= treat-as-ok) kinds (resultOKKinds):
// - "ok" — explicit success signal
// - "message" — A2A-SDK Message envelope (`{"result":{"kind":"message","parts":[...]}}`),
// emitted by every successful agent reply. Fix: #1696 originally allow-listed only
// "ok" / empty, which mis-flagged every successful agent response as an SDK error
// (PM scheduler observed 21 consecutive false-failure ticks before auto-disable;
// screenshot 2026-05-23). See [#1696 follow-up].
//
// Known non-ok kinds:
// - "rate_limited" — LLM API rate-limit hit (Max-plan, etc.)
// - "quota_exhausted" — quota / budget exhausted
// - "sdk_error" — SDK threw an internal error
//
// See #1696.
//
// resultOKKinds is the allowlist of `result.kind` values that are
// UNCONDITIONALLY successful (no further parsing needed). Anything
// outside this set is treated as a non-ok SDK signal, EXCEPT `task`
// which is gated separately on `result.status.state` (see
// classifyTaskState — A2A Task can be either in-progress or terminally
// failed, depending on its status).
//
// Add to this list when new always-success envelope kinds are introduced
// upstream. NEVER add an envelope that can carry a failure sub-state.
var resultOKKinds = map[string]struct{}{
"": {}, // absent / empty → treat as ok (no signal)
"ok": {}, // explicit success
"message": {}, // A2A-SDK Message envelope (always a successful agent reply)
}
// taskOKStates is the A2A Task `status.state` allowlist for results that
// have `kind: "task"`. Tasks can be in-progress (submitted/working) or
// terminally successful (completed) — those are clean signals to the
// scheduler. Terminal failure states (failed/canceled/rejected) are
// surfaced as the scheduler's last_status so operators can see the real
// state. Cf. CR2 review feedback on #1716.
var taskOKStates = map[string]struct{}{
"": {}, // status.state absent → conservative: don't fire false-failure
"submitted": {}, // task accepted, not yet running
"working": {}, // task in progress
"completed": {}, // task finished successfully
}
// classifyTaskState inspects `result.status.state` (or `result.status_state`
// legacy variant) and returns "" when the state is in taskOKStates (success
// or in-progress) or the state string when it is a terminal failure that
// should propagate as last_status.
func classifyTaskState(result map[string]json.RawMessage) string {
rawStatus, ok := result["status"]
if !ok {
return "" // no status block → no signal, leave clean
}
var status map[string]json.RawMessage
if err := json.Unmarshal(rawStatus, &status); err != nil {
return ""
}
if rawState, ok := status["state"]; ok {
var s string
if json.Unmarshal(rawState, &s) == nil {
if _, isOK := taskOKStates[s]; !isOK {
return s
}
}
}
return ""
}
func detectResultKind(body []byte) string {
if len(body) == 0 {
return ""
}
var top map[string]json.RawMessage
if err := json.Unmarshal(body, &top); err != nil {
return ""
}
// Check result.kind first (canonical JSON-RPC shape).
if rawResult, ok := top["result"]; ok {
var result map[string]json.RawMessage
if err := json.Unmarshal(rawResult, &result); err == nil {
// result.kind (canonical JSON-RPC envelope field).
if rawKind, ok := result["kind"]; ok {
var k string
if json.Unmarshal(rawKind, &k) == nil {
// Special-case task: success or failure depends on status.state.
if k == "task" {
if bad := classifyTaskState(result); bad != "" {
return bad
}
// task with ok / in-progress state → clean
} else if _, isOK := resultOKKinds[k]; !isOK {
return k
}
}
}
// result.result_kind (legacy / alternative field name).
if rawKind, ok := result["result_kind"]; ok {
var k string
if json.Unmarshal(rawKind, &k) == nil {
if k == "task" {
if bad := classifyTaskState(result); bad != "" {
return bad
}
} else if _, isOK := resultOKKinds[k]; !isOK {
return k
}
}
}
}
}
// Top-level error: non-ok HTTP 200 with a structured error in the body.
if rawErr, ok := top["error"]; ok {
var errMsg string
if err := json.Unmarshal(rawErr, &errMsg); err == nil && errMsg != "" {
// Distinguish SDK errors from other errors. SDK-layer errors from the
// Claude Code runtime include specific markers.
lower := strings.ToLower(errMsg)
// Check more specific patterns first (max-plan quota > general rate).
if strings.Contains(lower, "max-plan") || strings.Contains(lower, "quota") || strings.Contains(lower, "budget") {
return "quota_exhausted"
}
if strings.Contains(lower, "rate limit") || strings.Contains(lower, "rate_limit") {
return "rate_limited"
}
if strings.Contains(lower, "claude code returned an error") || strings.Contains(lower, "sdk error") ||
strings.Contains(lower, "api key") || strings.Contains(lower, "authentication") {
return "sdk_error"
}
}
}
return ""
}
// isEmptyResponse checks if an A2A response body indicates the agent
// produced no meaningful output. Catches "(no response generated)" from
// the workspace runtime + genuinely empty/null responses. Used by the
@@ -808,6 +1018,32 @@ func isEmptyResponse(body []byte) bool {
return false
}
// a2aErrorFromBody extracts an A2A/JSON-RPC error message from a 2xx
// response body. The adapter SDK may return HTTP 200 with an error
// payload when it throws internally; this prevents the scheduler from
// falsely recording last_status='ok'.
// Issue #1696.
func a2aErrorFromBody(body []byte) string {
if len(body) == 0 {
return ""
}
var resp map[string]interface{}
if json.Unmarshal(body, &resp) != nil {
return ""
}
// JSON-RPC style: {"error":{"code":-32603,"message":"..."}}
if errObj, ok := resp["error"].(map[string]interface{}); ok {
if msg, ok := errObj["message"].(string); ok {
return msg
}
}
// Plain style: {"error":"..."}
if errStr, ok := resp["error"].(string); ok {
return errStr
}
return ""
}
// truncation moved to internal/textutil.TruncateBytes (#2962 SSOT).
// The original #2026 fix lives in textutil's package docs as canonical
// prior art. Ellipsis was previously "..." (3 ASCII bytes); the SSOT
@@ -3,6 +3,7 @@ package scheduler
import (
"context"
"database/sql"
"encoding/json"
"testing"
"time"
"unicode/utf8"
@@ -256,6 +257,58 @@ func (p *successProxy) ProxyA2ARequest(
return 200, []byte(`{"ok":true}`), nil
}
// ── adapterErrorProxy ─────────────────────────────────────────────────────────
// adapterErrorProxy is a test double whose ProxyA2ARequest returns HTTP 200
// with a JSON-RPC error body, simulating an adapter SDK that throws internally
// but still completes the HTTP round-trip. Issue #1696.
type adapterErrorProxy struct{}
func (p *adapterErrorProxy) ProxyA2ARequest(
_ context.Context, _ string, _ []byte, _ string, _ bool,
) (int, []byte, error) {
return 200, []byte(`{"jsonrpc":"2.0","id":"cron-test-123","error":{"code":-32603,"message":"adapter SDK internal error"}}`), nil
}
// ── TestFireSchedule_AdapterSDKError (#1696) ──────────────────────────────────
//
// When the adapter SDK throws internally and returns HTTP 200 with an error
// payload, fireSchedule must record last_status='error', not 'ok'.
func TestFireSchedule_AdapterSDKError(t *testing.T) {
mock := setupTestDB(t)
sched := scheduleRow{
ID: "55555555-dead-beef-0000-000000000005",
WorkspaceID: "66666666-dead-beef-0000-000000000006",
Name: "adapter-err-job",
CronExpr: "0 * * * *",
Timezone: "UTC",
Prompt: "do something",
}
// active_tasks check → 0 (workspace is idle; proceed to fire)
mock.ExpectQuery(`SELECT COALESCE`).
WillReturnRows(sqlmock.NewRows([]string{"coalesce"}).AddRow(0))
// Post-fire UPDATE must record last_status='error' with the adapter error message.
mock.ExpectExec(`UPDATE workspace_schedules`).
WithArgs(sched.ID, sqlmock.AnyArg(), "error", "A2A adapter error: adapter SDK internal error").
WillReturnResult(sqlmock.NewResult(0, 1))
// activity_logs INSERT must carry status='error' and the error detail.
mock.ExpectExec(`INSERT INTO activity_logs`).
WithArgs(sched.WorkspaceID, sqlmock.AnyArg(), sqlmock.AnyArg(), "error", "A2A adapter error: adapter SDK internal error").
WillReturnResult(sqlmock.NewResult(0, 1))
s := New(&adapterErrorProxy{}, nil)
s.fireSchedule(context.Background(), sched)
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet DB expectations — adapter error not recorded correctly: %v", err)
}
}
// ── TestFireSchedule_ComputeNextRunError (#722 Bug 1) ─────────────────────────
//
// When ComputeNextRun fails (bad cron expression), fireSchedule must NOT write
@@ -285,6 +338,12 @@ func TestFireSchedule_ComputeNextRunError(t *testing.T) {
WithArgs(sched.ID).
WillReturnResult(sqlmock.NewResult(0, 1))
// #1696 consecutive_sdk_errors reset — successProxy has no result_kind,
// so detectResultKind returns "" and lastStatus="ok" → reset.
mock.ExpectExec(`UPDATE workspace_schedules`).
WithArgs(sched.ID).
WillReturnResult(sqlmock.NewResult(0, 1))
// UPDATE must fire — COALESCE($2, next_run_at) keeps existing value when $2 is nil.
// AnyArg for $2 because it will be nil (ComputeNextRun failed).
mock.ExpectExec(`UPDATE workspace_schedules`).
@@ -540,7 +599,14 @@ func TestFireSchedule_NormalSuccess_AdvancesNextRunAt(t *testing.T) {
WithArgs(sched.ID).
WillReturnResult(sqlmock.NewResult(0, 1))
// 3. Normal UPDATE after successful proxy call.
// 3. #1696 consecutive_sdk_errors reset — successProxy response has no
// result_kind in the body, so detectResultKind returns "" and lastStatus
// is "ok" → we hit the SDK-error counter reset branch.
mock.ExpectExec(`UPDATE workspace_schedules`).
WithArgs(sched.ID).
WillReturnResult(sqlmock.NewResult(0, 1))
// 4. Normal UPDATE after successful proxy call.
// Args: $1=sched.ID, $2=nextRunPtr (computed time), $3=lastStatus, $4=lastError
mock.ExpectExec(`UPDATE workspace_schedules`).
WithArgs(sched.ID, sqlmock.AnyArg(), "ok", "").
@@ -603,6 +669,336 @@ func TestRecordSkipped_AdvancesNextRunAt(t *testing.T) {
}
// trigger CI
// ── TestDetectResultKind ───────────────────────────────────────────────────────
// TestDetectResultKind covers the SDK error detection path: HTTP 200 responses
// with non-ok result_kind in the body must be recognised and returned as the
// kind string, not silently treated as ok.
func TestDetectResultKind(t *testing.T) {
// The test exercises detectResultKind directly so we don't need a full
// fireSchedule mock for this unit-test level.
tests := []struct {
name string
body string
wantKind string
}{
{
name: "clean ok response — empty body",
body: `{}`,
wantKind: "",
},
{
name: "clean ok response — result.kind absent",
body: `{"result":{"parts":[{"text":"hello"}]}}`,
wantKind: "",
},
{
name: "clean ok response — result.kind=ok",
body: `{"result":{"kind":"ok","parts":[{"text":"hello"}]}}`,
wantKind: "",
},
{
name: "clean ok response — result.result_kind=ok",
body: `{"result":{"result_kind":"ok","parts":[{"text":"hello"}]}}`,
wantKind: "",
},
{
// REGRESSION GUARD: A2A-SDK canonical Message envelope.
// Pre-fix, every successful agent reply was mis-flagged as an SDK
// error (PM scheduler hit 21 consecutive false-failure ticks before
// auto-disable; canvas screenshot 2026-05-23).
name: "clean ok response — result.kind=message (A2A Message envelope)",
body: `{"jsonrpc":"2.0","result":{"kind":"message","parts":[{"kind":"text","text":"hello"}]},"id":"1"}`,
wantKind: "",
},
{
name: "clean ok response — result.result_kind=message",
body: `{"result":{"result_kind":"message","parts":[{"text":"hello"}]}}`,
wantKind: "",
},
{
// A2A Task envelope, in-progress — `status.state` discriminator is
// `submitted` or `working` → treat as clean (not an SDK error).
name: "clean ok — task envelope state=working",
body: `{"result":{"kind":"task","task_id":"abc","status":{"state":"working"}}}`,
wantKind: "",
},
{
name: "clean ok — task envelope state=submitted",
body: `{"result":{"kind":"task","status":{"state":"submitted"}}}`,
wantKind: "",
},
{
name: "clean ok — task envelope state=completed",
body: `{"result":{"kind":"task","status":{"state":"completed"}}}`,
wantKind: "",
},
{
// Conservative: missing status.state → don't fire false-failure.
name: "clean ok — task envelope no status block",
body: `{"result":{"kind":"task","task_id":"abc"}}`,
wantKind: "",
},
{
// REGRESSION GUARD: terminal failure states MUST propagate as last_status.
// Without taskOKStates gating, the blanket "task" allowlist would have
// masked these — CR2 review feedback on #1716.
name: "SDK error — task envelope state=failed",
body: `{"result":{"kind":"task","status":{"state":"failed"}}}`,
wantKind: "failed",
},
{
name: "SDK error — task envelope state=canceled",
body: `{"result":{"kind":"task","status":{"state":"canceled"}}}`,
wantKind: "canceled",
},
{
name: "SDK error — task envelope state=rejected",
body: `{"result":{"kind":"task","status":{"state":"rejected"}}}`,
wantKind: "rejected",
},
{
name: "SDK error — result.kind=rate_limited",
body: `{"result":{"kind":"rate_limited","parts":[{"text":"error"}]}}`,
wantKind: "rate_limited",
},
{
name: "SDK error — result.kind=quota_exhausted",
body: `{"result":{"kind":"quota_exhausted"}}`,
wantKind: "quota_exhausted",
},
{
name: "SDK error — result.kind=sdk_error",
body: `{"result":{"kind":"sdk_error"}}`,
wantKind: "sdk_error",
},
{
name: "SDK error — result.result_kind=rate_limited",
body: `{"result":{"result_kind":"rate_limited"}}`,
wantKind: "rate_limited",
},
{
name: "SDK error — error string with rate limit",
body: `{"result":{"parts":[]},"error":"An error occurred: rate limit exceeded"}`,
wantKind: "rate_limited",
},
{
name: "SDK error — error string with max-plan",
body: `{"error":"Max-plan rate limit reached"}`,
wantKind: "quota_exhausted",
},
{
name: "SDK error — error string with quota",
body: `{"error":"quota exhausted for model"}`,
wantKind: "quota_exhausted",
},
{
name: "SDK error — error string with sdk error",
body: `{"error":"Claude Code returned an error result: success"}`,
wantKind: "sdk_error",
},
{
name: "SDK error — error string with api key",
body: `{"error":"invalid API key"}`,
wantKind: "sdk_error",
},
{
name: "unknown error string — not an SDK error",
body: `{"error":"something went wrong"}`,
wantKind: "",
},
{
name: "empty response body",
body: ``,
wantKind: "",
},
{
name: "malformed JSON",
body: `not valid json`,
wantKind: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := detectResultKind([]byte(tc.body))
if got != tc.wantKind {
t.Errorf("detectResultKind(%q) = %q, want %q", tc.body, got, tc.wantKind)
}
})
}
}
// ── TestFireSchedule_SDKError_RateLimited (#1696) ───────────────────────────────
//
// When ProxyA2ARequest returns HTTP 200 but the response body contains a
// non-ok result_kind, fireSchedule must:
// 1. Set last_status to the result_kind (not 'ok').
// 2. Set last_error to describe the SDK error.
// 3. Increment consecutive_sdk_errors.
// 4. NOT auto-disable on first occurrence (threshold is 3).
//
// This test uses an sdkErrorProxy that returns a rate-limited body and asserts
// the first run is recorded as 'rate_limited' with consecutive_sdk_errors=1
// and enabled=true.
func TestFireSchedule_SDKError_RateLimited(t *testing.T) {
mock := setupTestDB(t)
sched := scheduleRow{
ID: "sdk1-test-sched-0001",
WorkspaceID: "sdk1-test-workspace1",
Name: "rate-limited-job",
CronExpr: "0 * * * *",
Timezone: "UTC",
Prompt: "do work",
}
// 1. active_tasks check → workspace idle
mock.ExpectQuery(`SELECT COALESCE`).
WillReturnRows(sqlmock.NewRows([]string{"coalesce"}).AddRow(0))
// 2. #1696 consecutive_sdk_errors bump — RETURNING gives us count=1.
// Use ExpectQuery (not Exec) because QueryRowContext + RETURNING
// produces a result set consumed via .Scan().
mock.ExpectQuery(`UPDATE workspace_schedules`).
WithArgs(sched.ID).
WillReturnRows(sqlmock.NewRows([]string{"consecutive_sdk_errors"}).AddRow(1))
// 3. Post-fire UPDATE — last_status='rate_limited', last_error='SDK error: result_kind=rate_limited'
mock.ExpectExec(`UPDATE workspace_schedules`).
WithArgs(sched.ID, sqlmock.AnyArg(), "rate_limited", sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
// 4. activity_logs INSERT
mock.ExpectExec(`INSERT INTO activity_logs`).
WithArgs(sched.WorkspaceID, sqlmock.AnyArg(), sqlmock.AnyArg(), "rate_limited", sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
s := New(&sdkErrorProxy{kind: "rate_limited"}, nil)
s.fireSchedule(context.Background(), sched)
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet DB expectations for SDK-error first run: %v", err)
}
}
// ── TestFireSchedule_SDKError_AutoDisableOnThirdConsecutive (#1696) ───────────
//
// On the 3rd consecutive SDK error, fireSchedule must auto-disable the
// schedule (enabled=false) in addition to recording the error status.
// Threshold is 3 per #1696 requirement.
func TestFireSchedule_SDKError_AutoDisableOnThirdConsecutive(t *testing.T) {
mock := setupTestDB(t)
sched := scheduleRow{
ID: "sdk2-test-sched-0002",
WorkspaceID: "sdk2-test-workspace2",
Name: "auto-disable-job",
CronExpr: "0 * * * *",
Timezone: "UTC",
Prompt: "do work",
}
// 1. active_tasks check → workspace idle
mock.ExpectQuery(`SELECT COALESCE`).
WillReturnRows(sqlmock.NewRows([]string{"coalesce"}).AddRow(0))
// 2. #1696 consecutive_sdk_errors bump — RETURNING gives count=3 (threshold met).
// Use ExpectQuery (not Exec) because QueryRowContext + RETURNING
// produces a result set consumed via .Scan().
mock.ExpectQuery(`UPDATE workspace_schedules`).
WithArgs(sched.ID).
WillReturnRows(sqlmock.NewRows([]string{"consecutive_sdk_errors"}).AddRow(3))
// 3. Auto-disable UPDATE — sets enabled=false (schedule has hit 3rd SDK error)
mock.ExpectExec(`UPDATE workspace_schedules SET enabled`).
WithArgs(sched.ID).
WillReturnResult(sqlmock.NewResult(0, 1))
// 4. Post-fire UPDATE
mock.ExpectExec(`UPDATE workspace_schedules`).
WithArgs(sched.ID, sqlmock.AnyArg(), "rate_limited", sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
// 5. activity_logs INSERT
mock.ExpectExec(`INSERT INTO activity_logs`).
WithArgs(sched.WorkspaceID, sqlmock.AnyArg(), sqlmock.AnyArg(), "rate_limited", sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
s := New(&sdkErrorProxy{kind: "rate_limited"}, nil)
s.fireSchedule(context.Background(), sched)
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet DB expectations for SDK-error auto-disable: %v", err)
}
}
// ── TestFireSchedule_SDKError_CounterResetOnCleanRun (#1696) ──────────────────
//
// A clean HTTP-200 run (no SDK error) must reset consecutive_sdk_errors to 0.
// This prevents false auto-disable after intermittent SDK blips.
func TestFireSchedule_SDKError_CounterResetOnCleanRun(t *testing.T) {
mock := setupTestDB(t)
sched := scheduleRow{
ID: "sdk3-test-sched-0003",
WorkspaceID: "sdk3-test-workspace3",
Name: "clean-reset-job",
CronExpr: "30 * * * *",
Timezone: "UTC",
Prompt: "do work",
}
// 1. active_tasks check → workspace idle
mock.ExpectQuery(`SELECT COALESCE`).
WillReturnRows(sqlmock.NewRows([]string{"coalesce"}).AddRow(0))
// 2. No SDK error — #1696 counter is reset to 0
// (lastStatus is 'ok', resultKind is empty, so we go to the reset branch)
mock.ExpectExec(`UPDATE workspace_schedules`).
WithArgs(sched.ID).
WillReturnResult(sqlmock.NewResult(0, 1))
// 3. Post-fire UPDATE
mock.ExpectExec(`UPDATE workspace_schedules`).
WithArgs(sched.ID, sqlmock.AnyArg(), "ok", "").
WillReturnResult(sqlmock.NewResult(0, 1))
// 4. activity_logs INSERT
mock.ExpectExec(`INSERT INTO activity_logs`).
WithArgs(sched.WorkspaceID, sqlmock.AnyArg(), sqlmock.AnyArg(), "ok", "").
WillReturnResult(sqlmock.NewResult(0, 1))
s := New(&successProxy{}, nil)
s.fireSchedule(context.Background(), sched)
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet DB expectations for SDK-error counter reset: %v", err)
}
}
// ── sdkErrorProxy ──────────────────────────────────────────────────────────────
//
// sdkErrorProxy is a test double whose ProxyA2ARequest returns HTTP 200 but
// embeds a non-ok result_kind in the response body, simulating a Claude Code
// SDK that returned 200 but the inner LLM call threw a rate-limit / quota error.
// Used by TestFireSchedule_SDKError_* to cover #1696 SDK error detection.
type sdkErrorProxy struct {
kind string // result_kind value to embed in the response body
}
func (p *sdkErrorProxy) ProxyA2ARequest(
_ context.Context, _ string, _ []byte, _ string, _ bool,
) (int, []byte, error) {
body, _ := json.Marshal(map[string]interface{}{
"result": map[string]interface{}{
"kind": p.kind,
"parts": []map[string]interface{}{{"kind": "text", "text": "(no response generated)"}},
},
})
return 200, body, nil
}
// ── TestTruncate_utf8Safe_regression2026 ──────────────────────────────────────
// TestTruncate_utf8Safe_regression2026 locks in the #2026 fix: truncate must
@@ -0,0 +1 @@
ALTER TABLE workspaces DROP COLUMN IF EXISTS compute;
@@ -0,0 +1,2 @@
ALTER TABLE workspaces
ADD COLUMN IF NOT EXISTS compute JSONB NOT NULL DEFAULT '{}'::jsonb;
@@ -0,0 +1,4 @@
-- migration: 20260523000000_schedule_consecutive_sdk_errors.down.sql
-- Reverts #1696 fix for #1696 (consecutive_sdk_errors column)
ALTER TABLE workspace_schedules DROP COLUMN IF EXISTS consecutive_sdk_errors;
@@ -0,0 +1,20 @@
-- migration: 20260523000000_schedule_consecutive_sdk_errors.up.sql
-- Fixes #1696: Add consecutive_sdk_errors counter to track SDK errors (HTTP 200
-- responses where the Claude Code runtime returned a non-ok result_kind).
-- When this counter reaches 3, the scheduler sets last_status='rate_limited'
-- and auto-disables the schedule.
--
-- The core issue: the claude-code-sdk adapter returns HTTP 200 even when the
-- inner LLM call throws (e.g. Max-plan rate-limit). All 3 observed runs logged
-- "completed (HTTP 200)" yet surfaced agent errors in the workspace chat.
-- This counter lets us detect that pattern and escalate appropriately.
ALTER TABLE workspace_schedules
ADD COLUMN IF NOT EXISTS consecutive_sdk_errors INTEGER NOT NULL DEFAULT 0;
COMMENT ON COLUMN workspace_schedules.consecutive_sdk_errors IS
'Count of consecutive scheduler fires where ProxyA2ARequest returned HTTP 200
but the response body contained a non-ok result_kind (e.g. rate_limited,
sdk_error, quota_exhausted). Reset to 0 on any non-SDK-error status.
After 3 consecutive SDK errors the schedule is auto-disabled with
status rate_limited. Fixes #1696.';
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_workspace_display_control_locks_expires;
DROP TABLE IF EXISTS workspace_display_control_locks;
@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS workspace_display_control_locks (
workspace_id uuid PRIMARY KEY REFERENCES workspaces(id) ON DELETE CASCADE,
controller text NOT NULL CHECK (controller IN ('user', 'agent')),
controlled_by text NOT NULL CHECK (length(controlled_by) > 0 AND length(controlled_by) <= 200),
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_workspace_display_control_locks_expires
ON workspace_display_control_locks (expires_at);