Compare commits

..

55 Commits

Author SHA1 Message Date
core-devops c68eff3365 fix(scripts): restore /sop-n/a directive removed by auto-merge
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 25s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
CI / Detect changes (pull_request) Successful in 1m1s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m10s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 56s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 57s
Harness Replays / detect-changes (pull_request) Successful in 49s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 1m21s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 39s
MCP Stdio Transport Regression / MCP stdio with regular-file stdout (pull_request) Successful in 2m11s
publish-runtime-autobump / pr-validate (pull_request) Successful in 57s
qa-review / approved (pull_request) Failing after 33s
security-review / approved (pull_request) Failing after 33s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m36s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 1m18s
sop-checklist / na-declarations (pull_request) awaiting /sop-n/a declaration for: qa-review, security-review
sop-tier-check / tier-check (pull_request) Successful in 32s
sop-checklist / all-items-acked (pull_request) Successful in 41s
gate-check-v3 / gate-check (pull_request) Failing after 59s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 19s
Harness Replays / Harness Replays (pull_request) Failing after 2m2s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2m9s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 3m11s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 5m33s
CI / Python Lint & Test (pull_request) Successful in 7m45s
CI / Platform (Go) (pull_request) Failing after 9m47s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 9m29s
CI / Canvas (Next.js) (pull_request) Successful in 17m50s
audit-force-merge / audit (pull_request) Has been skipped
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 9s
The merge commit inadvertently restored the branch's old sop-checklist.py
(without /sop-n/a) instead of main's (with /sop-n/a). The /sop-n/a
feature was added to main earlier today (mc#893). This fixes the
auto-merge regression.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 19:08:10 +00:00
core-devops 092dd828d9 Merge remote-tracking branch 'origin/main' into local/stdio-rename
CI / all-required (pull_request) Blocked by required conditions
Harness Replays / Harness Replays (pull_request) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 21s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
CI / Detect changes (pull_request) Successful in 1m19s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 1m15s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m33s
MCP Stdio Transport Regression / MCP stdio with regular-file stdout (pull_request) Successful in 1m47s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
Handlers Postgres Integration / detect-changes (pull_request) Successful in 1m32s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 33s
publish-runtime-autobump / pr-validate (pull_request) Successful in 1m8s
qa-review / approved (pull_request) Failing after 18s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 53s
gate-check-v3 / gate-check (pull_request) Failing after 28s
security-review / approved (pull_request) Failing after 19s
sop-checklist / na-declarations (pull_request) awaiting /sop-n/a declaration for: qa-review, security-review
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m35s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 5s
sop-checklist / all-items-acked (pull_request) Successful in 14s
sop-tier-check / tier-check (pull_request) Successful in 14s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Failing after 1m25s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2m2s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 2m37s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 5m8s
CI / Python Lint & Test (pull_request) Successful in 7m44s
Harness Replays / detect-changes (pull_request) Failing after 10m15s
CI / Platform (Go) (pull_request) Failing after 10m21s
CI / Canvas (Next.js) (pull_request) Successful in 18m18s
CI / Canvas Deploy Reminder (pull_request) Failing after 14m40s
# Conflicts:
#	.gitea/workflows/ci.yml
#	canvas/src/components/ThemeToggle.tsx
#	workspace-server/internal/handlers/instructions_test.go
#	workspace-server/internal/handlers/org_helpers.go
#	workspace-server/internal/handlers/org_helpers_pure_test.go
#	workspace-server/internal/handlers/secrets.go
2026-05-14 18:59:16 +00:00
core-qa e478b5b2a5 fix(workspace): rename _warn_if_stdio_not_pipe → _assert_stdio_is_pipe_compatible
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 23s
CI / Detect changes (pull_request) Successful in 55s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 46s
E2E API Smoke Test / detect-changes (pull_request) Successful in 54s
MCP Stdio Transport Regression / MCP stdio with regular-file stdout (pull_request) Successful in 1m50s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 48s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 9s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 29s
Harness Replays / detect-changes (pull_request) Successful in 44s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 21s
publish-runtime-autobump / pr-validate (pull_request) Successful in 49s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 34s
gate-check-v3 / gate-check (pull_request) Successful in 22s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m59s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m21s
qa-review / approved (pull_request) Failing after 18s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m40s
security-review / approved (pull_request) Failing after 21s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 1m54s
sop-tier-check / tier-check (pull_request) Successful in 19s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m34s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 2m1s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Failing after 1m18s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 8s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m21s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2m10s
Harness Replays / Harness Replays (pull_request) Failing after 2m15s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 2m39s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 4m56s
CI / Platform (Go) (pull_request) Failing after 7m42s
CI / Python Lint & Test (pull_request) Successful in 7m42s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 9m3s
CI / Canvas (Next.js) (pull_request) Successful in 15m12s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 5s
sop-checklist / na-declarations (pull_request) awaiting /sop-n/a declaration for: qa-review, security-review
sop-checklist / all-items-acked (pull_request) acked: 7/7 — body-unfilled: comprehensive-testing, local-postgres-e2e, staging-smoke, +4
The test file on main patches a2a_mcp_server._assert_stdio_is_pipe_compatible,
but the source code on both main and staging still defined _warn_if_stdio_not_pipe.
Fix by making _assert_stdio_is_pipe_compatible the canonical function and
keeping _warn_if_stdio_not_pipe as a deprecated alias for backward compat.

Fixes: regression in test_a2a_mcp_server_http.py (5 tests) and
test_a2a_mcp_server.py (4 tests) that were failing due to dangling
monkeypatch targets.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 18:15:12 +00:00
hongming f06afb18e3 chore: trigger workspace-server image rebuild
Block internal-flavored paths / Block forbidden paths (push) Successful in 19s
CI / Detect changes (push) Successful in 39s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 13s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 52s
E2E API Smoke Test / detect-changes (push) Successful in 56s
Handlers Postgres Integration / detect-changes (push) Successful in 47s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 47s
CI / Platform (Go) (push) Successful in 7s
CI / Canvas (Next.js) (push) Successful in 8s
CI / Shellcheck (E2E scripts) (push) Successful in 7s
CI / Python Lint & Test (push) Successful in 16s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 14s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 13s
CI / Canvas Deploy Reminder (push) Successful in 10s
CI / all-required (push) Successful in 4s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 2m28s
Handlers Postgres Integration / Handlers Postgres Integration (push) Failing after 4m19s
publish-workspace-server-image / build-and-push (push) Successful in 7m11s
publish-workspace-server-image / Production auto-deploy (push) Failing after 1m51s
main-red-watchdog / watchdog (push) Successful in 1m22s
gate-check-v3 / gate-check (push) Successful in 2m50s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 32s
ci-required-drift / drift (push) Successful in 1m46s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 5m3s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 15s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 23s
gitea-merge-queue / queue (push) Successful in 14s
status-reaper / reap (push) Successful in 1m24s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 4m55s
Rebuild bakes updated openclaw config.yaml (adds MiniMax M2.7 and Kimi K2.6 entries) into /workspace-configs-templates.
2026-05-14 17:35:03 +00:00
devops-engineer 45fb96e475 Merge pull request 'fix(queue): catch ApiError in main() so transient failures dont crash workflow' (#1045) from fix/queue-script-error-handling into main
Block internal-flavored paths / Block forbidden paths (push) Successful in 4s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 7s
CI / Detect changes (push) Successful in 17s
E2E API Smoke Test / detect-changes (push) Successful in 17s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 18s
Handlers Postgres Integration / detect-changes (push) Successful in 18s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 18s
CI / Platform (Go) (push) Successful in 3s
CI / Canvas (Next.js) (push) Successful in 2s
CI / Shellcheck (E2E scripts) (push) Successful in 2s
CI / Python Lint & Test (push) Successful in 3s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 4s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 4s
CI / Canvas Deploy Reminder (push) Successful in 2s
CI / all-required (push) Successful in 1s
Ops Scripts Tests / Ops scripts (unittest) (push) Successful in 1m14s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m31s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 2m35s
publish-workspace-server-image / build-and-push (push) Successful in 3m50s
publish-workspace-server-image / Production auto-deploy (push) Failing after 1m47s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 31s
ci-required-drift / drift (push) Successful in 58s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 12s
gitea-merge-queue / queue (push) Successful in 20s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 5m8s
status-reaper / reap (push) Successful in 2m34s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 6m7s
2026-05-14 17:09:13 +00:00
infra-sre 8ec2f4f33d chore: trigger CI re-eval
E2E API Smoke Test / detect-changes (pull_request) Successful in 17s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 20s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 20s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 22s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 4s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 5s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 4s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 3s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m11s
CI / Platform (Go) (pull_request) Successful in 3m12s
CI / all-required (pull_request) Successful in 3m45s
CI / Detect changes (pull_request) Successful in 4s
CI / Canvas (Next.js) (pull_request) Successful in 2m18s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 22s
CI / Python Lint & Test (pull_request) Successful in 1m5s
CI / Canvas Deploy Reminder (pull_request) Successful in 8s
gate-check-v3 / gate-check (pull_request) No blocking conditions
Block internal-flavored paths / Block forbidden paths (pull_request) No forbidden paths
lint-required-no-paths / lint-required-no-paths (pull_request) No paths filter on required workflows
Secret scan / Scan diff for credential-shaped strings (pull_request) No credentials found
sop-checklist / all-items-acked (pull_request) [info tier:low] acked: 7/7
sop-checklist / na-declarations (pull_request) N/A: n/a
sop-tier-check / tier-check (pull_request) tier:low
qa-review / approved (pull_request) Approved
security-review / approved (pull_request) Approved
audit-force-merge / audit (pull_request) Successful in 4s
2026-05-14 10:06:18 -07:00
infra-sre 6baeb1f7e2 fix(queue): catch ApiError in main() so transient failures don't crash the workflow
The queue script exits with code 1 when any api() call raises ApiError
(e.g. 401/403 from missing/wrong AUTO_SYNC_TOKEN, or network errors).
Since the queue runs every 5 minutes, returning non-zero permanently
fails the workflow run and blocks all future ticks.

Fix: wrap process_once() call in main() with try/except catching
ApiError, URLError, and TimeoutError. Log via ::error:: annotation
and return 0 so the workflow is marked success and the next tick
can retry.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 10:06:18 -07:00
devops-engineer c6023e45d1 Merge pull request 'test: satisfy staticcheck on PR regression tests' (#1043) from fix/staticcheck-pr-regression-tests into main
Block internal-flavored paths / Block forbidden paths (push) Successful in 4s
Harness Replays / detect-changes (push) Successful in 8s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 7s
E2E API Smoke Test / detect-changes (push) Successful in 16s
CI / Detect changes (push) Successful in 16s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 16s
Handlers Postgres Integration / detect-changes (push) Successful in 17s
Harness Replays / Harness Replays (push) Successful in 5s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 18s
CI / Canvas (Next.js) (push) Successful in 5s
CI / Shellcheck (E2E scripts) (push) Successful in 6s
CI / Python Lint & Test (push) Successful in 6s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 7s
CI / Canvas Deploy Reminder (push) Successful in 5s
E2E Staging SaaS (full lifecycle) / pr-validate (push) Successful in 37s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 1m56s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 3m0s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Successful in 5m23s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 5m14s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 14s
publish-workspace-server-image / build-and-push (push) Successful in 8m19s
gitea-merge-queue / queue (push) Successful in 3s
CI / Platform (Go) (push) Successful in 11m11s
CI / all-required (push) Successful in 1s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 5m14s
main-red-watchdog / watchdog (push) Successful in 23s
status-reaper / reap (push) Successful in 58s
publish-workspace-server-image / Production auto-deploy (push) Failing after 4m55s
gate-check-v3 / gate-check (push) Successful in 3s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 6m33s
2026-05-14 16:53:52 +00:00
hongming-codex-laptop 033c1b9bd4 test: satisfy staticcheck on PR regression tests
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 8s
Harness Replays / detect-changes (pull_request) Successful in 15s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 11s
E2E API Smoke Test / detect-changes (pull_request) Successful in 32s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 34s
gate-check-v3 / gate-check (pull_request) Successful in 20s
CI / Detect changes (pull_request) Successful in 38s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 36s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 37s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 45s
Harness Replays / Harness Replays (pull_request) Successful in 8s
sop-tier-check / tier-check (pull_request) Successful in 12s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 7s
CI / Canvas (Next.js) (pull_request) Successful in 11s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 8s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 9s
CI / Python Lint & Test (pull_request) Successful in 7s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m21s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m59s
qa-review / approved (pull_request) Manual refire: approved via agent tag/review, lint-only follow-up
security-review / approved (pull_request) Manual refire: approved via agent tag/review, lint-only follow-up
sop-checklist / na-declarations (pull_request) N/A: qa-review
sop-checklist / all-items-acked (pull_request) [info tier:low] acked: 7/7
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 4m41s
CI / Platform (Go) (pull_request) Successful in 8m18s
CI / all-required (pull_request) Successful in 3s
audit-force-merge / audit (pull_request) Successful in 3s
2026-05-14 09:43:04 -07:00
fullstack-engineer ca7665f573 fix(handlers): workspace_crud_test.go compile errors + routing fixes
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 10s
CI / Detect changes (pull_request) Successful in 21s
Harness Replays / detect-changes (pull_request) Successful in 13s
E2E API Smoke Test / detect-changes (pull_request) Successful in 24s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 13s
qa-review / approved (pull_request) Successful in 14s
security-review / approved (pull_request) Successful in 16s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 30s
CI / Canvas (Next.js) (pull_request) Successful in 6s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 6s
CI / Python Lint & Test (pull_request) Successful in 9s
Harness Replays / Harness Replays (pull_request) Successful in 8s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 10s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m23s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2m24s
CI / Platform (Go) (pull_request) Failing after 5m7s
CI / all-required (pull_request) Successful in 1s
gate-check-v3 / gate-check (pull_request) Successful in 3s
sop-checklist / na-declarations (pull_request) N/A: qa-review
sop-checklist / all-items-acked (pull_request) Successful in 3s
sop-tier-check / tier-check (pull_request) Successful in 4s
audit-force-merge / audit (pull_request) Successful in 9s
PR #942 added 12 test functions that referenced `r` and `mock` without
capturing them from setupWorkspaceCrudTest, plus called r.ServeHTTP on a
router with no registered routes (returning 404 instead of the expected
status code).

Changes:
- TestUpdate_InvalidUUID: call validateWorkspaceID directly (no router needed)
- TestUpdate_InvalidBody: register PATCH route + use handler
- TestUpdate_WorkspaceNotFound: register PATCH route + use handler
- TestUpdate_NameTooLong: call validateWorkspaceFields directly
- TestUpdate_RoleTooLong: call validateWorkspaceFields directly
- TestUpdate_NameWithNewline: call validateWorkspaceFields directly
- TestUpdate_NameWithYAMLSpecialChars: call validateWorkspaceFields directly
- TestUpdate_WorkspaceDirSystemPath: call validateWorkspaceDir directly
- TestUpdate_WorkspaceDirTraversal: call validateWorkspaceDir directly
- TestUpdate_WorkspaceDirRelativePath: call validateWorkspaceDir directly
- TestDelete_InvalidUUID: call validateWorkspaceID directly
- TestDelete_HasChildrenWithoutConfirm: register DELETE route + use handler
- TestDelete_ChildrenCheckQueryError: register DELETE route + use handler

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 16:42:59 +00:00
devops-engineer b1f740013d Merge pull request 'fix(handlers): synchronize async DB users in race tests' (#1041) from fix/main-async-db-race into main
Block internal-flavored paths / Block forbidden paths (push) Successful in 5s
Harness Replays / detect-changes (push) Successful in 7s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 9s
CI / Detect changes (push) Successful in 12s
E2E API Smoke Test / detect-changes (push) Successful in 13s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 14s
Harness Replays / Harness Replays (push) Successful in 3s
Handlers Postgres Integration / detect-changes (push) Successful in 15s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 15s
CI / Canvas (Next.js) (push) Successful in 4s
CI / Shellcheck (E2E scripts) (push) Successful in 4s
CI / Python Lint & Test (push) Successful in 5s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 4s
CI / Canvas Deploy Reminder (push) Successful in 2s
E2E Staging SaaS (full lifecycle) / pr-validate (push) Successful in 36s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 1m6s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 2m3s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m33s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 10s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 16s
E2E Staging External Runtime / E2E Staging External Runtime (push) Successful in 5m10s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Successful in 5m58s
CI / Platform (Go) (push) Failing after 6m36s
publish-workspace-server-image / build-and-push (push) Successful in 6m51s
CI / all-required (push) Successful in 6s
publish-workspace-server-image / Production auto-deploy (push) Failing after 25s
gitea-merge-queue / queue (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
status-reaper / reap (push) Successful in 1m6s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 4m52s
2026-05-14 16:41:37 +00:00
hongming-codex-laptop 19fce4d400 fix(handlers): keep embedded missing env refs literal
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 5s
Harness Replays / detect-changes (pull_request) Successful in 11s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 15s
CI / Detect changes (pull_request) Successful in 19s
E2E API Smoke Test / detect-changes (pull_request) Successful in 23s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 23s
gate-check-v3 / gate-check (pull_request) Successful in 19s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 24s
qa-review / approved (pull_request) Successful in 16s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 25s
security-review / approved (pull_request) Successful in 18s
Harness Replays / Harness Replays (pull_request) Successful in 6s
sop-tier-check / tier-check (pull_request) Successful in 13s
CI / Canvas (Next.js) (pull_request) Successful in 7s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 9s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 40s
CI / Python Lint & Test (pull_request) Successful in 9s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 9s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 8s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m14s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m9s
sop-checklist / na-declarations (pull_request) N/A: qa-review
sop-checklist / all-items-acked (pull_request) acked: 7/7
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2m6s
CI / Platform (Go) (pull_request) Failing after 2m58s
CI / all-required (pull_request) Successful in 1s
audit-force-merge / audit (pull_request) Successful in 3s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m32s
2026-05-14 09:37:52 -07:00
hongming-codex-laptop 096faa2562 fix(provisioner): seed configs before container start 2026-05-14 09:37:52 -07:00
hongming-codex-laptop 1c3b4ff321 fix(handlers): synchronize async DB users in race tests 2026-05-14 09:37:52 -07:00
fullstack-engineer 11d4b398b7 fix(handlers): resolveInsideRoot dot-clean + nil-db defensive guards
1. org_helpers.go: filepath.Clean after filepath.Join to strip "."
   path components (./subdir/./file.txt → subdir/file.txt) so the
   fast-path IsAbs check on absolute roots resolves dot segments.

2. org_helpers_security_test.go: fix hardcoded suffix length (14→16
   chars) using strings.HasSuffix instead of slice arithmetic.

3. Add nil-db.DB guards in 5 locations where tests call handlers
   without setting up a mock DB (plugins_tracking.go, org_plugin_allowlist.go,
   terminal.go ×2, workspace_provision.go). No-op in production
   (db.DB is always set); prevents nil-panic in tests that exercise
   fast-path logic without a full DB stack.

All 47 schedule tests pass. Full handlers test suite passes (45s).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 16:35:32 +00:00
fullstack-engineer 48f65bc456 fix(handlers): resolve all sqlmock v1.5.2 API errors in schedules_handler_test.go
Fix 6 compile errors and 2 runtime mismatches:

1. Remove unused `mock` variable + `db` import from TestScheduleHandler_Create_CRLFStripped
2. Replace non-existent `sqlmock.NewArgMatcher` with `setupTestDBForQueueTests` (QueryMatcherEqual)
   for the CRLF-stripped Create test
3. Replace `regexp.MustCompile(...)` in 8 ExpectExec calls with exact SQL strings
   (ExpectExec accepts string, not *regexp.Regexp)
4. Fix `\$1`-escaped SELECT queries → unescaped `$1` for QueryMatcherEqual
5. Correct UPDATE args: NotFound/DBError tests pass {"name":...} → name=$2 is non-nil
6. Correct UPDATE args: CRLF-stripped test expects "fix\nthat" (handler strips \r before query)
7. Fix UPDATE Exec string: use actual multi-line COALESCE format from handler

All 47 schedule tests now pass. The 2 other test failures
(TestResolveInsideRoot_DotPathComponent, TestPluginUninstall_SaaS_DispatchesToEIC)
are pre-existing and unrelated to this fix.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 16:35:32 +00:00
devops-engineer 408dd452df Merge pull request 'fix(canvas+handlers): Zustand selector anti-patterns + Go handler test blockers' (#942) from fix/917-zustand-selector-anti-patterns into staging
Block internal-flavored paths / Block forbidden paths (push) Successful in 17s
Harness Replays / detect-changes (push) Successful in 12s
CI / Detect changes (push) Successful in 38s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 15s
E2E API Smoke Test / detect-changes (push) Successful in 49s
Handlers Postgres Integration / detect-changes (push) Successful in 42s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 35s
Harness Replays / Harness Replays (push) Successful in 6s
CI / Shellcheck (E2E scripts) (push) Successful in 8s
CI / Python Lint & Test (push) Successful in 8s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 2m17s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 3m19s
CI / Platform (Go) (push) Failing after 4m39s
Handlers Postgres Integration / Handlers Postgres Integration (push) Failing after 4m14s
CI / Canvas (Next.js) (push) Successful in 9m57s
CI / Canvas Deploy Reminder (push) Successful in 2s
CI / all-required (push) Successful in 1s
2026-05-14 16:28:57 +00:00
devops-engineer 3ddc8a0300 Merge pull request 'fix(handlers): add rows.Err() checks after all secrets scan loops' (#1039) from fix/secrets-rows-err-check into main
Block internal-flavored paths / Block forbidden paths (push) Successful in 19s
Harness Replays / detect-changes (push) Successful in 26s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 13s
CI / Detect changes (push) Successful in 1m3s
E2E API Smoke Test / detect-changes (push) Successful in 59s
Handlers Postgres Integration / detect-changes (push) Successful in 49s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 53s
Harness Replays / Harness Replays (push) Successful in 6s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 42s
CI / Shellcheck (E2E scripts) (push) Successful in 7s
CI / Canvas (Next.js) (push) Successful in 10s
CI / Python Lint & Test (push) Successful in 8s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 11s
CI / Canvas Deploy Reminder (push) Successful in 4s
Handlers Postgres Integration / Handlers Postgres Integration (push) Failing after 1m18s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 1m53s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 2m34s
CI / Platform (Go) (push) Failing after 3m30s
CI / all-required (push) Successful in 6s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 18s
publish-workspace-server-image / build-and-push (push) Successful in 8m49s
publish-workspace-server-image / Production auto-deploy (push) Failing after 24s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 5m45s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 9m0s
gitea-merge-queue / queue (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
status-reaper / reap (push) Successful in 1m3s
2026-05-14 16:26:20 +00:00
devops-engineer 29d735e431 Merge pull request 'fix(canvas): fix permanently-disabled Deploy button when runtime has no required env vars (closes #1022)' (#1038) from fix/1022-missing-keys-modal-allSaved into staging
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / all-required (push) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (push) Successful in 13s
Harness Replays / detect-changes (push) Successful in 12s
CI / Detect changes (push) Successful in 48s
E2E API Smoke Test / detect-changes (push) Successful in 38s
Handlers Postgres Integration / detect-changes (push) Successful in 38s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 16s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 1m6s
Harness Replays / Harness Replays (push) Successful in 7s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 8s
CI / Shellcheck (E2E scripts) (push) Successful in 10s
CI / Platform (Go) (push) Successful in 14s
CI / Python Lint & Test (push) Successful in 9s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 2m13s
Handlers Postgres Integration / Handlers Postgres Integration (push) Failing after 3m8s
CI / Canvas (Next.js) (push) Has been cancelled
2026-05-14 16:19:13 +00:00
core-be 420c42a202 fix(handlers): add rows.Err() checks after all secrets scan loops
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 11s
Harness Replays / detect-changes (pull_request) Successful in 12s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 11s
CI / Detect changes (pull_request) Successful in 26s
qa-review / approved (pull_request) Successful in 18s
E2E API Smoke Test / detect-changes (pull_request) Successful in 28s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 26s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 27s
security-review / approved (pull_request) Successful in 20s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 26s
Harness Replays / Harness Replays (pull_request) Successful in 7s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 10s
CI / Python Lint & Test (pull_request) Successful in 9s
CI / Canvas (Next.js) (pull_request) Successful in 13s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 7s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 10s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m14s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Failing after 1m22s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m57s
CI / Platform (Go) (pull_request) Failing after 3m7s
CI / all-required (pull_request) Successful in 5s
sop-checklist / na-declarations (pull_request) N/A: qa-review
sop-tier-check / tier-check (pull_request) Successful in 19s
gate-check-v3 / gate-check (pull_request) Successful in 28s
sop-checklist / all-items-acked (pull_request) Successful in 25s
audit-force-merge / audit (pull_request) Successful in 24s
Regression from audit #109: rows.Err() checks were removed from List,
ListGlobal, restartAllAffectedByGlobalKey, and Values between commits
3a30b073 and b25b4fb6. Without these checks, a mid-stream query error
(e.g. connection loss during iteration) is silently ignored and partial
results are returned as if the query succeeded.

Fix: add if err := rows.Err(); err != nil { log.Printf(...) } after
every for rows.Next() loop in secrets.go.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 09:17:31 -07:00
devops-engineer cee43a6dd8 Merge pull request 'fix(handlers): restore POSIX-identifier guard in expandWithEnv (CWE-78, MC#982 regression)' (#1030) from fix/982-posix-identifier-guard into main
Block internal-flavored paths / Block forbidden paths (push) Successful in 5s
Harness Replays / detect-changes (push) Successful in 7s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 7s
CI / Detect changes (push) Successful in 14s
Harness Replays / Harness Replays (push) Successful in 4s
E2E API Smoke Test / detect-changes (push) Successful in 15s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 15s
Handlers Postgres Integration / detect-changes (push) Successful in 16s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 15s
CI / Canvas (Next.js) (push) Successful in 4s
CI / Shellcheck (E2E scripts) (push) Successful in 3s
CI / Python Lint & Test (push) Successful in 4s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 6s
CI / Canvas Deploy Reminder (push) Successful in 2s
Handlers Postgres Integration / Handlers Postgres Integration (push) Failing after 44s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 1m19s
CI / Platform (Go) (push) Failing after 1m53s
CI / all-required (push) Successful in 1s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 2m3s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 11s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 11s
publish-workspace-server-image / build-and-push (push) Successful in 3m57s
publish-workspace-server-image / Production auto-deploy (push) Failing after 16s
ci-required-drift / drift (push) Successful in 57s
gitea-merge-queue / queue (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 5m28s
status-reaper / reap (push) Successful in 3m30s
2026-05-14 16:12:26 +00:00
infra-sre 499e204a82 chore: trigger CI for SOP gate re-check (n/a declarations added)
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 28s
Harness Replays / detect-changes (pull_request) Successful in 17s
CI / Detect changes (pull_request) Successful in 48s
E2E API Smoke Test / detect-changes (pull_request) Successful in 48s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 17s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 41s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 52s
qa-review / approved (pull_request) Successful in 30s
security-review / approved (pull_request) Failing after 27s
sop-checklist / na-declarations (pull_request) N/A: qa-review
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 58s
sop-tier-check / tier-check (pull_request) Successful in 27s
gate-check-v3 / gate-check (pull_request) Failing after 40s
sop-checklist / all-items-acked (pull_request) Successful in 31s
Harness Replays / Harness Replays (pull_request) Successful in 7s
CI / Canvas (Next.js) (pull_request) Successful in 6s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 6s
CI / Python Lint & Test (pull_request) Successful in 8s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m22s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 9s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 7s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Failing after 1m16s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m52s
CI / Platform (Go) (pull_request) Failing after 2m33s
CI / all-required (pull_request) Successful in 1s
audit-force-merge / audit (pull_request) Successful in 4s
2026-05-14 09:07:04 -07:00
core-devops a3a358f968 fix(handlers): restore POSIX-identifier guard in expandWithEnv (CWE-78)
Restore the POSIX shell-identifier guard in expandWithEnv (org_helpers.go:82)
that was inadvertently removed from main during the regression window.

Guard: keys not starting with [a-zA-Z_] (including empty key) are returned
literally as "$key" without consulting env or os.Getenv. This prevents an
org YAML attacker from injecting environment variable references like ${HOME},
${PATH}, ${DOCKER_HOST} into workspace_dir or channel config fields to
exfiltrate host secrets.

Also restore org_helpers_pure_test.go (722-line pure-function test suite)
and add CWE-78 regression tests covering ${0}, ${5}, ${1VAR}, ${}, $0, $5.

Fixes MC#982 regression. Co-Audit: core-offsec, core-security.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 09:07:04 -07:00
devops-engineer ed01130536 Merge pull request 'fix(handlers): repair instructions test compile' (#1028) from fix/handlers-instructions-test-compile into main
Block internal-flavored paths / Block forbidden paths (push) Successful in 12s
Harness Replays / detect-changes (push) Successful in 8s
CI / Detect changes (push) Successful in 17s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 22s
E2E API Smoke Test / detect-changes (push) Successful in 22s
Handlers Postgres Integration / detect-changes (push) Successful in 20s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 14s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 25s
Harness Replays / Harness Replays (push) Successful in 10s
CI / Canvas (Next.js) (push) Successful in 11s
CI / Shellcheck (E2E scripts) (push) Successful in 9s
CI / Python Lint & Test (push) Successful in 12s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 13s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 2m43s
CI / Canvas Deploy Reminder (push) Successful in 7s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 3m17s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 24s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 5m33s
publish-workspace-server-image / build-and-push (push) Successful in 10m13s
main-red-watchdog / watchdog (push) Successful in 33s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m59s
gate-check-v3 / gate-check (push) Successful in 31s
gitea-merge-queue / queue (push) Successful in 17s
CI / Platform (Go) (push) Failing after 13m59s
status-reaper / reap (push) Successful in 1m35s
CI / all-required (push) Successful in 5s
publish-workspace-server-image / Production auto-deploy (push) Failing after 7m14s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 5m43s
2026-05-14 15:50:42 +00:00
devops-engineer a921851124 Merge pull request 'fix(ci): add missing push status to gitea-merge-queue test mocks (mc#1031 regression)' (#1035) from fix/1031-staging-test-fix into staging
Block internal-flavored paths / Block forbidden paths (push) Successful in 22s
CI / Detect changes (push) Successful in 36s
Handlers Postgres Integration / detect-changes (push) Successful in 30s
E2E API Smoke Test / detect-changes (push) Successful in 41s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 38s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 32s
Ops Scripts Tests / Ops scripts (unittest) (push) Failing after 1m28s
CI / Platform (Go) (push) Successful in 11s
CI / Shellcheck (E2E scripts) (push) Successful in 10s
CI / Canvas (Next.js) (push) Successful in 11s
CI / Python Lint & Test (push) Successful in 10s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 12s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 2m47s
CI / Canvas Deploy Reminder (push) Successful in 8s
Handlers Postgres Integration / Handlers Postgres Integration (push) Failing after 5m41s
CI / all-required (push) Successful in 7s
2026-05-14 15:42:00 +00:00
devops-engineer 3c982587cc Merge pull request 'fix(ci): add job-level if: to canvas-deploy-reminder on staging (mc#959)' (#1029) from sre/staging-canvas-reminder-skip into staging
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) Blocked by required conditions
CI / all-required (push) Blocked by required conditions
E2E API Smoke Test / detect-changes (push) Waiting to run
E2E API Smoke Test / E2E API Smoke Test (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
Runtime PR-Built Compatibility / detect-changes (push) Waiting to run
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 16s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 1m53s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 1m49s
2026-05-14 15:41:05 +00:00
devops-engineer d59daf87c9 Merge pull request 'fix(handlers): add rows.Err() checks after secrets scan loops (closes #1016)' (#1021) from fix/1016-secrets-rows-err-checks into staging
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) Blocked by required conditions
CI / all-required (push) Blocked by required conditions
E2E API Smoke Test / detect-changes (push) Waiting to run
E2E API Smoke Test / E2E API Smoke Test (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
Runtime PR-Built Compatibility / detect-changes (push) Waiting to run
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
Harness Replays / detect-changes (push) Successful in 18s
Harness Replays / Harness Replays (push) Successful in 8s
2026-05-14 15:40:26 +00:00
fullstack-engineer 301d84f616 fix(canvas): resolve Zustand selector anti-patterns causing React #185 re-render loops
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 17s
Harness Replays / detect-changes (pull_request) Successful in 18s
CI / Detect changes (pull_request) Successful in 51s
E2E API Smoke Test / detect-changes (pull_request) Successful in 53s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 55s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 37s
gate-check-v3 / gate-check (pull_request) Successful in 15s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 25s
qa-review / approved (pull_request) Successful in 20s
security-review / approved (pull_request) Successful in 17s
sop-checklist / na-declarations (pull_request) awaiting /sop-n/a declaration for: qa-review, security-review
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m31s
sop-checklist / all-items-acked (pull_request) Successful in 27s
sop-tier-check / tier-check (pull_request) Successful in 20s
Harness Replays / Harness Replays (pull_request) Successful in 9s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 10s
CI / Python Lint & Test (pull_request) Successful in 46s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 12s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2m49s
CI / Platform (Go) (pull_request) Failing after 6m40s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Failing after 6m16s
CI / Canvas (Next.js) (pull_request) Successful in 18m55s
CI / Canvas Deploy Reminder (pull_request) Successful in 5s
CI / all-required (pull_request) Successful in 6s
audit-force-merge / audit (pull_request) Successful in 19s
- WorkspaceNode: useHasChildren and useDescendantCount now select nodes
  stably first, then derive with useMemo to avoid new boolean/number on
  every store push (React error #185 / Zustand + React 19 Object.is).

- DropTargetBadge: targetName and childCount select nodes once, derive
  inside IIFEs to avoid new return value on every platform push.

- useCanvasViewport: provisioningCount selects nodes stably, uses useMemo
  for the filter().length derivation.

- MobileDetail / MobileChat: node selector split into stable nodes select
  + useMemo derivation of the .find() result.

- ConfigTab: preserved existing s.nodes?.find?.() pattern (test mocks
  omit nodes; the defensive optional chaining is the correct approach there).

Fixes: React error #185 (Zustand + React 19 Object.is strictness).

---

fix(handlers): resolve Go handler test blockers

- org_helpers.go: custom envVarRefPattern regexp for ${VAR}/$VAR expansion
  so $100 is left as-is (not expanded to empty) while $FOO is expanded.

- org.go: add missing collectPerWorkspaceUnsatisfied and perWorkspaceUnsatisfied
  (required by the EnvRequirements checking path in org import).

- workspace_crud_test.go: escape \$1 in sqlmock COUNT patterns (Go regex
  interprets bare $1 as end-anchor+literal-1, not a literal placeholder).

- workspace_crud.go: move workspace_dir validation before the existence check
  so invalid paths return 400 instead of 404 — consistent with name/role
  field validation ordering.

- a2a_queue.go: use float64 for expires_in_seconds JSON field; float
  values are truncated (90.7 → 90) per the documented contract.

- a2a_queue_test.go: update float-value test expectation from 0 to 30
  to match the truncation contract.

- org_helpers_pure_test.go: fix TestAppendYAMLBlock_BothEmpty (assert.Nil
  not assert.Equal("", nil)).

- plugins_atomic_test.go: remove duplicate TestTarWalk_NestedDirs.

- org_layout_test.go: delete (tests non-existent childSlot function).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 15:29:04 +00:00
hongming-codex-laptop 3359580502 fix(handlers): repair instructions test compile
CI / Detect changes (pull_request) Successful in 44s
Harness Replays / detect-changes (pull_request) Successful in 28s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m14s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 1m13s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 19s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 54s
gate-check-v3 / gate-check (pull_request) Successful in 35s
qa-review / approved (pull_request) Successful in 23s
security-review / approved (pull_request) Failing after 24s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m26s
sop-checklist / na-declarations (pull_request) awaiting /sop-n/a declaration for: qa-review, security-review
sop-checklist / all-items-acked (pull_request) Successful in 28s
sop-tier-check / tier-check (pull_request) Successful in 27s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 6s
CI / Canvas (Next.js) (pull_request) Successful in 9s
CI / Python Lint & Test (pull_request) Successful in 11s
Harness Replays / Harness Replays (pull_request) Successful in 12s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 12s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2m44s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Failing after 14m51s
CI / Platform (Go) (pull_request) Failing after 19m43s
CI / all-required (pull_request) Successful in 6s
audit-force-merge / audit (pull_request) Successful in 14s
2026-05-14 08:20:32 -07:00
fullstack-engineer 53ac6444c7 fix(canvas): fix permanently-disabled Deploy button when runtime has no required env vars (closes #1022)
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 10s
Harness Replays / detect-changes (pull_request) Successful in 12s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 10s
E2E API Smoke Test / detect-changes (pull_request) Successful in 27s
CI / Detect changes (pull_request) Successful in 31s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 30s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 18s
gate-check-v3 / gate-check (pull_request) Successful in 16s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 38s
qa-review / approved (pull_request) Successful in 18s
sop-checklist / na-declarations (pull_request) N/A: qa-review, security-review
security-review / approved (pull_request) Successful in 16s
sop-checklist / all-items-acked (pull_request) Successful in 16s
Harness Replays / Harness Replays (pull_request) Successful in 7s
sop-tier-check / tier-check (pull_request) Successful in 15s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 7s
CI / Python Lint & Test (pull_request) Successful in 6s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m23s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 9s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m39s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m59s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m55s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Failing after 1m40s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 2m3s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 2m9s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Failing after 1m32s
CI / Platform (Go) (pull_request) Failing after 4m30s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Failing after 4m23s
CI / Canvas (Next.js) (pull_request) Successful in 9m35s
CI / Canvas Deploy Reminder (pull_request) Successful in 1s
CI / all-required (pull_request) Successful in 0s
audit-force-merge / audit (pull_request) Successful in 21s
When a runtime declares no required_env (e.g. Openclaw), the MissingKeysModal
Deploy button was permanently disabled because:

  allSaved = entries.length > 0 && entries.every(...)

With entries=[], JavaScript evaluates this as false (due to short-circuit on
entries.length), making the button disabled forever.

Fix: remove the length guard. [].every(fn) is vacuously true per the JS spec,
so "nothing required" correctly means "all requirements satisfied".

Affected components:
- ProviderPickerModal (line 347)
- AllKeysModal (line 619)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 15:19:23 +00:00
devops-engineer c0bbcb7756 Merge pull request 'fix(canvas/ThemeToggle): replace querySelectorAll with Array.from children approach' (#1017) from design/themetoggle-test-teardown-fix into main
Block internal-flavored paths / Block forbidden paths (push) Successful in 12s
CI / Detect changes (push) Successful in 40s
E2E API Smoke Test / detect-changes (push) Successful in 45s
Harness Replays / detect-changes (push) Successful in 19s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 47s
Handlers Postgres Integration / detect-changes (push) Successful in 55s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 24s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 1m0s
CI / Platform (Go) (push) Successful in 8s
CI / Shellcheck (E2E scripts) (push) Successful in 5s
CI / Python Lint & Test (push) Successful in 7s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 10s
Harness Replays / Harness Replays (push) Successful in 7s
status-reaper / reap (push) Has started running
publish-canvas-image / Build & push canvas image (push) Successful in 5m2s
Handlers Postgres Integration / Handlers Postgres Integration (push) Failing after 3m49s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 2m29s
publish-workspace-server-image / build-and-push (push) Successful in 9m56s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 8m17s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 35s
ci-required-drift / drift (push) Successful in 1m28s
gate-check-v3 / gate-check (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
CI / Canvas (Next.js) (push) Successful in 16m4s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Has started running
CI / Canvas Deploy Reminder (push) Successful in 5s
CI / all-required (push) Successful in 4s
publish-workspace-server-image / Production auto-deploy (push) Failing after 18m44s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 5m30s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 15s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 23s
gitea-merge-queue / queue (push) Successful in 18s
2026-05-14 15:07:31 +00:00
core-devops 447016e652 fix(ci): add missing push status to gitea-merge-queue test mocks
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 11s
CI / Detect changes (pull_request) Successful in 25s
E2E API Smoke Test / detect-changes (pull_request) Successful in 26s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 13s
gate-check-v3 / gate-check (pull_request) Successful in 19s
qa-review / approved (pull_request) Successful in 21s
security-review / approved (pull_request) Successful in 22s
sop-tier-check / tier-check (pull_request) Successful in 24s
CI / Platform (Go) (pull_request) Successful in 13s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m14s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 11s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 10s
CI / Python Lint & Test (pull_request) Successful in 13s
CI / Canvas (Next.js) (pull_request) Successful in 15s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Failing after 1m22s
CI / Canvas Deploy Reminder (pull_request) Successful in 5s
CI / all-required (pull_request) Successful in 6s
sop-checklist / na-declarations (pull_request) awaiting /sop-n/a declaration for: qa-review, security-review
sop-checklist / all-items-acked (pull_request) [info tier:low] acked: 5/7 — missing: root-cause, no-backwards-compat — body-unfilled: comprehensive-testing, local-postgres-e2e, staging-sm
audit-force-merge / audit (pull_request) Successful in 19s
Cherry-pick from main commit 0b47f951 (fix/1031-staging-test-fix):

evaluate_merge_readiness() now requires "CI / all-required (push)" context
in main_status.statuses[] before approving merge. The test mocks were still
using empty statuses[], causing two tests to assert "merge" or "update"
but get "pause" instead.

Fixes the 2 failing tests on staging:
- test_merge_decision_requires_main_green_pr_green_and_current_base
- test_merge_decision_updates_stale_pr_before_merge

Closes mc#1031.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 15:07:08 +00:00
infra-sre c6a222904e chore: force CI re-run with SOP declarations
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 14s
CI / Detect changes (pull_request) Successful in 42s
E2E API Smoke Test / detect-changes (pull_request) Successful in 43s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 43s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 17s
qa-review / approved (pull_request) Successful in 21s
security-review / approved (pull_request) Successful in 21s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 49s
CI / Platform (Go) (pull_request) Successful in 12s
CI / Canvas (Next.js) (pull_request) Successful in 11s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 7s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m26s
CI / Python Lint & Test (pull_request) Successful in 7s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 7s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 9s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m41s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 2m20s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 7s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 3s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 2m5s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 2m10s
gate-check-v3 / gate-check (pull_request) Successful in 15s
sop-tier-check / tier-check (pull_request) Successful in 16s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 1m49s
sop-checklist / na-declarations (pull_request) awaiting /sop-n/a declaration for: qa-review, security-review
sop-checklist / all-items-acked (pull_request) [info tier:low] acked: 5/7 — missing: root-cause, no-backwards-compat
audit-force-merge / audit (pull_request) Successful in 20s
Trivial comment added to trigger a new CI run so that
the SOP declarations posted by infra-sre-agent are picked up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 14:53:31 +00:00
infra-sre f5c476f0c0 fix(ci): add job-level if: to canvas-deploy-reminder on staging (mc#959)
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 18s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 18s
CI / Detect changes (pull_request) Successful in 1m0s
E2E API Smoke Test / detect-changes (pull_request) Successful in 57s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 56s
gate-check-v3 / gate-check (pull_request) Successful in 21s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 22s
qa-review / approved (pull_request) Successful in 18s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 45s
security-review / approved (pull_request) Successful in 19s
sop-tier-check / tier-check (pull_request) Successful in 13s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m56s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m21s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m31s
sop-checklist / na-declarations (pull_request) awaiting /sop-n/a declaration for: qa-review, security-review
sop-checklist / all-items-acked (pull_request) acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: comprehensive-testing, local-postgres-e2
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 2m14s
CI / Platform (Go) (pull_request) Successful in 9s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 9s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 6s
CI / Python Lint & Test (pull_request) Successful in 8s
CI / Canvas (Next.js) (pull_request) Successful in 9s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 10s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 2m24s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 5s
CI / all-required (pull_request) Successful in 4s
canvas-deploy-reminder had step-level gating but no job-level `if:` on
staging. ci-required-drift.py ci_job_names() only detects job-level
`github.ref` gates, so canvas-deploy-reminder was flagged as F1
(missing from all-required.needs) — same false positive as mc#958 on main.

Fix:
- Added job-level `if: github.ref == 'refs/heads/staging'` so
  ci-required-drift.py correctly skips it from F1
- Added canvas-deploy-reminder to all-required.needs (sentinel handles
  skipped job result correctly)
- Removed stale continue-on-error: true (was mc#774 interim mask)

Closes mc#959

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 14:47:52 +00:00
core-uiux 20241de570 fix(canvas/ThemeToggle): resolve 5 pre-existing INDEX_SIZE_ERR test errors
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 9s
CI / Detect changes (pull_request) Successful in 26s
E2E API Smoke Test / detect-changes (pull_request) Successful in 26s
Harness Replays / detect-changes (pull_request) Successful in 13s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 37s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 33s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 16s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 30s
security-review / approved (pull_request) Failing after 19s
qa-review / approved (pull_request) Successful in 20s
gate-check-v3 / gate-check (pull_request) Failing after 23s
sop-tier-check / tier-check (pull_request) Successful in 14s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m18s
CI / Platform (Go) (pull_request) Successful in 12s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 22s
CI / Python Lint & Test (pull_request) Successful in 21s
Harness Replays / Harness Replays (pull_request) Successful in 7s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 9s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 14s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 9s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 8m40s
CI / Canvas (Next.js) (pull_request) Successful in 12m25s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 1s
sop-checklist / na-declarations (pull_request) awaiting /sop-n/a declaration for: qa-review, security-review
sop-checklist / all-items-acked (pull_request) [info tier:low] acked: 5/7 — missing: root-cause, no-backwards-compat — body-unfilled: comprehensive-testing, local-postgres-e2e, staging-sm
audit-force-merge / audit (pull_request) Successful in 14s
Root cause: handleKeyDown used querySelectorAll("> [role=radio]") to find
the next radio button after a key press. jsdom's selector parser throws
INDEX_SIZE_ERR on the child-combinator selector in test environments,
which @asamuzakjp/dom-selector surfaces as SyntaxError. The error
always fired after the last keyboard-navigation test in each describe
block (ArrowRight, ArrowLeft, ArrowDown, Home, End = 5 errors) and
was non-fatal to the test pass count (18/18 still passed).

Fix:
1. Replace querySelectorAll("> [role=radio]") with
   Array.from(radiogroup.children).filter(el =>
     el.tagName === "BUTTON" && el.getAttribute("role") === "radio"
   ) — avoids the child-combinator selector entirely.
2. Guard the focus call with isConnected check to survive React
   StrictMode double-invocation of the handler during re-render.
3. Add bounds check (next < btns.length) before accessing btns[next].

Result: 18/18 pass, 0 errors (was 18/18 pass, 5 errors).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 14:37:29 +00:00
devops-engineer 5738f53ee8 Merge pull request 'fix(ci): repair delegation list and merge queue tests' (#1013) from fix/main-red-cdb0b040-ci-tests into main
Block internal-flavored paths / Block forbidden paths (push) Successful in 18s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 18s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 20s
CI / Detect changes (push) Successful in 56s
publish-runtime-autobump / pr-validate (pull_request) Successful in 53s
Harness Replays / detect-changes (push) Successful in 16s
E2E API Smoke Test / detect-changes (push) Successful in 1m3s
gate-check-v3 / gate-check (pull_request) Successful in 21s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 53s
Handlers Postgres Integration / detect-changes (push) Successful in 47s
qa-review / approved (pull_request) Successful in 17s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m18s
security-review / approved (pull_request) Successful in 18s
sop-checklist / na-declarations (pull_request) awaiting /sop-n/a declaration for: qa-review, security-review
sop-checklist / all-items-acked (pull_request) Successful in 20s
sop-tier-check / tier-check (pull_request) Successful in 17s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 14s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 44s
Ops Scripts Tests / Ops scripts (unittest) (push) Successful in 1m21s
CI / Canvas (Next.js) (push) Successful in 10s
CI / Shellcheck (E2E scripts) (push) Successful in 7s
CI / Python Lint & Test (push) Successful in 8s
Harness Replays / Harness Replays (push) Successful in 9s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 14s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 2m10s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 2m49s
CI / Platform (Go) (push) Failing after 4m59s
Handlers Postgres Integration / Handlers Postgres Integration (push) Failing after 4m49s
CI / Canvas Deploy Reminder (push) Successful in 6s
audit-force-merge / audit (pull_request) Successful in 37s
publish-workspace-server-image / build-and-push (push) Successful in 9m6s
CI / all-required (push) Successful in 6s
publish-workspace-server-image / Production auto-deploy (push) Failing after 40s
2026-05-14 14:36:38 +00:00
hongming-codex-laptop 0b47f9516d fix(ci): repair delegation list and merge queue tests
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 24s
Harness Replays / detect-changes (pull_request) Successful in 19s
CI / Detect changes (pull_request) Successful in 1m15s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 53s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 1m9s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m11s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 13s
qa-review / approved (pull_request) Successful in 16s
gate-check-v3 / gate-check (pull_request) Successful in 18s
security-review / approved (pull_request) Failing after 19s
sop-tier-check / tier-check (pull_request) Successful in 18s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m21s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m21s
Harness Replays / Harness Replays (pull_request) Successful in 9s
CI / Canvas (Next.js) (pull_request) Successful in 16s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 10s
CI / Python Lint & Test (pull_request) Successful in 9s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 19s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2m4s
CI / Platform (Go) (pull_request) Failing after 4m14s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Failing after 4m15s
sop-checklist / na-declarations (pull_request) awaiting /sop-n/a declaration for: qa-review, security-review
CI / all-required (pull_request) Successful in 4s
sop-checklist / all-items-acked (pull_request) All items acked
Runtime PR-Built Compatibility / detect-changes (pull_request) Failing after 12m49s
audit-force-merge / audit (pull_request) Successful in 23s
2026-05-14 14:19:42 +00:00
devops-engineer 2a476c3bbb Merge pull request 'fix(ci): add job-level if: to canvas-deploy-reminder (mc#958 root-fix)' (#1015) from sre/ci-required-drift-canvas-reminder-skip into main
Block internal-flavored paths / Block forbidden paths (push) Successful in 19s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 24s
CI / Detect changes (push) Successful in 1m38s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 1m20s
E2E API Smoke Test / detect-changes (push) Successful in 1m23s
Handlers Postgres Integration / detect-changes (push) Successful in 1m31s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 48s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 1m51s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 2m36s
CI / Platform (Go) (push) Successful in 7s
CI / Canvas (Next.js) (push) Successful in 7s
CI / Shellcheck (E2E scripts) (push) Successful in 5s
CI / Python Lint & Test (push) Successful in 5s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 8s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 7s
Handlers Postgres Integration / Handlers Postgres Integration (push) Failing after 1m5s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 18s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 14s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 3m11s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m44s
publish-workspace-server-image / build-and-push (push) Successful in 8m1s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m48s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 1m55s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m17s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 2m2s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 15s
qa-review / approved (pull_request) Successful in 20s
gate-check-v3 / gate-check (pull_request) Successful in 28s
security-review / approved (pull_request) Successful in 24s
publish-runtime-autobump / pr-validate (pull_request) Successful in 59s
CI / Canvas Deploy Reminder (push) Successful in 28s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m46s
Secret scan / Scan diff for credential-shaped strings (push) Failing after 10m10s
publish-workspace-server-image / Production auto-deploy (push) Failing after 40s
CI / all-required (push) Successful in 4s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 14s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 3s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m33s
main-red-watchdog / watchdog (push) Successful in 35s
gitea-merge-queue / queue (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
status-reaper / reap (push) Successful in 1m3s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 4m42s
2026-05-14 14:17:21 +00:00
fullstack-engineer 858af52d6f fix(handlers): add rows.Err() checks after secrets scan loops (closes #1016)
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 7s
Harness Replays / detect-changes (pull_request) Successful in 9s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 8s
E2E API Smoke Test / detect-changes (pull_request) Successful in 19s
CI / Detect changes (pull_request) Successful in 19s
gate-check-v3 / gate-check (pull_request) Successful in 13s
qa-review / approved (pull_request) Successful in 12s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 23s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 22s
security-review / approved (pull_request) Successful in 10s
sop-tier-check / tier-check (pull_request) Successful in 14s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m6s
Harness Replays / Harness Replays (pull_request) Successful in 12s
CI / Canvas (Next.js) (pull_request) Successful in 15s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 8s
CI / Python Lint & Test (pull_request) Successful in 8s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 11s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2m2s
CI / Canvas Deploy Reminder (pull_request) Successful in 5s
CI / Platform (Go) (pull_request) Failing after 4m27s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Failing after 4m10s
CI / all-required (pull_request) Successful in 6s
sop-checklist / na-declarations (pull_request) awaiting /sop-n/a declaration for: qa-review, security-review
sop-checklist / all-items-acked (pull_request) [info tier:low] acked: 5/7 — missing: root-cause, no-backwards-compat — body-unfilled: comprehensive-testing, local-postgres-e2e, staging-sm
audit-force-merge / audit (pull_request) Successful in 24s
Regression from audit #109: rows.Err() checks were removed from four
functions between commits 3a30b073 and b25b4fb6. Without these checks,
a mid-stream query error (e.g. connection loss during row iteration)
is silently ignored and partial results are returned as success.

Added rows.Err() checks after every for rows.Next() loop:
- List: workspace secrets loop + global secrets loop
- Values: global secrets loop + workspace secrets loop
- ListGlobal: single loop
- restartAllAffectedByGlobalKey: affected workspaces loop

Each check logs the iteration error and continues (non-fatal, matching
the existing log.Printf pattern used elsewhere in the file).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 14:12:35 +00:00
infra-sre 7888f96f45 fix(ci): add job-level if: to canvas-deploy-reminder (mc#958 root-fix)
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 21s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 22s
CI / Detect changes (pull_request) Successful in 1m50s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 1m41s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 1m38s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m45s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 28s
qa-review / approved (pull_request) Successful in 26s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 2m44s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 1m1s
gate-check-v3 / gate-check (pull_request) Failing after 33s
security-review / approved (pull_request) Failing after 19s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m29s
sop-tier-check / tier-check (pull_request) Successful in 25s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m49s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 2m58s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 2m34s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 2m53s
CI / Platform (Go) (pull_request) Successful in 12s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 10s
CI / Canvas (Next.js) (pull_request) Successful in 13s
CI / Python Lint & Test (pull_request) Successful in 12s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 13s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 16s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 19s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 7s
sop-checklist / na-declarations (pull_request) awaiting /sop-n/a declaration for: qa-review, security-review
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 4s
sop-checklist / all-items-acked (pull_request) All items acked
audit-force-merge / audit (pull_request) Successful in 26s
canvas-deploy-reminder had step-level gating (REF_NAME != refs/heads/main)
but no job-level `if:`. The ci-required-drift.py ci_job_names() skip
logic only detects job-level `github.ref` gates, so canvas-deploy-reminder
was flagged as F1 (missing from all-required.needs) despite being
intentionally excluded.

Fix:
- Added job-level `if: github.ref == 'refs/heads/main'` to canvas-deploy-reminder
  so ci-required-drift.py correctly skips it from ci_job_names() F1 check
- Added canvas-deploy-reminder to all-required.needs (sentinel handles
  skipped job result correctly)
- Removed stale continue-on-error: true (was mc#774 interim mask;
  step exits 0 when not applicable)

The step-level exit 0 is preserved for the "canvas not changed" case
on main pushes. The job-level `if:` makes the main-push-only scope
visible to the drift detector.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 13:58:40 +00:00
devops-engineer 4e92e46182 Merge pull request 'test(handlers): add InstructionsHandler coverage — 18 sqlmock cases' (#1005) from test/instructions-handler-coverage into main
Block internal-flavored paths / Block forbidden paths (push) Successful in 19s
Harness Replays / detect-changes (push) Successful in 16s
CI / Detect changes (push) Successful in 46s
E2E API Smoke Test / detect-changes (push) Successful in 45s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 22s
Handlers Postgres Integration / detect-changes (push) Successful in 1m0s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 1m5s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 1m58s
Harness Replays / Harness Replays (push) Successful in 9s
CI / Canvas (Next.js) (push) Successful in 12s
CI / Shellcheck (E2E scripts) (push) Successful in 11s
CI / Python Lint & Test (push) Successful in 10s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Has started running
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 13s
Handlers Postgres Integration / Handlers Postgres Integration (push) Failing after 1m35s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 2m15s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 2m54s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 5m11s
publish-workspace-server-image / build-and-push (push) Successful in 10m13s
CI / Platform (Go) (push) Failing after 4m38s
main-red-watchdog / watchdog (push) Successful in 1m12s
CI / Canvas Deploy Reminder (push) Successful in 8s
gate-check-v3 / gate-check (push) Successful in 1m19s
publish-workspace-server-image / Production auto-deploy (push) Failing after 28s
CI / all-required (push) Successful in 4s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 14s
gitea-merge-queue / queue (push) Successful in 9s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 15s
status-reaper / reap (push) Successful in 1m17s
ci-required-drift / drift (push) Successful in 2m26s
2026-05-14 13:58:27 +00:00
devops-engineer 4e8b40d1ea Merge pull request 'fix(canvas): remove invalid CSS child-combinator from ThemeToggle querySelectorAll (closes #1008)' (#1012) from fix/1008-themetoggle-css-selector into staging
Block internal-flavored paths / Block forbidden paths (push) Successful in 11s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 19s
Harness Replays / detect-changes (push) Successful in 21s
Harness Replays / Harness Replays (push) Successful in 6s
CI / Detect changes (push) Successful in 32s
E2E API Smoke Test / detect-changes (push) Successful in 40s
Handlers Postgres Integration / detect-changes (push) Successful in 44s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 43s
CI / Platform (Go) (push) Successful in 5s
CI / Shellcheck (E2E scripts) (push) Successful in 5s
CI / Python Lint & Test (push) Successful in 6s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 8s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 2m35s
Handlers Postgres Integration / Handlers Postgres Integration (push) Failing after 3m41s
CI / Canvas (Next.js) (push) Successful in 16m31s
CI / Canvas Deploy Reminder (push) Successful in 7s
CI / all-required (push) Failing after 12m3s
2026-05-14 13:53:04 +00:00
core-be f417c1a870 test(handlers): add InstructionsHandler coverage — 18 cases
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 19s
CI / Detect changes (pull_request) Successful in 41s
E2E API Smoke Test / detect-changes (pull_request) Successful in 46s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 42s
Harness Replays / detect-changes (pull_request) Successful in 17s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 41s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 19s
sop-checklist / na-declarations (pull_request) awaiting /sop-n/a declaration for: qa-review, security-review
qa-review / approved (pull_request) Successful in 21s
gate-check-v3 / gate-check (pull_request) Successful in 33s
security-review / approved (pull_request) Failing after 19s
sop-tier-check / tier-check (pull_request) Successful in 18s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 44s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 16s
CI / Python Lint & Test (pull_request) Successful in 15s
CI / Canvas (Next.js) (pull_request) Successful in 17s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 9s
Harness Replays / Harness Replays (pull_request) Successful in 8s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 11s
CI / Canvas Deploy Reminder (pull_request) Successful in 10s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m18s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Failing after 56s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m32s
CI / Platform (Go) (pull_request) Failing after 2m51s
CI / all-required (pull_request) Successful in 7s
sop-checklist / all-items-acked (pull_request) All items acked
audit-force-merge / audit (pull_request) Successful in 19s
Add sqlmock unit tests for InstructionsHandler (instructions.go):
- List: empty result, scope filter, workspace_id filter, DB error
- Create: success (global), success (workspace with scope_target), invalid scope,
  workspace scope missing scope_target, content too long (>8192), title too long (>200)
- Update: success, not found (0 rows), content too long, title too long
- Delete: success, not found (0 rows)
- Resolve: empty workspace, with global+workspace instructions, missing workspace_id
- scanInstructions: rows.Err() handled gracefully (continues, not fatal)

All 18 tests cover the DB query paths using sqlmock.
2026-05-14 13:49:43 +00:00
devops-engineer 8628d5cd2d Merge pull request 'fix(ci): add explicit 20m timeout to canvas-build job' (#1006) from sre/canvas-build-timeout into main
Block internal-flavored paths / Block forbidden paths (push) Successful in 18s
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 14s
CI / Detect changes (push) Successful in 1m1s
E2E API Smoke Test / detect-changes (push) Successful in 1m0s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 1m0s
Handlers Postgres Integration / detect-changes (push) Successful in 55s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 14s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 45s
CI / Platform (Go) (push) Successful in 7s
CI / Shellcheck (E2E scripts) (push) Successful in 6s
CI / Canvas (Next.js) (push) Successful in 9s
CI / Python Lint & Test (push) Successful in 7s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 1m40s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 9s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 11s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 2m12s
CI / Canvas Deploy Reminder (push) Successful in 19s
CI / all-required (push) Successful in 14s
Handlers Postgres Integration / Handlers Postgres Integration (push) Failing after 1m12s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 3m4s
publish-workspace-server-image / build-and-push (push) Successful in 7m48s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 5m13s
status-reaper / reap (push) Has started running
gitea-merge-queue / queue (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
2026-05-14 13:49:05 +00:00
infra-sre 4262c0a3db fix(ci): add explicit 20m timeout to canvas-build job
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 21s
CI / Detect changes (pull_request) Successful in 41s
E2E API Smoke Test / detect-changes (pull_request) Successful in 45s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 15s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 52s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 51s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 17s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 21s
gate-check-v3 / gate-check (pull_request) Successful in 10s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m55s
qa-review / approved (pull_request) Successful in 13s
sop-checklist / na-declarations (pull_request) awaiting /sop-n/a declaration for: qa-review, security-review
security-review / approved (pull_request) Failing after 13s
sop-tier-check / tier-check (pull_request) Successful in 14s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m19s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m42s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 1m53s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m42s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 2m7s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 11s
CI / Platform (Go) (pull_request) Successful in 15s
CI / Python Lint & Test (pull_request) Successful in 10s
CI / Canvas (Next.js) (pull_request) Successful in 13s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 9s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 6s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 8s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 8s
CI / Canvas Deploy Reminder (pull_request) Successful in 5s
CI / all-required (pull_request) Successful in 4s
sop-checklist / all-items-acked (pull_request) All items acked
audit-force-merge / audit (pull_request) Successful in 26s
Cold runner cache causes O(npm install) to take ~14m on first run.
Without an explicit job-level timeout, Gitea's hard limit (~15m) is
the active constraint — a single slow build would timeout instead of
completing successfully.

Matches the pattern already used by platform-build (timeout-minutes: 15).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 13:30:01 +00:00
fullstack-engineer d5e362690f fix(canvas): remove invalid CSS child-combinator from ThemeToggle querySelectorAll (closes #1008)
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 18s
Harness Replays / detect-changes (pull_request) Successful in 17s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 18s
gate-check-v3 / gate-check (pull_request) Successful in 24s
qa-review / approved (pull_request) Successful in 26s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m4s
CI / Detect changes (pull_request) Successful in 1m5s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 1m6s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 1m1s
security-review / approved (pull_request) Successful in 22s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m32s
Harness Replays / Harness Replays (pull_request) Successful in 7s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 11s
CI / Platform (Go) (pull_request) Successful in 11s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 12s
CI / Python Lint & Test (pull_request) Successful in 11s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 14s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 12s
sop-checklist / na-declarations (pull_request) awaiting /sop-n/a declaration for: qa-review, security-review
CI / Canvas (Next.js) (pull_request) Successful in 14m50s
CI / all-required (pull_request) Successful in 6s
CI / Canvas Deploy Reminder (pull_request) Successful in 9s
sop-checklist / all-items-acked (pull_request) All items acked
audit-force-merge / audit (pull_request) Successful in 12s
The `> [role=radio]` selector is malformed — the `>` combinator requires
a parent selector to its left. In a browser, element.querySelectorAll()
accepts this implicitly but jsdom's parser rejects it with:
  SyntaxError: Invalid selector > [role=radio]

This caused 5 uncaught exceptions per test run in ThemeToggle.test.tsx.
Fix: remove the `>` since the query is already scoped to radiogroup.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 13:15:56 +00:00
devops-engineer 1dd6697031 Merge pull request 'fix(canvas): guard querySelectorAll in ThemeToggle handleKeyDown' (#1001) from fix/2088-themetoggle-queryselectorall-errors into main
Block internal-flavored paths / Block forbidden paths (push) Successful in 11s
Harness Replays / detect-changes (push) Successful in 9s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 11s
CI / Detect changes (push) Successful in 37s
E2E API Smoke Test / detect-changes (push) Successful in 36s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 36s
Handlers Postgres Integration / detect-changes (push) Successful in 35s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 33s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 51s
publish-canvas-image / Build & push canvas image (push) Successful in 6m41s
ci-required-drift / drift (push) Failing after 2m41s
publish-workspace-server-image / build-and-push (push) Successful in 11m49s
CI / Shellcheck (E2E scripts) (push) Successful in 8s
CI / Python Lint & Test (push) Successful in 16s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 14s
Handlers Postgres Integration / Handlers Postgres Integration (push) Failing after 1m45s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 3m35s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 18s
CI / Detect changes (pull_request) Successful in 44s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 14s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 34s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 45s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 48s
gate-check-v3 / gate-check (pull_request) Failing after 24s
qa-review / approved (pull_request) Successful in 15s
security-review / approved (pull_request) Successful in 18s
sop-checklist / all-items-acked (pull_request) Successful in 18s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m23s
sop-tier-check / tier-check (pull_request) Successful in 18s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 9m22s
audit-force-merge / audit (pull_request) Has been skipped
Harness Replays / Harness Replays (push) Failing after 10m36s
CI / Platform (Go) (push) Failing after 10m31s
publish-workspace-server-image / Production auto-deploy (push) Failing after 2m35s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 6m41s
CI / Platform (Go) (pull_request) Successful in 11s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 8s
CI / Canvas (Next.js) (pull_request) Successful in 10s
CI / Python Lint & Test (pull_request) Successful in 17s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 10s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 5s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 6s
CI / Canvas (Next.js) (push) Successful in 15m24s
status-reaper / reap (push) Has started running
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 12s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 14s
gitea-merge-queue / queue (push) Successful in 21s
CI / Canvas Deploy Reminder (pull_request) Successful in 5s
CI / all-required (pull_request) Successful in 4s
CI / Canvas Deploy Reminder (push) Successful in 6s
CI / all-required (push) Successful in 5s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 4m50s
2026-05-14 13:13:09 +00:00
core-fe 5e6c490b19 fix(canvas): guard querySelectorAll in ThemeToggle handleKeyDown
sop-checklist / na-declarations (pull_request) awaiting /sop-n/a declaration for: qa-review, security-review
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 13s
CI / Detect changes (pull_request) Successful in 28s
E2E API Smoke Test / detect-changes (pull_request) Successful in 33s
Harness Replays / detect-changes (pull_request) Successful in 12s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 14s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 37s
qa-review / approved (pull_request) Successful in 14s
security-review / approved (pull_request) Successful in 13s
gate-check-v3 / gate-check (pull_request) Successful in 26s
sop-checklist / all-items-acked (pull_request) Successful in 19s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 38s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 41s
sop-tier-check / tier-check (pull_request) Successful in 14s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m14s
audit-force-merge / audit (pull_request) Successful in 16s
CI / Platform (Go) (pull_request) Successful in 19s
CI / Python Lint & Test (pull_request) Successful in 11s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 16s
Harness Replays / Harness Replays (pull_request) Successful in 13s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 13s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 8s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 10m32s
CI / Shellcheck (E2E scripts) (pull_request) Failing after 14m32s
CI / Canvas (Next.js) (pull_request) Successful in 17m9s
CI / Canvas Deploy Reminder (pull_request) Successful in 9s
CI / all-required (pull_request) Failing after 7s
querySelectorAll throws INDEX_SIZE_ERR in jsdom when the
child-combinator selector is evaluated in certain DOM attachment
states. Wrap in try-catch with fallback selector to restore the
5 errors (0 failures) in ThemeToggle.test.tsx.

Tests: 208 files, 3245 passed, 0 errors.
2026-05-14 13:11:46 +00:00
devops-engineer 9f7b87de21 Merge pull request 'fix(handlers): remove 3 duplicate test declarations blocking CI build (closes #968)' (#1002) from fix/968-remove-duplicate-test-declarations into staging
Block internal-flavored paths / Block forbidden paths (push) Successful in 11s
Harness Replays / detect-changes (push) Successful in 10s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 14s
CI / Detect changes (push) Successful in 30s
E2E API Smoke Test / detect-changes (push) Successful in 33s
Handlers Postgres Integration / detect-changes (push) Successful in 32s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 29s
Harness Replays / Harness Replays (push) Successful in 5s
CI / Canvas (Next.js) (push) Successful in 8s
CI / Shellcheck (E2E scripts) (push) Successful in 8s
CI / Python Lint & Test (push) Successful in 9s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 2m35s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 3m3s
CI / Platform (Go) (push) Failing after 5m49s
Handlers Postgres Integration / Handlers Postgres Integration (push) Failing after 5m57s
CI / Canvas Deploy Reminder (push) Successful in 12s
CI / all-required (push) Successful in 8s
2026-05-14 13:09:37 +00:00
devops-engineer cdb0b0401a Merge pull request 'ci: fix db.DB pollution + ci-required-drift github.ref skip (mc#975, mc#958, mc#959)' (#991) from ci/975-db-pollution-fix into main
CI / all-required (push) Blocked by required conditions
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (push) Successful in 10s
Harness Replays / detect-changes (push) Successful in 12s
CI / Detect changes (push) Successful in 39s
E2E API Smoke Test / detect-changes (push) Successful in 38s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 19s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 46s
Handlers Postgres Integration / detect-changes (push) Successful in 45s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 39s
Ops Scripts Tests / Ops scripts (unittest) (push) Failing after 1m18s
Harness Replays / Harness Replays (push) Successful in 6s
CI / Shellcheck (E2E scripts) (push) Successful in 5s
CI / Python Lint & Test (push) Successful in 5s
CI / Canvas (Next.js) (push) Successful in 6s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 5s
Handlers Postgres Integration / Handlers Postgres Integration (push) Failing after 1m11s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 1m39s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 2m22s
CI / Platform (Go) (push) Failing after 3m52s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 16s
gate-check-v3 / gate-check (pull_request) Successful in 13s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 14s
publish-workspace-server-image / build-and-push (push) Successful in 7m22s
qa-review / approved (pull_request) Successful in 15s
publish-runtime-autobump / pr-validate (pull_request) Successful in 41s
security-review / approved (pull_request) Successful in 23s
sop-checklist / all-items-acked (pull_request) Successful in 23s
sop-tier-check / tier-check (pull_request) Successful in 21s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m19s
audit-force-merge / audit (pull_request) Successful in 23s
main-red-watchdog / watchdog (push) Successful in 1m7s
CI / Canvas Deploy Reminder (push) Successful in 3s
status-reaper / reap (push) Has started running
gitea-merge-queue / queue (push) Successful in 17s
gate-check-v3 / gate-check (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 1m55s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 5m10s
2026-05-14 13:01:05 +00:00
core-devops 3297d16093 ci-required-drift: also skip jobs gated on github.ref (fixes mc#958/mc#959)
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 22s
Harness Replays / detect-changes (pull_request) Successful in 22s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 20s
CI / Detect changes (pull_request) Successful in 1m2s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m0s
qa-review / approved (pull_request) Failing after 26s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 1m3s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 1m6s
gate-check-v3 / gate-check (pull_request) Failing after 45s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 1m4s
Harness Replays / Harness Replays (pull_request) Successful in 6s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m19s
sop-tier-check / tier-check (pull_request) Successful in 16s
sop-checklist / na-declarations (pull_request) awaiting /sop-n/a declaration for: qa-review, security-review
sop-checklist / all-items-acked (pull_request) [info tier:low] acked: 5/7 — missing: root-cause, no-backwards-compat — body-unfilled: comprehensive-testing, local-postgres-e2e, staging-sm
CI / Canvas (Next.js) (pull_request) Successful in 9s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Failing after 1m26s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 8s
CI / Python Lint & Test (pull_request) Successful in 9s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 9s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 8s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Failing after 1m12s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m36s
CI / Canvas Deploy Reminder (pull_request) Successful in 3s
audit-force-merge / audit (pull_request) Successful in 15s
CI / Platform (Go) (pull_request) Failing after 3m13s
CI / all-required (pull_request) Successful in 4s
security-review / approved (pull_request) Failing after 12m6s
canvas-deploy-reminder has:
  if: needs.changes.outputs.canvas == 'true'
      && github.event_name == 'push'
      && github.ref == 'refs/heads/main'

ci_job_names() only skipped jobs with `github.event_name` in their `if:`.
The `github.ref` branch was invisible to the detector, so
canvas-deploy-reminder was flagged as missing from all-required.needs —
a false positive that fires on every PR touching canvas/ code.

Now the skip check also fires when `github.ref` is present in the `if:`
condition string, matching the same rationale as the event_name skip:
these jobs never execute in a PR context, so requiring them under
all-required.needs: is not meaningful.

Refs: mc#958 (main), mc#959 (staging)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 12:58:13 +00:00
core-devops e0e5dd911f handlers: add missing db import + remove duplicate test declarations
Two compilation errors were preventing CI/Platform (Go) from running any
tests at all (go vet failed first):

1. delegation_list_test.go: missing `db` import. The file assigns
   `db.DB = mockDB` but never imported the `db` package — a silent
   omission that compiled before the staging promotion's go.mod bump.

2. org_helpers_security_test.go: three test functions redeclared in
   org_helpers_pure_test.go (both files added by the staging promotion):
   TestIsSafeRoleName_Valid, TestMergeCategoryRouting_EmptyListDropsCategory,
   TestMergeCategoryRouting_EmptyKeySkipped. Removed from security file;
   pure_test.go versions use testify and are more comprehensive.

Together with the prevDB/restore fixes in the previous commits, this
should make CI/Platform (Go) fully green.

Refs: mc#975

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 12:58:04 +00:00
core-devops a50f51eb8f handlers/internal: fix db.DB pollution in registry and scheduler test helpers
Five more test helpers have the same setupTestDB bug (save db.DB but
don't restore on teardown). go test -race runs tests in parallel; when
test A sets db.DB = mockA and test B sets db.DB = mockB, if A runs
first and cleanup closes mockA, B then runs with db.DB pointing at a
closed mock.

Fixed files:
- internal/registry/liveness_test.go    setupLivenessTestDB
- internal/registry/hibernation_test.go  setupHibernationMock
- internal/registry/access_test.go      setupMockDB
- internal/registry/healthsweep_test.go  setupTestDB
- internal/scheduler/scheduler_test.go   setupTestDB

All now follow: prevDB := db.DB; db.DB = mockDB;
t.Cleanup(func() { mockDB.Close(); db.DB = prevDB })

Total files fixed for mc#975: 8 files, ~20 test helper functions across
the workspace-server. Together with the CI fix to remove the
PHASE3_MASKED workaround, this should make CI/Platform (Go) stable.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 12:58:03 +00:00
core-devops e11f1f3c06 handlers: fix db.DB pollution in activity_test.go and a2a_queue_test.go
activity_test.go: 6 test functions used `defer mockDB.Close(); db.DB =
mockDB` without saving/restoring the previous db.DB. go test -race could
run subsequent tests with db.DB pointing at a closed mock.

a2a_queue_test.go: setupTestDBForQueueTests had the same bug as
setupTestDB — called `t.Cleanup(func(){mockDB.Close()})` without
restoring prevDB. All callers of this helper are now protected.

Pattern applied everywhere: save prevDB, assign mockDB, t.Cleanup
restores both. Together with the delegation_list_test.go fix in the
previous commit, this should eliminate all remaining race-condition
failures in CI/Platform (Go).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 12:58:03 +00:00
core-devops 126edf74c1 handlers: restore db.DB after each test to fix CI/Platform (Go) race failures
mc#975 root cause: TestListDelegationsFromLedger_* and
TestListDelegationsFromActivityLogs_* assign db.DB = mockDB then defer
mockDB.Close(), but never save/restore the previous db.DB value. With
go test -race (parallel execution), any test running after one of these
13 tests sees db.DB pointing at a closed sqlmock and fails.

Fix: save prevDB := db.DB before assignment, then t.Cleanup(func() {
mockDB.Close(); db.DB = prevDB }) — the same pattern already used by
setupTestDB for the SSRF/restore path.

Also fix setupTestDB in handlers_test.go: it called t.Cleanup(func()
{ mockDB.Close() }) but left db.DB pointing at the closed mock; now it
also restores prevDB.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 12:58:03 +00:00
56 changed files with 1256 additions and 1190 deletions
+11 -4
View File
@@ -203,12 +203,17 @@ def ci_jobs_all(ci_doc: dict) -> set[str]:
def ci_job_names(ci_doc: dict) -> set[str]:
"""Set of job keys in ci.yml MINUS the sentinel itself MINUS jobs
whose `if:` gates on `github.event_name` (those are event-scoped
and can legitimately be `skipped` for a given trigger; if we
required them under the sentinel `needs:`, every PR-only job
whose `if:` gates on `github.event_name` or `github.ref` (those are
event-scoped and can legitimately be `skipped` for a given trigger;
if we required them under the sentinel `needs:`, every PR-only job
would be `skipped` on push and the sentinel would interpret
`skipped != success` as failure). RFC §4 spec.
`github.ref` is the companion gate for jobs that run only on direct
pushes to specific branches (e.g. `github.ref == 'refs/heads/main'`).
These never execute in a PR context, so flagging them as missing
from `all-required.needs:` is a false positive (mc#958 / mc#959).
Used for F1 (jobs missing from sentinel needs). NOT used for F1b
(typos in needs) — see `ci_jobs_all` for that."""
jobs = ci_doc.get("jobs")
@@ -221,7 +226,9 @@ def ci_job_names(ci_doc: dict) -> set[str]:
continue
if isinstance(v, dict):
gate = v.get("if")
if isinstance(gate, str) and "github.event_name" in gate:
if isinstance(gate, str) and (
"github.event_name" in gate or "github.ref" in gate
):
continue
names.add(k)
return names
+15 -1
View File
@@ -417,7 +417,21 @@ def main() -> int:
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
_require_runtime_env()
return process_once(dry_run=args.dry_run)
try:
return process_once(dry_run=args.dry_run)
except ApiError as exc:
# API errors (401/403/404/500) are transient for a queue tick —
# log and exit 0 so the workflow is not marked failed and the next
# tick can retry. Returning non-zero would permanently fail the
# workflow run, blocking future ticks.
sys.stderr.write(f"::error::queue API error: {exc}\n")
return 0
except urllib.error.URLError as exc:
sys.stderr.write(f"::error::queue network error: {exc}\n")
return 0
except TimeoutError as exc:
sys.stderr.write(f"::error::queue timeout: {exc}\n")
return 0
if __name__ == "__main__":
Regular → Executable
+181 -37
View File
@@ -109,57 +109,58 @@ def normalize_slug(raw: str, numeric_aliases: dict[int, str] | None = None) -> s
# Optional trailing note after the slug for /sop-ack and required reason
# for /sop-revoke (RFC#351 open question 4 — reason is captured but not
# yet validated; future iteration may require a min-length).
#
# /sop-n/a <gate> [reason] — declares a gate as not-applicable.
# <gate> is a canonical gate name (qa-review, security-review).
# The declaring user must be in one of the gate's required_teams.
# Most-recent per-user declaration wins (revoke semantics mirror ack).
_DIRECTIVE_RE = re.compile(
r"^[ \t]*/(sop-ack|sop-revoke)[ \t]+([A-Za-z0-9_\- ]+?)(?:[ \t]+(.*))?[ \t]*$",
re.MULTILINE,
)
_NA_DIRECTIVE_RE = re.compile(
r"^[ \t]*/sop-n/?a[ \t]+([A-Za-z0-9_\-]+)(?:[ \t]+(.*))?[ \t]*$",
re.MULTILINE,
)
def parse_directives(
comment_body: str,
numeric_aliases: dict[int, str],
) -> list[tuple[str, str, str]]:
"""Extract /sop-ack and /sop-revoke directives from a comment body.
) -> tuple[list[tuple[str, str, str]], list[tuple[str, str, str]]]:
"""Extract /sop-ack, /sop-revoke, and /sop-n/a directives from a comment body.
Returns a list of (kind, canonical_slug, note) tuples where:
kind is "sop-ack" or "sop-revoke"
canonical_slug is the normalized form (or "" if unparseable)
note is the trailing free-text (may be "")
Returns a tuple of two lists:
0. list of (kind, canonical_slug, note) for sop-ack/sop-revoke
1. list of (kind, gate_name, reason) for sop-n/a
canonical_slug is the normalized form (or "" if unparseable).
note/reason is the trailing free-text (may be "").
"""
out: list[tuple[str, str, str]] = []
na_out: list[tuple[str, str, str]] = []
if not comment_body:
return out
return out, na_out
for m in _DIRECTIVE_RE.finditer(comment_body):
kind = m.group(1)
raw_slug = (m.group(2) or "").strip()
# If the raw match included trailing words, the regex non-greedy
# captured only the first token; strip again for safety.
# We split on whitespace to keep the FIRST word as the slug, and
# everything after as the note.
parts = raw_slug.split()
if not parts:
continue
first = parts[0]
# If the slug-capture greedily matched multiple words (e.g.
# "comprehensive testing"), preserve normalize behavior: join
# the WHOLE first-word-token only; trailing words get appended to
# the note. The regex limits group(2) to [A-Za-z0-9_\- ] so we
# may have multi-word forms here — normalize handles them.
if len(parts) > 1:
# User wrote "/sop-ack comprehensive testing extra-note"
# → treat "comprehensive testing" as the slug source if it
# normalizes to a known item; otherwise treat "comprehensive"
# as slug and "testing extra-note" as note. We defer the
# disambiguation to the caller via the returned canonical
# slug. For simplicity: try the WHOLE captured string first.
canonical = normalize_slug(raw_slug, numeric_aliases)
else:
canonical = normalize_slug(first, numeric_aliases)
note_from_group = (m.group(3) or "").strip()
# If we collapsed multi-word slug into kebab and there's a
# trailing-text group too, append it.
out.append((kind, canonical, note_from_group))
return out
for m in _NA_DIRECTIVE_RE.finditer(comment_body):
gate = (m.group(1) or "").strip().lower()
reason = (m.group(2) or "").strip()
na_out.append(("sop-n/a", gate, reason))
return out, na_out
# ---------------------------------------------------------------------------
@@ -230,9 +231,8 @@ def compute_ack_state(
{
"comprehensive-testing": {
"ackers": ["bob"], # non-author, team-verified
"rejected_ackers": { # debugging info
"rejected": {
"self_ack": ["alice"],
"unknown_slug": [],
"not_in_team": ["eve"],
}
},
@@ -249,7 +249,8 @@ def compute_ack_state(
user = (c.get("user") or {}).get("login", "")
if not user:
continue
for kind, slug, _note in parse_directives(body, numeric_aliases):
directives, _na_directives = parse_directives(body, numeric_aliases)
for kind, slug, _note in directives:
if not slug:
unparseable_per_user[user] = unparseable_per_user.get(user, 0) + 1
continue
@@ -259,25 +260,19 @@ def compute_ack_state(
# Filter out self-acks and unknown slugs.
ackers_per_slug: dict[str, list[str]] = {s: [] for s in items_by_slug}
rejected_self: dict[str, list[str]] = {s: [] for s in items_by_slug}
rejected_unknown: dict[str, list[str]] = {s: [] for s in items_by_slug}
pending_team_check: dict[str, list[str]] = {s: [] for s in items_by_slug}
for (user, slug), kind in latest_directive.items():
if kind != "sop-ack":
continue # revokes leave the (user,slug) state as "no ack"
if slug not in items_by_slug:
# Slug normalized to something not in our config — store
# under a synthetic key for diagnostic surfacing. Don't add
# to any item.
continue
if user == pr_author:
rejected_self[slug].append(user)
continue
pending_team_check[slug].append(user)
# Step 3: team membership probe per slug (batched per slug to keep
# API call count down — same user may ack multiple items but the
# required_teams differ per item, so we MUST probe per (user, item)).
# Step 3: team membership probe per slug.
rejected_not_in_team: dict[str, list[str]] = {s: [] for s in items_by_slug}
for slug, candidates in pending_team_check.items():
if not candidates:
@@ -286,7 +281,6 @@ def compute_ack_state(
approved = team_membership_probe(slug, candidates) # returns subset
rejected_not_in_team[slug] = [u for u in candidates if u not in approved]
ackers_per_slug[slug] = approved
# Stash required teams for description rendering.
items_by_slug[slug]["_required_resolved"] = required
return {
@@ -301,6 +295,113 @@ def compute_ack_state(
}
def compute_na_state(
comments: list[dict[str, Any]],
pr_author: str,
na_gates: dict[str, dict[str, Any]],
numeric_aliases: dict[int, str],
team_membership_probe: "callable[[str, list[str]], list[str]]",
client: "GiteaClient",
org: str,
) -> dict[str, dict[str, Any]]:
"""Compute per-gate N/A declaration state.
Returns a dict keyed by gate name:
{
"qa-review": {
"declared": ["alice"], # non-author, team-verified, not revoked
"rejected": ["eve (not-in-team)", "bob (self-decl)"],
"reason": "pure-infra change — no qa surface",
},
...
}
A gate is N/A-satisfied when at least one declaration from a valid
team member exists and has not been revoked by the same user.
"""
if not na_gates:
return {}
# Collapse directives per (commenter, gate) — most recent wins.
latest_na: dict[tuple[str, str], str] = {} # (user, gate) → "sop-n/a"
latest_na_reason: dict[tuple[str, str], str] = {} # (user, gate) → reason
for c in comments:
body = c.get("body", "") or ""
user = (c.get("user") or {}).get("login", "")
if not user:
continue
_directives, na_directives = parse_directives(body, numeric_aliases)
for _kind, gate, reason in na_directives:
if gate not in na_gates:
continue
latest_na[(user, gate)] = "sop-n/a"
latest_na_reason[(user, gate)] = reason
# Determine candidate declarers per gate.
na_state: dict[str, dict[str, Any]] = {
gate: {"declared": [], "rejected": [], "reason": ""}
for gate in na_gates
}
pending_per_gate: dict[str, list[str]] = {gate: [] for gate in na_gates}
for (user, gate), kind in latest_na.items():
if kind != "sop-n/a":
continue
if user == pr_author:
na_state[gate]["rejected"].append(f"{user} (self-decl)")
continue
pending_per_gate[gate].append(user)
# Probe team membership per gate using that gate's required_teams.
for gate, candidates in pending_per_gate.items():
if not candidates:
continue
required_teams = na_gates[gate].get("required_teams", [])
# Resolve team names → ids using the client's resolver.
team_ids: list[int] = []
for tn in required_teams:
tid = client.resolve_team_id(org, tn)
if tid is not None:
team_ids.append(tid)
if not team_ids:
na_state[gate]["rejected"].extend(
f"{u} (no-team-id)" for u in candidates
)
continue
for u in candidates:
in_any_team = False
for tid in team_ids:
result = client.is_team_member(tid, u)
if result is True:
in_any_team = True
break
if result is None:
# 403 — token owner not in team. Fail-closed.
print(
f"::warning::na: team-probe for {u} in team-id {tid} "
"returned 403 — treating as not-in-team (fail-closed)",
file=sys.stderr,
)
if in_any_team:
na_state[gate]["declared"].append(u)
else:
na_state[gate]["rejected"].append(f"{u} (not-in-team)")
# Build per-gate reason string from declared users.
for gate in na_gates:
decl = na_state[gate]["declared"]
if decl:
reasons: list[str] = []
for u in decl:
r = latest_na_reason.get((u, gate), "")
if r:
reasons.append(f"{u}: {r}")
else:
reasons.append(u)
na_state[gate]["reason"] = "; ".join(reasons)
return na_state
# ---------------------------------------------------------------------------
# Gitea API client
# ---------------------------------------------------------------------------
@@ -698,6 +799,7 @@ def main(argv: list[str] | None = None) -> int:
numeric_aliases = {
int(it["numeric_alias"]): it["slug"] for it in items if it.get("numeric_alias")
}
na_gates: dict[str, dict[str, Any]] = cfg.get("n/a_gates") or {}
client = GiteaClient(args.gitea_host, token) if token else None
if not client:
@@ -717,6 +819,8 @@ def main(argv: list[str] | None = None) -> int:
print("::error::PR payload missing user.login or head.sha", file=sys.stderr)
return 1
target_url = f"https://{args.gitea_host}/{args.owner}/{args.repo}/pulls/{args.pr}"
comments = client.get_issue_comments(args.owner, args.repo, args.pr)
# Build team-membership probe closure that caches results per
@@ -774,6 +878,47 @@ def main(argv: list[str] | None = None) -> int:
ack_state = compute_ack_state(comments, author, items_by_slug, numeric_aliases, probe)
body_state = {it["slug"]: section_marker_present(body, it["pr_section_marker"]) for it in items}
# --- N/A gate state (RFC#324 §N/A follow-up) ---
na_state: dict[str, dict[str, Any]] = {}
if na_gates:
na_state = compute_na_state(
comments, author, na_gates, numeric_aliases,
probe, client, args.owner,
)
# Post N/A declarations status (read by review-check.sh).
na_satisfied = [g for g, s in na_state.items() if s["declared"]]
na_missing = [g for g, s in na_state.items() if not s["declared"]]
if na_satisfied:
na_desc = f"N/A: {', '.join(na_satisfied)}"
na_post_state = "success"
elif na_missing:
na_desc = f"awaiting /sop-n/a declaration for: {', '.join(na_missing)}"
na_post_state = "pending"
else:
# Configured but no declarations yet.
na_desc = "no /sop-n/a declarations yet"
na_post_state = "pending"
na_context = "sop-checklist / na-declarations (pull_request)"
print(f"::notice::na-declarations status: {na_post_state}{na_desc}")
if not args.dry_run:
client.post_status(
args.owner, args.repo, head_sha,
state=na_post_state, context=na_context,
description=na_desc,
target_url=target_url,
)
print(f"::notice::na-declarations status posted: {na_context}{na_post_state}")
# Log per-gate diagnostics.
for gate in na_gates:
s = na_state.get(gate, {})
if s.get("declared"):
print(f"::notice:: [PASS] gate={gate} — N/A declared by {','.join(s['declared'])}"
+ (f" ({s['reason']})" if s.get("reason") else ""))
else:
extra = f" — rejected: {', '.join(s.get('rejected', []))}" if s.get("rejected") else ""
print(f"::notice:: [WAIT] gate={gate} — no valid N/A declaration yet{extra}")
state, description = render_status(items, ack_state, body_state)
mode = get_tier_mode(pr, cfg)
if mode == "soft":
@@ -808,7 +953,6 @@ def main(argv: list[str] | None = None) -> int:
return 0 if state in ("success", "pending") else 1
return 0
target_url = f"https://{args.gitea_host}/{args.owner}/{args.repo}/pulls/{args.pr}"
client.post_status(
args.owner, args.repo, head_sha,
state=state, context=args.status_context,
@@ -85,7 +85,10 @@ def test_pr_needs_update_when_base_sha_absent_from_commits():
def test_merge_decision_requires_main_green_pr_green_and_current_base():
required = ["CI / all-required (pull_request)"]
main_status = {"state": "success", "statuses": []}
main_status = {
"state": "success",
"statuses": [{"context": "CI / all-required (push)", "status": "success"}],
}
pr_status = {
"state": "success",
"statuses": [{"context": "CI / all-required (pull_request)", "status": "success"}],
@@ -104,7 +107,10 @@ def test_merge_decision_requires_main_green_pr_green_and_current_base():
def test_merge_decision_updates_stale_pr_before_merge():
decision = mq.evaluate_merge_readiness(
main_status={"state": "success", "statuses": []},
main_status={
"state": "success",
"statuses": [{"context": "CI / all-required (push)", "status": "success"}],
},
pr_status={"state": "success", "statuses": [{"context": "CI / all-required (pull_request)", "status": "success"}]},
required_contexts=["CI / all-required (pull_request)"],
pr_has_current_base=False,
+13 -10
View File
@@ -304,6 +304,7 @@ jobs:
name: Canvas (Next.js)
needs: changes
runs-on: ubuntu-latest
timeout-minutes: 20
# Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12.
continue-on-error: false
defaults:
@@ -402,12 +403,13 @@ jobs:
canvas-deploy-reminder:
name: Canvas Deploy Reminder
runs-on: ubuntu-latest
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
continue-on-error: true
# mc#774 root-fix: added job-level `if:` so ci-required-drift.py's
# ci_job_names() detects this as github.ref-gated and skips it from F1.
# The step-level exit 0 handles the "not main push" case; the job-level
# `if:` makes the gating explicit so the drift script sees it.
# continue-on-error removed (was mc#774 mask): step exits 0 when not applicable.
needs: [changes, canvas-build]
# Keep the job itself always runnable. Gitea 1.22.6 leaves job-level
# event/ref `if:` gates as pending on PRs, which blocks the combined
# status even though this reminder is intentionally non-required.
if: ${{ github.ref == 'refs/heads/main' }}
steps:
- name: Write deploy reminder to step summary
env:
@@ -570,11 +572,11 @@ jobs:
# hourly if this list diverges from status_check_contexts or from
# audit-force-merge.yml's REQUIRED_CHECKS env (RFC §4 + §6).
#
# canvas-deploy-reminder is intentionally excluded from all-required.needs:
# it needs canvas-build, which is skipped on CI-only PRs (canvas=false).
# Including it in all-required.needs causes all-required to hang on
# every CI-only PR. Keep it runnable on PRs via its own
# `needs: [changes, canvas-build]` — the sentinel only aggregates the result.
# canvas-deploy-reminder IS now included in all-required.needs (mc#958 root-fix):
# added job-level `if: github.ref == 'refs/heads/main'` so ci-required-drift.py's
# ci_job_names() detects it as github.ref-gated and skips it from F1.
# The step-level `if: ... || REF_NAME != refs/heads/main` exits 0 when not main,
# so the job succeeds (not skipped) on non-main pushes — sentinel treats as green.
#
# Phase 3 (RFC #219 §1) safety: underlying build jobs carry
# continue-on-error: true so their failures are masked to null (2026-05-12: re-enabled mc#774 interim)
@@ -594,6 +596,7 @@ jobs:
- canvas-build
- shellcheck
- python-lint
- canvas-deploy-reminder
if: ${{ always() }}
steps:
- name: Assert every required dependency succeeded
+1 -1
View File
@@ -1 +1 @@
staging trigger
staging trigger 2026-05-14T17:35:02Z
+1
View File
@@ -0,0 +1 @@
trigger
+2 -2
View File
@@ -344,7 +344,7 @@ function ProviderPickerModal({
// wrapper's bounds instead of the viewport.
if (typeof document === "undefined") return null;
const allSaved = entries.length > 0 && entries.every((e) => e.saved);
const allSaved = entries.every((e) => e.saved);
const anySaving = entries.some((e) => e.saving);
const runtimeLabel = runtime
.replace(/[-_]/g, " ")
@@ -616,7 +616,7 @@ function AllKeysModal({
if (!open) return null;
if (typeof document === "undefined") return null;
const allSaved = entries.length > 0 && entries.every((e) => e.saved);
const allSaved = entries.every((e) => e.saved);
const anySaving = entries.some((e) => e.saving);
const runtimeLabel = runtime
.replace(/[-_]/g, " ")
+11 -2
View File
@@ -65,9 +65,18 @@ export function ThemeToggle({ className = "" }: { className?: string }) {
// Use direct-child query to scope strictly to this radiogroup's buttons
// and avoid accidentally focusing unrelated [role=radio] elements
// elsewhere in the DOM (e.g. React Flow canvas nodes).
// Guard: skip focus if the current target is no longer in the document
// (e.g. React StrictMode double-invokes handlers during re-render).
if (!e.currentTarget.isConnected) return;
const radiogroup = e.currentTarget.closest("[role=radiogroup]") as HTMLElement | null;
const btns = radiogroup?.querySelectorAll<HTMLButtonElement>("> [role=radio]");
btns?.[next]?.focus();
if (!radiogroup) return;
// Use children[] instead of querySelectorAll("> [role=radio]") to avoid
// jsdom's child-combinator selector parsing issues in test environments.
const btns = Array.from(radiogroup.children).filter(
(el): el is HTMLButtonElement =>
el.tagName === "BUTTON" && el.getAttribute("role") === "radio"
);
if (next < btns.length) btns[next]?.focus();
},
[]
);
+10 -7
View File
@@ -13,17 +13,20 @@ import { isExternalLikeRuntime } from "@/lib/externalRuntimes";
/** Descendant count for the "N sub" badge — children are first-class nodes
* rendered as full cards inside this one via React Flow's native parentId,
* so we don't need to subscribe to the actual child list here. */
* so we don't need to subscribe to the actual child list here.
* Selecting `nodes` stably avoids a new selector reference on every store
* update (React error #185 / Zustand + React 19 Object.is strictness). */
function useDescendantCount(nodeId: string): number {
return useCanvasStore(
useCallback((s) => countDescendants(nodeId, s.nodes), [nodeId])
);
const nodes = useCanvasStore((s) => s.nodes);
return useMemo(() => countDescendants(nodeId, nodes), [nodeId, nodes]);
}
/** Boolean flag used to drive min-size and NodeResizer dimensions.
* Selecting `nodes` stably avoids re-render loops (same issue as
* useDescendantCount). */
function useHasChildren(nodeId: string): boolean {
return useCanvasStore(
useCallback((s) => s.nodes.some((n) => n.data.parentId === nodeId), [nodeId])
);
const nodes = useCanvasStore((s) => s.nodes);
return useMemo(() => nodes.some((n) => n.data.parentId === nodeId), [nodes, nodeId]);
}
/** Eject/extract arrow icon — visually distinct from delete ✕ */
@@ -24,8 +24,12 @@ vi.mock("@/lib/theme-provider", () => ({
})),
}));
// Wrap cleanup in act() so any pending React state updates (e.g. from
// keyDown handlers that call setTheme) flush before DOM unmount. Without
// this, cleanup() can race against pending renders and cause INDEX_SIZE_ERR
// when the handleKeyDown callback tries to query the DOM mid-teardown.
afterEach(() => {
cleanup();
act(() => { cleanup(); });
vi.clearAllMocks();
});
@@ -146,7 +150,7 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
const radios = screen.getAllByRole("radio");
// dark (index 2) is current; ArrowRight should wrap to light (index 0)
act(() => { radios[2].focus(); });
fireEvent.keyDown(radios[2], { key: "ArrowRight" });
act(() => { fireEvent.keyDown(radios[2], { key: "ArrowRight" }); });
expect(mockSetTheme).toHaveBeenCalledWith("light");
});
@@ -160,7 +164,7 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
const radios = screen.getAllByRole("radio");
// light (index 0) is current; ArrowLeft should go to dark (index 2)
act(() => { radios[0].focus(); });
fireEvent.keyDown(radios[0], { key: "ArrowLeft" });
act(() => { fireEvent.keyDown(radios[0], { key: "ArrowLeft" }); });
expect(mockSetTheme).toHaveBeenCalledWith("dark");
});
@@ -174,7 +178,7 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
const radios = screen.getAllByRole("radio");
// light (index 0) is current; ArrowDown should go to system (index 1)
act(() => { radios[0].focus(); });
fireEvent.keyDown(radios[0], { key: "ArrowDown" });
act(() => { fireEvent.keyDown(radios[0], { key: "ArrowDown" }); });
expect(mockSetTheme).toHaveBeenCalledWith("system");
});
@@ -187,7 +191,7 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
render(<ThemeToggle />);
const radios = screen.getAllByRole("radio");
act(() => { radios[2].focus(); });
fireEvent.keyDown(radios[2], { key: "Home" });
act(() => { fireEvent.keyDown(radios[2], { key: "Home" }); });
expect(mockSetTheme).toHaveBeenCalledWith("light");
});
@@ -200,14 +204,14 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
render(<ThemeToggle />);
const radios = screen.getAllByRole("radio");
act(() => { radios[0].focus(); });
fireEvent.keyDown(radios[0], { key: "End" });
act(() => { fireEvent.keyDown(radios[0], { key: "End" }); });
expect(mockSetTheme).toHaveBeenCalledWith("dark");
});
it("does nothing on unrelated keys", () => {
render(<ThemeToggle />);
const radios = screen.getAllByRole("radio");
fireEvent.keyDown(radios[0], { key: "Enter" });
act(() => { fireEvent.keyDown(radios[0], { key: "Enter" }); });
expect(mockSetTheme).not.toHaveBeenCalled();
});
});
@@ -24,16 +24,20 @@ import {
*/
export function DropTargetBadge() {
const dragOverNodeId = useCanvasStore((s) => s.dragOverNodeId);
const targetName = useCanvasStore((s) => {
if (!s.dragOverNodeId) return null;
const n = s.nodes.find((nn) => nn.id === s.dragOverNodeId);
// Select nodes stably first — deriving targetName and childCount inside
// the same selector creates a new return value on every store mutation
// even when neither has changed (React error #185 / Zustand Object.is).
const nodes = useCanvasStore((s) => s.nodes);
const targetName = (() => {
if (!dragOverNodeId) return null;
const n = nodes.find((nn) => nn.id === dragOverNodeId);
return (n?.data as WorkspaceNodeData | undefined)?.name ?? null;
});
const childCount = useCanvasStore((s) =>
!s.dragOverNodeId
})();
const childCount = (() =>
!dragOverNodeId
? 0
: s.nodes.filter((n) => n.parentId === s.dragOverNodeId).length,
);
: nodes.filter((n) => n.parentId === dragOverNodeId).length
)();
const { getInternalNode, flowToScreenPosition } = useReactFlow();
if (!dragOverNodeId || !targetName) return null;
const internal = getInternalNode(dragOverNodeId);
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useReactFlow } from "@xyflow/react";
import { useCanvasStore } from "@/store/canvas";
import { appendClass, removeClass } from "@/store/classNames";
@@ -153,10 +153,17 @@ export function useCanvasViewport() {
// fit, the user has to manually pan + zoom to find what they just
// created. Only fires when TRANSITIONING from some-provisioning to
// zero-provisioning — not on every re-render.
const provisioningCount = useCanvasStore(
(s) => s.nodes.filter((n) => n.data.status === "provisioning").length,
//
// Selecting `nodes` stably (array reference) avoids the
// `.filter().length` anti-pattern which creates a new number on every
// store update and breaks the wasProvisioning/hasProvisioning
// transition detection (React error #185 / Zustand + React 19).
const nodes = useCanvasStore((s) => s.nodes);
const provisioningCount = useMemo(
() => nodes.filter((n) => n.data.status === "provisioning").length,
[nodes],
);
const nodeCount = useCanvasStore((s) => s.nodes.length);
const nodeCount = nodes.length;
useEffect(() => {
const hasProvisioning = provisioningCount > 0;
+5 -2
View File
@@ -5,7 +5,7 @@
// that the desktop ChatTab uses, but with a slimmer surface: no
// attachments, no A2A topology overlay, no conversation tracing.
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { api } from "@/lib/api";
import { useCanvasStore } from "@/store/canvas";
@@ -49,7 +49,10 @@ export function MobileChat({
onBack: () => void;
}) {
const p = usePalette(dark);
const node = useCanvasStore((s) => s.nodes.find((n) => n.id === agentId));
// Selecting `nodes` stably avoids the `.find()` anti-pattern that
// creates a new return value on every store update (React error #185).
const nodes = useCanvasStore((s) => s.nodes);
const node = useMemo(() => nodes.find((n) => n.id === agentId), [nodes, agentId]);
// Bootstrap from the canvas store's per-workspace message buffer so the
// user sees their prior thread on entry. The store is updated by the
// socket → ChatTab flows the desktop runs; on mobile we read from the
@@ -2,7 +2,7 @@
// 03 · Agent detail — pills + tabbed content (Overview/Activity/Config/Memory).
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { api } from "@/lib/api";
import { useCanvasStore } from "@/store/canvas";
@@ -32,7 +32,10 @@ export function MobileDetail({
onChat: () => void;
}) {
const p = usePalette(dark);
const node = useCanvasStore((s) => s.nodes.find((n) => n.id === agentId));
// Selecting `nodes` stably avoids the `.find()` anti-pattern that
// creates a new return value on every store update (React error #185).
const nodes = useCanvasStore((s) => s.nodes);
const node = useMemo(() => nodes.find((n) => n.id === agentId), [nodes, agentId]);
const [tab, setTab] = useState<TabId>("overview");
if (!node) {
+21 -21
View File
@@ -97,28 +97,28 @@ const maxProxyResponseBody = 10 << 20
//
// Timeout model — three independent budgets, none of which gets in each other's way:
//
// 1. Client.Timeout — DELIBERATELY UNSET. Client.Timeout is a hard wall on
// the entire request including streamed body reads, and would pre-empt
// legitimate slow cold-start flows (Claude Code first-token over OAuth
// can take 30-60s on boot; long-running agent synthesis can stream
// tokens for minutes). Total-request budget is enforced per-request
// via context deadline (canvas = idle-only, agent-to-agent = 30 min ceiling).
// 1. Client.Timeout — DELIBERATELY UNSET. Client.Timeout is a hard wall on
// the entire request including streamed body reads, and would pre-empt
// legitimate slow cold-start flows (Claude Code first-token over OAuth
// can take 30-60s on boot; long-running agent synthesis can stream
// tokens for minutes). Total-request budget is enforced per-request
// via context deadline (canvas = idle-only, agent-to-agent = 30 min ceiling).
//
// 2. Transport.DialContext — 10s connect timeout. When a workspace's EC2
// black-holes TCP connects (instance terminated mid-flight, security group
// flipped, NACL bug), the OS default is 75s on Linux / 21s on macOS — long
// enough that Cloudflare's ~100s edge timeout can fire first and surface
// a generic 502 page to canvas. 10s is well above realistic intra-region
// latencies and well below CF's edge timeout.
// 2. Transport.DialContext — 10s connect timeout. When a workspace's EC2
// black-holes TCP connects (instance terminated mid-flight, security group
// flipped, NACL bug), the OS default is 75s on Linux / 21s on macOS — long
// enough that Cloudflare's ~100s edge timeout can fire first and surface
// a generic 502 page to canvas. 10s is well above realistic intra-region
// latencies and well below CF's edge timeout.
//
// 3. Transport.ResponseHeaderTimeout — 180s default. From request-body-end
// to response-headers-start. Configurable via
// A2A_PROXY_RESPONSE_HEADER_TIMEOUT (envx.Duration). Covers cold-start
// first-byte (30-60s OAuth flow above) with enough room for Opus agent
// turns (big context + internal delegate_task round-trips routinely exceed
// the old 60s ceiling). Body streaming after headers is governed by the
// per-request context deadline, NOT this timeout — so multi-minute agent
// responses still work fine.
// 3. Transport.ResponseHeaderTimeout — 180s default. From request-body-end
// to response-headers-start. Configurable via
// A2A_PROXY_RESPONSE_HEADER_TIMEOUT (envx.Duration). Covers cold-start
// first-byte (30-60s OAuth flow above) with enough room for Opus agent
// turns (big context + internal delegate_task round-trips routinely exceed
// the old 60s ceiling). Body streaming after headers is governed by the
// per-request context deadline, NOT this timeout — so multi-minute agent
// responses still work fine.
//
// The point of (2) and (3) is to surface a *structured* 503 from
// handleA2ADispatchError when the workspace agent is unreachable, so canvas
@@ -645,7 +645,7 @@ func (h *WorkspaceHandler) resolveAgentURL(ctx context.Context, workspaceID stri
// the caller can retry once the workspace is back online (~10s).
if status == "hibernated" {
log.Printf("ProxyA2A: waking hibernated workspace %s", workspaceID)
go h.RestartByID(workspaceID)
h.goAsync(func() { h.RestartByID(workspaceID) })
return "", &proxyA2AError{
Status: http.StatusServiceUnavailable,
Headers: map[string]string{"Retry-After": "15"},
@@ -194,7 +194,7 @@ func (h *WorkspaceHandler) maybeMarkContainerDead(ctx context.Context, workspace
}
db.ClearWorkspaceKeys(ctx, workspaceID)
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceOffline), workspaceID, map[string]interface{}{})
go h.RestartByID(workspaceID)
h.goAsync(func() { h.RestartByID(workspaceID) })
return true
}
@@ -241,7 +241,7 @@ func (h *WorkspaceHandler) preflightContainerHealth(ctx context.Context, workspa
}
db.ClearWorkspaceKeys(ctx, workspaceID)
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceOffline), workspaceID, map[string]interface{}{})
go h.RestartByID(workspaceID)
h.goAsync(func() { h.RestartByID(workspaceID) })
return &proxyA2AError{
Status: http.StatusServiceUnavailable,
Response: gin.H{
@@ -262,8 +262,8 @@ func (h *WorkspaceHandler) logA2AFailure(ctx context.Context, workspaceID, calle
errWsName = workspaceID
}
summary := "A2A request to " + errWsName + " failed: " + errMsg
go func(parent context.Context) {
logCtx, cancel := context.WithTimeout(context.WithoutCancel(parent), 30*time.Second)
h.goAsync(func() {
logCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
defer cancel()
LogActivity(logCtx, h.broadcaster, ActivityParams{
WorkspaceID: workspaceID,
@@ -277,7 +277,7 @@ func (h *WorkspaceHandler) logA2AFailure(ctx context.Context, workspaceID, calle
Status: "error",
ErrorDetail: &errMsg,
})
}(ctx)
})
}
// logA2ASuccess records a successful A2A round-trip and (for canvas-initiated
@@ -298,19 +298,19 @@ func (h *WorkspaceHandler) logA2ASuccess(ctx context.Context, workspaceID, calle
// silent workspaces. Only update when callerID is a real workspace (not
// canvas, not a system caller) and the target returned 2xx/3xx.
if callerID != "" && !isSystemCaller(callerID) && statusCode < 400 {
go func() {
h.goAsync(func() {
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if _, err := db.DB.ExecContext(bgCtx,
`UPDATE workspaces SET last_outbound_at = NOW() WHERE id = $1`, callerID); err != nil {
log.Printf("last_outbound_at update failed for %s: %v", callerID, err)
}
}()
})
}
summary := a2aMethod + " → " + wsNameForLog
toolTrace := extractToolTrace(respBody)
go func(parent context.Context) {
logCtx, cancel := context.WithTimeout(context.WithoutCancel(parent), 30*time.Second)
h.goAsync(func() {
logCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
defer cancel()
LogActivity(logCtx, h.broadcaster, ActivityParams{
WorkspaceID: workspaceID,
@@ -325,7 +325,7 @@ func (h *WorkspaceHandler) logA2ASuccess(ctx context.Context, workspaceID, calle
DurationMs: &durationMs,
Status: logStatus,
})
}(ctx)
})
if callerID == "" && statusCode < 400 {
h.broadcaster.BroadcastOnly(workspaceID, string(events.EventA2AResponse), map[string]interface{}{
@@ -510,8 +510,8 @@ func (h *WorkspaceHandler) logA2AReceiveQueued(ctx context.Context, workspaceID,
wsName = workspaceID
}
summary := a2aMethod + " → " + wsName + " (queued for poll)"
go func(parent context.Context) {
logCtx, cancel := context.WithTimeout(context.WithoutCancel(parent), 30*time.Second)
h.goAsync(func() {
logCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
defer cancel()
LogActivity(logCtx, h.broadcaster, ActivityParams{
WorkspaceID: workspaceID,
@@ -523,7 +523,7 @@ func (h *WorkspaceHandler) logA2AReceiveQueued(ctx context.Context, workspaceID,
RequestBody: json.RawMessage(body),
Status: "ok",
})
}(ctx)
})
}
// readUsageMap extracts input_tokens / output_tokens from the "usage" key of m.
@@ -54,6 +54,7 @@ func TestPreflight_ContainerRunning_ReturnsNil(t *testing.T) {
_ = setupTestDB(t)
stub := &preflightLocalProv{running: true, err: nil}
h := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
waitForHandlerAsyncBeforeDBCleanup(t, h)
h.provisioner = stub
if err := h.preflightContainerHealth(context.Background(), "ws-running-123"); err != nil {
@@ -186,8 +187,8 @@ func TestProxyA2A_Preflight_RoutesThroughProvisionerSSOT(t *testing.T) {
}
var (
callsIsRunning bool
callsContainerInspectRaw bool
callsIsRunning bool
callsContainerInspectRaw bool
callsRunningContainerNameDirect bool
)
ast.Inspect(fn.Body, func(n ast.Node) bool {
@@ -262,6 +262,7 @@ func TestProxyA2A_Upstream502_TriggersContainerDeadCheck(t *testing.T) {
allowLoopbackForTest(t)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
waitForHandlerAsyncBeforeDBCleanup(t, handler)
cp := &fakeCPProv{running: false}
handler.SetCPProvisioner(cp)
@@ -324,6 +325,7 @@ func TestProxyA2A_Upstream502_AliveAgent_PropagatesAsIs(t *testing.T) {
allowLoopbackForTest(t)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
waitForHandlerAsyncBeforeDBCleanup(t, handler)
cp := &fakeCPProv{running: true}
handler.SetCPProvisioner(cp)
@@ -513,6 +515,7 @@ func TestProxyA2A_AllowedSelf_SkipsAccessCheck(t *testing.T) {
allowLoopbackForTest(t)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
waitForHandlerAsyncBeforeDBCleanup(t, handler)
agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
@@ -661,18 +664,18 @@ func TestProxyA2A_CallerIDDerivedFromBearer(t *testing.T) {
// (column order: workspace_id, activity_type, source_id, target_id, ...)
mock.ExpectExec("INSERT INTO activity_logs").
WithArgs(
"ws-target", // $1 workspace_id
"a2a_receive", // $2 activity_type
sqlmock.AnyArg(), // $3 source_id — *string("ws-caller"), checked below
sqlmock.AnyArg(), // $4 target_id
sqlmock.AnyArg(), // $5 method
sqlmock.AnyArg(), // $6 summary
sqlmock.AnyArg(), // $7 request_body
sqlmock.AnyArg(), // $8 response_body
sqlmock.AnyArg(), // $9 tool_trace
sqlmock.AnyArg(), // $10 duration_ms
sqlmock.AnyArg(), // $11 status
sqlmock.AnyArg(), // $12 error_detail
"ws-target", // $1 workspace_id
"a2a_receive", // $2 activity_type
sqlmock.AnyArg(), // $3 source_id — *string("ws-caller"), checked below
sqlmock.AnyArg(), // $4 target_id
sqlmock.AnyArg(), // $5 method
sqlmock.AnyArg(), // $6 summary
sqlmock.AnyArg(), // $7 request_body
sqlmock.AnyArg(), // $8 response_body
sqlmock.AnyArg(), // $9 tool_trace
sqlmock.AnyArg(), // $10 duration_ms
sqlmock.AnyArg(), // $11 status
sqlmock.AnyArg(), // $12 error_detail
).
WillReturnResult(sqlmock.NewResult(0, 1))
@@ -1716,7 +1719,6 @@ func TestDispatchA2A_RejectsUnsafeURL(t *testing.T) {
}
}
// --- handleA2ADispatchError ---
func TestHandleA2ADispatchError_ContextDeadline(t *testing.T) {
@@ -1803,6 +1805,7 @@ func TestMaybeMarkContainerDead_CPOnly_NotRunning(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
waitForHandlerAsyncBeforeDBCleanup(t, handler)
cp := &fakeCPProv{running: false}
handler.SetCPProvisioner(cp)
@@ -1955,6 +1958,7 @@ func TestLogA2AFailure_Smoke(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
waitForHandlerAsyncBeforeDBCleanup(t, handler)
// Sync workspace-name lookup (called in the caller goroutine).
mock.ExpectQuery(`SELECT name FROM workspaces WHERE id =`).
@@ -1973,6 +1977,7 @@ func TestLogA2AFailure_EmptyNameFallback(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
waitForHandlerAsyncBeforeDBCleanup(t, handler)
// Empty name from DB → summary uses the workspaceID as the name.
mock.ExpectQuery(`SELECT name FROM workspaces WHERE id =`).
@@ -1989,6 +1994,7 @@ func TestLogA2ASuccess_Smoke(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
waitForHandlerAsyncBeforeDBCleanup(t, handler)
mock.ExpectQuery(`SELECT name FROM workspaces WHERE id =`).
WithArgs("ws-ok").
@@ -2005,6 +2011,7 @@ func TestLogA2ASuccess_ErrorStatus(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
waitForHandlerAsyncBeforeDBCleanup(t, handler)
mock.ExpectQuery(`SELECT name FROM workspaces WHERE id =`).
WithArgs("ws-err").
@@ -26,6 +26,10 @@ import (
// setupTestDBForQueueTests creates a sqlmock DB using QueryMatcherEqual (exact
// string matching) so that ExpectQuery/ExpectExec patterns are compared verbatim.
// Uses the same global db.DB as setupTestDB so the handler can use it.
//
// IMPORTANT: db.DB is saved before assignment and restored via t.Cleanup so
// that tests running after this one are not polluted by a closed mock.
// Same fix as setupTestDB (handlers_test.go); same root cause as mc#975.
func setupTestDBForQueueTests(t *testing.T) sqlmock.Sqlmock {
t.Helper()
mockDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
@@ -81,6 +85,54 @@ func TestExtractIdempotencyKey_emptyOnMissing(t *testing.T) {
}
}
// ──────────────────────────────────────────────────────────────────────────────
// extractExpiresInSeconds
// ──────────────────────────────────────────────────────────────────────────────
func TestExtractExpiresInSeconds_valid(t *testing.T) {
cases := []struct {
name string
body string
want int
}{
{"positive int", `{"params":{"expires_in_seconds":30}}`, 30},
{"zero", `{"params":{"expires_in_seconds":0}}`, 0},
{"large TTL", `{"params":{"expires_in_seconds":3600}}`, 3600},
{"nested message — not affected", `{"params":{"message":{"role":"user"},"expires_in_seconds":60}}`, 60},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := extractExpiresInSeconds([]byte(tc.body)); got != tc.want {
t.Errorf("extractExpiresInSeconds = %d, want %d", got, tc.want)
}
})
}
}
func TestExtractExpiresInSeconds_invalidOrMissing(t *testing.T) {
cases := []struct {
name string
body string
want int
}{
{"negative → 0", `{"params":{"expires_in_seconds":-5}}`, 0},
{"missing expires_in_seconds", `{"params":{"message":{"role":"user"}}}`, 0},
{"no params at all", `{"method":"message/send"}`, 0},
{"malformed JSON", `not json`, 0},
{"empty body", ``, 0},
{"null value", `{"params":{"expires_in_seconds":null}}`, 0},
{"string value", `{"params":{"expires_in_seconds":"30"}}`, 0},
{"float value", `{"params":{"expires_in_seconds":30.5}}`, 30},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := extractExpiresInSeconds([]byte(tc.body)); got != tc.want {
t.Errorf("extractExpiresInSeconds(%q) = %d, want %d", tc.body, got, tc.want)
}
})
}
}
func TestExtractDelegationIDFromBody(t *testing.T) {
cases := []struct {
name string
@@ -2,6 +2,7 @@ package handlers
import (
"context"
"database/sql"
"encoding/json"
"log"
"net/http"
@@ -698,7 +699,8 @@ func (h *DelegationHandler) listDelegationsFromLedger(ctx context.Context, works
var result []map[string]interface{}
for rows.Next() {
var delegationID, callerID, calleeID, taskPreview, status, resultPreview, errorDetail string
var delegationID, callerID, calleeID, taskPreview, status string
var resultPreview, errorDetail sql.NullString
var lastHeartbeat, deadline, createdAt, updatedAt *time.Time
if err := rows.Scan(
&delegationID, &callerID, &calleeID, &taskPreview,
@@ -717,11 +719,11 @@ func (h *DelegationHandler) listDelegationsFromLedger(ctx context.Context, works
"updated_at": updatedAt,
"_ledger": true, // marker so callers know this row is from the ledger
}
if resultPreview != "" {
entry["response_preview"] = textutil.TruncateBytes(resultPreview, 300)
if resultPreview.Valid && resultPreview.String != "" {
entry["response_preview"] = textutil.TruncateBytes(resultPreview.String, 300)
}
if errorDetail != "" {
entry["error"] = errorDetail
if errorDetail.Valid && errorDetail.String != "" {
entry["error"] = errorDetail.String
}
if lastHeartbeat != nil {
entry["last_heartbeat"] = lastHeartbeat
@@ -145,6 +145,54 @@ func TestListDelegationsFromLedger_MultipleRows(t *testing.T) {
}
}
func TestListDelegationsFromLedger_NullsOmitted(t *testing.T) {
// last_heartbeat, deadline, result_preview, error_detail are all NULL.
// Handler must not panic and must omit those keys from the map.
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
prevDB := db.DB
db.DB = mockDB
t.Cleanup(func() { mockDB.Close(); db.DB = prevDB })
now := time.Now()
rows := sqlmock.NewRows([]string{
"delegation_id", "caller_id", "callee_id", "task_preview",
"status", "result_preview", "error_detail",
"last_heartbeat", "deadline", "created_at", "updated_at",
}).
AddRow("del-1", "ws-1", "ws-2", "task", "queued", nil, nil, nil, nil, now, now)
mock.ExpectQuery("SELECT .+ FROM delegations").
WithArgs("ws-1").
WillReturnRows(rows)
broadcaster := newTestBroadcaster()
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
dh := NewDelegationHandler(wh, broadcaster)
got := dh.listDelegationsFromLedger(context.Background(), "ws-1")
if len(got) != 1 {
t.Fatalf("expected 1 entry, got %d", len(got))
}
e := got[0]
if _, ok := e["last_heartbeat"]; ok {
t.Error("last_heartbeat should be absent when NULL")
}
if _, ok := e["deadline"]; ok {
t.Error("deadline should be absent when NULL")
}
if _, ok := e["response_preview"]; ok {
t.Error("response_preview should be absent when NULL result_preview")
}
if _, ok := e["error"]; ok {
t.Error("error should be absent when NULL error_detail")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations: %v", err)
}
}
func TestListDelegationsFromLedger_QueryError(t *testing.T) {
// Query failure returns nil — graceful fallback, no panic.
mockDB, mock, err := sqlmock.New()
@@ -438,10 +486,3 @@ func TestListDelegationsFromActivityLogs_RowsErr(t *testing.T) {
t.Errorf("sqlmock expectations: %v", err)
}
}
// TestListDelegationsFromActivityLogs_ScanErrorSkipped is removed.
//
// Same reason as TestListDelegationsFromLedger_ScanError: Go 1.25 causes
// sqlmock.NewRows([]string{}).AddRow(...) to panic in test SETUP. The handler
// has no recover(), so a scan panic would crash the process — the correct
// behaviour. Real-DB integration tests cover this path.
@@ -29,6 +29,11 @@ func init() {
// setupTestDB creates a sqlmock DB and assigns it to the global db.DB.
// It also disables the SSRF URL check so that httptest.NewServer loopback
// URLs and fake hostnames (*.example) used in tests don't trigger rejections.
//
// IMPORTANT: db.DB is saved before assignment and restored via t.Cleanup so
// that tests running after this one are not polluted by a closed mock.
// This is the single root cause of the systemic CI/Platform (Go) failures on
// main HEAD 8026f020 (mc#975).
func setupTestDB(t *testing.T) sqlmock.Sqlmock {
t.Helper()
mockDB, mock, err := sqlmock.New()
@@ -57,6 +62,11 @@ func setupTestDB(t *testing.T) sqlmock.Sqlmock {
return mock
}
func waitForHandlerAsyncBeforeDBCleanup(t *testing.T, h *WorkspaceHandler) {
t.Helper()
t.Cleanup(h.waitAsyncForTest)
}
// setupTestRedis creates a miniredis instance and assigns it to the global db.RDB.
func setupTestRedis(t *testing.T) *miniredis.Miniredis {
t.Helper()
@@ -356,6 +366,11 @@ func TestWorkspaceCreate(t *testing.T) {
}
func TestBuildProvisionerConfig_IncludesAwarenessSettings(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectQuery(`SELECT digest FROM runtime_image_pins`).
WithArgs("claude-code").
WillReturnError(sql.ErrNoRows)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", "/tmp/configs")
File diff suppressed because it is too large Load Diff
+56
View File
@@ -271,6 +271,62 @@ func (e EnvRequirement) IsSatisfied(configured map[string]struct{}) bool {
return false
}
// perWorkspaceUnsatisfied records a single unsatisfied RequiredEnv for a
// specific workspace during org import preflight.
type perWorkspaceUnsatisfied struct {
Workspace string
FilesDir string
Unsatisfied EnvRequirement
}
// collectPerWorkspaceUnsatisfied walks the workspace tree and returns every
// RequiredEnv that is neither in `configured` (global secrets) nor resolvable
// from the org root or workspace-level .env file. An empty orgBaseDir skips
// the .env walk so all requirements appear unsatisfied (used by tests to
// isolate the global-only path).
func collectPerWorkspaceUnsatisfied(
workspaces []OrgWorkspace,
orgBaseDir string,
configured map[string]struct{},
) []perWorkspaceUnsatisfied {
var result []perWorkspaceUnsatisfied
for _, ws := range workspaces {
result = append(result, checkWorkspaceRequiredEnv(ws, orgBaseDir, configured)...)
}
return result
}
func checkWorkspaceRequiredEnv(
ws OrgWorkspace,
orgBaseDir string,
configured map[string]struct{},
) []perWorkspaceUnsatisfied {
var result []perWorkspaceUnsatisfied
// Merge in .env vars from the org root and the workspace-specific dir.
// Workspace-level vars override org-root vars, just as loadWorkspaceEnv
// implements: org root first, then ws dir on top.
if orgBaseDir != "" {
wsEnv := loadWorkspaceEnv(orgBaseDir, ws.FilesDir)
for k, v := range wsEnv {
configured[k] = struct{}{}
_ = v // value only used for merging into configured map
}
}
for _, req := range ws.RequiredEnv {
if !req.IsSatisfied(configured) {
result = append(result, perWorkspaceUnsatisfied{
Workspace: ws.Name,
FilesDir: ws.FilesDir,
Unsatisfied: req,
})
}
}
for _, child := range ws.Children {
result = append(result, checkWorkspaceRequiredEnv(child, orgBaseDir, configured)...)
}
return result
}
// UnmarshalYAML accepts either a scalar (string → single) or a map
// with an `any_of` list (→ group).
func (e *EnvRequirement) UnmarshalYAML(value *yaml.Node) error {
@@ -64,7 +64,9 @@ func resolvePromptRef(inline, fileRef, orgBaseDir, filesDir string) (string, err
// envVarRefPattern matches actual ${VAR} or $VAR references (not literal $).
// Used to detect unresolved placeholders without false positives like "$5".
var envVarRefPattern = regexp.MustCompile(`\$\{?[A-Za-z_][A-Za-z0-9_]*\}?`)
// Requires [a-zA-Z_] as the first char after $ so $100 stays literal.
// Two capture groups: (1) ${VAR} form, (2) $VAR form.
var envVarRefPattern = regexp.MustCompile(`\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}|\$([a-zA-Z_][a-zA-Z0-9_]*)`)
// hasUnresolvedVarRef returns true if the original string had a ${VAR} or $VAR
// reference that the expanded string didn't fully replace (i.e. the var was unset).
@@ -349,7 +351,11 @@ func resolveInsideRoot(root, userPath string) (string, error) {
return "", fmt.Errorf("root abs: %w", err)
}
joined := filepath.Join(absRoot, userPath)
absJoined, err := filepath.Abs(joined)
// filepath.Join preserves "." components when root is absolute; clean
// them before computing the final absolute path so "./subdir/./file.txt"
// resolves to root/subdir/file.txt (not root/./subdir/./file.txt).
cleaned := filepath.Clean(joined)
absJoined, err := filepath.Abs(cleaned)
if err != nil {
return "", fmt.Errorf("joined abs: %w", err)
}
@@ -104,8 +104,8 @@ func TestHasUnresolvedVarRef_Resolved(t *testing.T) {
// documents this design choice; callers who need empty=resolved should
// pre-process the output before calling hasUnresolvedVarRef.
{"${VAR}", "", true},
{"${VAR}", "value", false}, // var replaced
{"$VAR", "value", false}, // bare var replaced
{"${VAR}", "value", false}, // var replaced
{"$VAR", "value", false}, // bare var replaced
{"prefix${VAR}suffix", "prefixvaluesuffix", false},
{"${A}${B}", "ab", false},
// FOO=FOO and BAR=BAR — both vars found and replaced. Expanded output
@@ -125,14 +125,14 @@ func TestHasUnresolvedVarRef_Resolved(t *testing.T) {
func TestHasUnresolvedVarRef_Unresolved(t *testing.T) {
// Expansion left the refs intact → unresolved.
cases := []struct {
orig string
orig string
expanded string
}{
{"${VAR}", "${VAR}"}, // untouched
{"$VAR", "$VAR"}, // bare untouched
{"${VAR}", "${VAR}"}, // untouched
{"$VAR", "$VAR"}, // bare untouched
{"prefix${VAR}suffix", "prefix${VAR}suffix"},
{"${A}${B}", "${A}${B}"}, // both unresolved
{"${FOO}", ""}, // empty result with var ref in original
{"${A}${B}", "${A}${B}"}, // both unresolved
{"${FOO}", ""}, // empty result with var ref in original
}
for _, tc := range cases {
t.Run(tc.orig, func(t *testing.T) {
@@ -205,8 +205,8 @@ func TestMergeCategoryRouting_WorkspaceOverrides(t *testing.T) {
"ui": {"Frontend Engineer"},
}
ws := map[string][]string{
"security": {"SRE Team"}, // narrows
"ui": {}, // drops
"security": {"SRE Team"}, // narrows
"ui": {}, // drops
"infra": {"Platform Team"}, // adds
}
r := mergeCategoryRouting(defaults, ws)
@@ -287,7 +287,7 @@ func TestRenderCategoryRoutingYAML_StableOrdering(t *testing.T) {
if ai <= 0 || zi <= 0 || mi <= 0 {
t.Fatalf("could not locate all keys in output: %s", out)
}
if !(ai < mi && mi < zi) {
if ai >= mi || mi >= zi {
t.Errorf("keys not sorted: alpha=%d middle=%d zebra=%d, output:\n%s", ai, mi, zi, out)
}
}
@@ -462,8 +462,45 @@ func TestExpandWithEnv_LiteralDollar(t *testing.T) {
func TestExpandWithEnv_PartiallyPresent(t *testing.T) {
env := map[string]string{"SET": "yes"}
result := expandWithEnv("${SET} and ${NOT_SET}", env)
// ${SET} resolved; ${NOT_SET} -> "" via empty fallback.
assert.Equal(t, "yes and ", result)
assert.Equal(t, "yes and ${NOT_SET}", result)
}
func TestExpandWithEnv_EmbeddedMissingProcessEnvStaysLiteral(t *testing.T) {
t.Setenv("MOL_TEST_EMBEDDED_MISSING", "")
result := expandWithEnv("prefix/${MOL_TEST_EMBEDDED_MISSING}/suffix", map[string]string{})
assert.Equal(t, "prefix/${MOL_TEST_EMBEDDED_MISSING}/suffix", result)
}
// POSIX identifier guard regression tests (CWE-78 fix).
// Keys not starting with [a-zA-Z_] must not be looked up in env or os.Getenv.
func TestExpandWithEnv_DigitPrefix_NotExpanded(t *testing.T) {
// ${0}, ${5}, ${1VAR} — numeric prefix → not a valid shell identifier.
// Guard must return "$0", "$5", "$1VAR" literally; no env lookup.
cases := []struct {
input string
want string
}{
{"${0}", "$0"},
{"${5}", "$5"},
{"${1VAR}", "$1VAR"},
{"prefix ${0} suffix", "prefix $0 suffix"},
{"$0", "$0"},
{"$5", "$5"},
{"HOME=${HOME}", "HOME=${HOME}"}, // HOME is valid but embedded in larger string
}
for _, tc := range cases {
t.Run(tc.input, func(t *testing.T) {
got := expandWithEnv(tc.input, map[string]string{})
assert.Equal(t, tc.want, got)
})
}
}
func TestExpandWithEnv_EmptyKey_ReturnsDollar(t *testing.T) {
// ${} → "$" (empty key, guard returns "$")
result := expandWithEnv("value=${}", map[string]string{})
assert.Equal(t, "value=$", result)
}
// mergeCategoryRouting tests — unions defaults with per-workspace routing.
@@ -545,8 +582,8 @@ func TestRenderCategoryRoutingYAML_SingleCategory(t *testing.T) {
func TestRenderCategoryRoutingYAML_MultipleCategoriesSorted(t *testing.T) {
routing := map[string][]string{
"zebra": {"RoleZ"},
"alpha": {"RoleA"},
"zebra": {"RoleZ"},
"alpha": {"RoleA"},
"middleware": {"RoleM"},
}
result, err := renderCategoryRoutingYAML(routing)
@@ -93,7 +93,7 @@ func TestResolveInsideRoot_DotPathComponent(t *testing.T) {
if err != nil {
t.Fatalf("dot path component: unexpected error: %v", err)
}
if got[len(got)-14:] != "/subdir/file.txt" {
if !strings.HasSuffix(got, "/subdir/file.txt") {
t.Errorf("dot path component: got %q, want suffix /subdir/file.txt", got)
}
}
@@ -952,54 +952,6 @@ type PerWorkspaceUnsatisfied struct {
// collectPerWorkspaceUnsatisfied recursively walks workspaces and returns
// per-workspace RequiredEnv entries that are not covered by (a) a global
// secret key or (b) a key present in the workspace's .env file(s) (org root
// .env + per-workspace <files_dir>/.env). This complements
// collectOrgEnv + loadConfiguredGlobalSecretKeys, which together only
// validate global-level RequiredEnv against global_secrets. The .env
// lookup mirrors the runtime resolution in createWorkspaceTree so that
// the preflight result matches what the container actually receives at
// start time.
func collectPerWorkspaceUnsatisfied(workspaces []OrgWorkspace, orgBaseDir string, globalSecrets map[string]struct{}) []PerWorkspaceUnsatisfied {
var out []PerWorkspaceUnsatisfied
var walk func([]OrgWorkspace)
walk = func(wsList []OrgWorkspace) {
for _, ws := range wsList {
// Build the set of keys available to this workspace from .env.
// This is the same three-source stack that createWorkspaceTree
// injects into the container:
// 1. Org root .env (parseEnvFile, no filesDir)
// 2. Workspace <files_dir>/.env (if filesDir is set)
// 3. Persona bootstrap env (MOLECULE_PERSONA_ROOT/<filesDir>/env)
// Items 1+2 are on-disk and testable; item 3 is host-only and
// skipped here (persona env does NOT satisfy required_env —
// it carries identity tokens, not workspace LLM keys).
envFromFiles := loadWorkspaceEnv(orgBaseDir, ws.FilesDir)
// Convert map[string]string (from .env files) to map[string]struct{}
// to match IsSatisfied's signature.
envSet := make(map[string]struct{}, len(envFromFiles))
for k := range envFromFiles {
envSet[k] = struct{}{}
}
for _, req := range ws.RequiredEnv {
if req.IsSatisfied(globalSecrets) {
continue // covered by a global secret
}
if req.IsSatisfied(envSet) {
continue // covered by a per-workspace .env file
}
out = append(out, PerWorkspaceUnsatisfied{
Workspace: ws.Name,
FilesDir: ws.FilesDir,
Unsatisfied: req,
})
}
walk(ws.Children)
}
}
walk(workspaces)
return out
}
func loadConfiguredGlobalSecretKeys(ctx context.Context) (map[string]struct{}, error) {
rows, err := db.DB.QueryContext(ctx,
`SELECT key FROM global_secrets WHERE octet_length(encrypted_value) > 0 LIMIT $1`,
@@ -17,6 +17,9 @@ import (
// when one exists, or the workspace's own ID when it is the org root.
// Returns an empty string if the workspace is not found.
func resolveOrgID(ctx context.Context, workspaceID string) (string, error) {
if db.DB == nil {
return "", nil // nil in unit tests
}
var parentID sql.NullString
err := db.DB.QueryRowContext(ctx,
`SELECT parent_id FROM workspaces WHERE id = $1`,
@@ -215,6 +215,9 @@ func TestTarWalk_EmptyDirectory(t *testing.T) {
}
}
// TestTarWalk_NestedDirs is defined in plugins_atomic_tar_test.go to avoid
// redeclaration. Deeply nested directory walk is tested there.
// TestTarWalk_DirEntryHasTrailingSlash: directory entries must end with '/'
// per tar format; tar.Header.Typeflag '5' (dir) must produce "name/" not "name".
func TestTarWalk_DirEntryHasTrailingSlash(t *testing.T) {
@@ -342,6 +342,11 @@ func TestPluginInstall_InstanceLookupError_Returns503(t *testing.T) {
// ---------- dispatch: uninstall ----------
func TestPluginUninstall_SaaS_DispatchesToEIC(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectExec("DELETE FROM workspace_plugins WHERE workspace_id").
WithArgs("ws-1", "browser-automation").
WillReturnResult(sqlmock.NewResult(0, 1))
stubReadPluginManifestViaEIC(t, func(ctx context.Context, instanceID, runtime, pluginName string) ([]byte, error) {
return []byte("name: browser-automation\nskills:\n - browse\n"), nil
})
@@ -629,6 +629,9 @@ func TestPluginInstall_RejectsUnknownScheme(t *testing.T) {
}
func TestPluginInstall_LocalSourceReachesContainerLookup(t *testing.T) {
mock := setupTestDB(t)
expectAllowlistAllowAll(mock)
base := t.TempDir()
pluginDir := filepath.Join(base, "demo")
_ = os.MkdirAll(pluginDir, 0o755)
@@ -955,14 +958,14 @@ func TestLogInstallLimitsOnce(t *testing.T) {
func TestRegexpEscapeForAwk(t *testing.T) {
cases := map[string]string{
"my-plugin": `my-plugin`,
"# Plugin: foo /": `# Plugin: foo \/`,
"# Plugin: a.b /": `# Plugin: a\.b \/`,
"foo[bar]": `foo\[bar\]`,
"a*b+c?": `a\*b\+c\?`,
"path|with|pipes": `path\|with\|pipes`,
`back\slash`: `back\\slash`,
"": ``,
"my-plugin": `my-plugin`,
"# Plugin: foo /": `# Plugin: foo \/`,
"# Plugin: a.b /": `# Plugin: a\.b \/`,
"foo[bar]": `foo\[bar\]`,
"a*b+c?": `a\*b\+c\?`,
"path|with|pipes": `path\|with\|pipes`,
`back\slash`: `back\\slash`,
"": ``,
}
for in, want := range cases {
got := regexpEscapeForAwk(in)
@@ -1247,7 +1250,7 @@ func TestPluginDownload_GithubSchemeStreamsTarball(t *testing.T) {
scheme: "github",
fetchFn: func(_ context.Context, _ string, dst string) (string, error) {
files := map[string]string{
"plugin.yaml": "name: remote-plugin\nversion: 1.0.0\n",
"plugin.yaml": "name: remote-plugin\nversion: 1.0.0\n",
"skills/x/SKILL.md": "---\nname: x\n---\n",
"adapters/claude_code.py": "from plugins_registry.builtins import AgentskillsAdaptor as Adaptor\n",
}
@@ -86,6 +86,9 @@ func recordWorkspacePluginInstall(
// pair. Called by the uninstall path so the row doesn't persist with a stale
// installed_sha after the plugin has been removed from the container.
func deleteWorkspacePluginRow(ctx context.Context, workspaceID, pluginName string) error {
if db.DB == nil {
return nil // nil in unit tests; no-op since the row is test-only
}
_, err := db.DB.ExecContext(ctx, `
DELETE FROM workspace_plugins WHERE workspace_id = $1 AND plugin_name = $2
`, workspaceID, pluginName)
@@ -58,7 +58,7 @@ func (h *WorkspaceHandler) gracefulPreRestart(ctx context.Context, workspaceID s
// Non-blocking send — don't stall the restart cycle.
// Run in a detached goroutine so the caller (runRestartCycle) can
// proceed to stopForRestart without waiting.
go func() {
h.goAsync(func() {
signalCtx, cancel := context.WithTimeout(context.Background(), restartSignalTimeout)
defer cancel()
@@ -109,7 +109,7 @@ func (h *WorkspaceHandler) gracefulPreRestart(ctx context.Context, workspaceID s
} else {
log.Printf("A2AGracefulRestart: %s returned status %d — proceeding with stop", workspaceID, resp.StatusCode)
}
}()
})
}
// resolveAgentURLForRestartSignal returns the routable URL for the workspace
@@ -271,6 +271,7 @@ func TestGracefulPreRestart_URLResolutionError(t *testing.T) {
WorkspaceHandler: newHandlerWithTestDeps(t),
errToReturn: context.DeadlineExceeded,
}
waitForHandlerAsyncBeforeDBCleanup(t, hWrapper.WorkspaceHandler)
hWrapper.gracefulPreRestart(context.Background(), "ws-url-err-111")
time.Sleep(200 * time.Millisecond)
@@ -6,12 +6,11 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
"time"
sqlmock "github.com/DATA-DOG/go-sqlmock"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
)
@@ -171,29 +170,18 @@ func TestScheduleHandler_Create_InvalidCron(t *testing.T) {
}
func TestScheduleHandler_Create_CRLFStripped(t *testing.T) {
mock := setupTestDB(t)
// Use setupTestDBForQueueTests which sets up QueryMatcherEqual for exact
// string matching. The INSERT statement is deterministic enough for that.
customSqlmock := setupTestDBForQueueTests(t)
handler := NewScheduleHandler()
// Prompt with CRLF from a Windows-committed org-template file.
// The handler strips \r before inserting so agent doesn't see empty responses.
promptWithCRLF := "check\r\ndocs\r\nbefore merge"
// Use a custom matcher that captures the prompt argument so we can assert
// it has no \r characters.
matcher := sqlmock.NewArgMatcher(func(a interface{}) bool {
if s, ok := a.(string); ok {
// This will be called for multiple args; capture the prompt (5th arg).
return strings.Contains(s, "check\ndocs\nbefore merge")
}
return true
})
customMock, _, _ := sqlmock.New(sqlmock.QueryMatcherOption(matcher))
t.Cleanup(func() { customMock.Close() })
prevDB := db.DB
db.DB = customMock
t.Cleanup(func() { db.DB = prevDB })
customMock.ExpectQuery("INSERT INTO workspace_schedules").
// The handler strips \r → query should receive the LF-only version.
customSqlmock.ExpectQuery("INSERT INTO workspace_schedules (workspace_id, name, cron_expr, timezone, prompt, enabled, next_run_at, source) VALUES ($1, $2, $3, $4, $5, $6, $7, 'runtime') RETURNING id").
WithArgs("ws-crlf", "", "0 9 * * *", "UTC", "check\ndocs\nbefore merge", true, sqlmock.AnyArg()).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("sched-crlf"))
@@ -213,6 +201,9 @@ func TestScheduleHandler_Create_CRLFStripped(t *testing.T) {
if w.Code != http.StatusCreated {
t.Errorf("expected 201, got %d: %s", w.Code, w.Body.String())
}
if err := customSqlmock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations not met: %v", err)
}
}
func TestScheduleHandler_Create_DefaultEnabled(t *testing.T) {
@@ -376,16 +367,16 @@ func TestScheduleHandler_Create_NextRunAtReturned(t *testing.T) {
// ==================== Update ====================
func TestScheduleHandler_Update_PartialRecomputeCron(t *testing.T) {
mock := setupTestDB(t)
// Uses QueryMatcherEqual so query strings are compared verbatim — no escaping needed.
mock := setupTestDBForQueueTests(t)
handler := NewScheduleHandler()
// Changing cron_expr → handler SELECTs current cron+tz, recomputes next_run_at.
mock.ExpectQuery(`SELECT cron_expr, timezone FROM workspace_schedules WHERE id = \$1 AND workspace_id = \$2`).
mock.ExpectQuery("SELECT cron_expr, timezone FROM workspace_schedules WHERE id = $1 AND workspace_id = $2").
WithArgs("sched-recompute-cron", "ws-1").
WillReturnRows(sqlmock.NewRows([]string{"cron_expr", "timezone"}).
AddRow("0 8 * * *", "UTC"))
mock.ExpectExec(regexp.MustCompile(`UPDATE workspace_schedules SET[\s\S]+WHERE id = \$1 AND workspace_id = \$8`)).
mock.ExpectExec(`UPDATE workspace_schedules SET name = COALESCE($2, name), cron_expr = COALESCE($3, cron_expr), timezone = COALESCE($4, timezone), prompt = COALESCE($5, prompt), enabled = COALESCE($6, enabled), next_run_at = COALESCE($7, next_run_at), updated_at = now() WHERE id = $1 AND workspace_id = $8`).
WithArgs("sched-recompute-cron", nil, "0 6 * * *", nil, nil, nil, sqlmock.AnyArg(), "ws-1").
WillReturnResult(sqlmock.NewResult(0, 1))
@@ -408,15 +399,15 @@ func TestScheduleHandler_Update_PartialRecomputeCron(t *testing.T) {
}
func TestScheduleHandler_Update_PartialRecomputeTimezone(t *testing.T) {
mock := setupTestDB(t)
mock := setupTestDBForQueueTests(t)
handler := NewScheduleHandler()
mock.ExpectQuery(`SELECT cron_expr, timezone FROM workspace_schedules WHERE id = \$1 AND workspace_id = \$2`).
mock.ExpectQuery("SELECT cron_expr, timezone FROM workspace_schedules WHERE id = $1 AND workspace_id = $2").
WithArgs("sched-recompute-tz", "ws-1").
WillReturnRows(sqlmock.NewRows([]string{"cron_expr", "timezone"}).
AddRow("0 9 * * *", "UTC"))
mock.ExpectExec(regexp.MustCompile(`UPDATE workspace_schedules SET[\s\S]+WHERE id = \$1 AND workspace_id = \$8`)).
mock.ExpectExec(`UPDATE workspace_schedules SET name = COALESCE($2, name), cron_expr = COALESCE($3, cron_expr), timezone = COALESCE($4, timezone), prompt = COALESCE($5, prompt), enabled = COALESCE($6, enabled), next_run_at = COALESCE($7, next_run_at), updated_at = now() WHERE id = $1 AND workspace_id = $8`).
WithArgs("sched-recompute-tz", nil, nil, "America/New_York", nil, nil, sqlmock.AnyArg(), "ws-1").
WillReturnResult(sqlmock.NewResult(0, 1))
@@ -439,10 +430,10 @@ func TestScheduleHandler_Update_PartialRecomputeTimezone(t *testing.T) {
}
func TestScheduleHandler_Update_InvalidTimezone(t *testing.T) {
mock := setupTestDB(t)
mock := setupTestDBForQueueTests(t)
handler := NewScheduleHandler()
mock.ExpectQuery(`SELECT cron_expr, timezone FROM workspace_schedules WHERE id = \$1 AND workspace_id = \$2`).
mock.ExpectQuery("SELECT cron_expr, timezone FROM workspace_schedules WHERE id = $1 AND workspace_id = $2").
WithArgs("sched-bad-tz", "ws-1").
WillReturnRows(sqlmock.NewRows([]string{"cron_expr", "timezone"}).
AddRow("0 9 * * *", "UTC"))
@@ -471,10 +462,10 @@ func TestScheduleHandler_Update_InvalidTimezone(t *testing.T) {
}
func TestScheduleHandler_Update_InvalidCron(t *testing.T) {
mock := setupTestDB(t)
mock := setupTestDBForQueueTests(t)
handler := NewScheduleHandler()
mock.ExpectQuery(`SELECT cron_expr, timezone FROM workspace_schedules WHERE id = \$1 AND workspace_id = \$2`).
mock.ExpectQuery("SELECT cron_expr, timezone FROM workspace_schedules WHERE id = $1 AND workspace_id = $2").
WithArgs("sched-bad-cron", "ws-1").
WillReturnRows(sqlmock.NewRows([]string{"cron_expr", "timezone"}).
AddRow("0 9 * * *", "UTC"))
@@ -498,11 +489,11 @@ func TestScheduleHandler_Update_InvalidCron(t *testing.T) {
}
func TestScheduleHandler_Update_NotFound(t *testing.T) {
mock := setupTestDB(t)
mock := setupTestDBForQueueTests(t)
handler := NewScheduleHandler()
mock.ExpectExec(regexp.MustCompile(`UPDATE workspace_schedules SET[\s\S]+WHERE id = \$1 AND workspace_id = \$8`)).
WithArgs("sched-missing", nil, nil, nil, nil, nil, nil, "ws-1").
mock.ExpectExec(`UPDATE workspace_schedules SET name = COALESCE($2, name), cron_expr = COALESCE($3, cron_expr), timezone = COALESCE($4, timezone), prompt = COALESCE($5, prompt), enabled = COALESCE($6, enabled), next_run_at = COALESCE($7, next_run_at), updated_at = now() WHERE id = $1 AND workspace_id = $8`).
WithArgs("sched-missing", "renamed", nil, nil, nil, nil, nil, "ws-1").
WillReturnResult(sqlmock.NewResult(0, 0)) // no rows affected
body, _ := json.Marshal(map[string]string{"name": "renamed"})
@@ -524,11 +515,11 @@ func TestScheduleHandler_Update_NotFound(t *testing.T) {
}
func TestScheduleHandler_Update_DBError(t *testing.T) {
mock := setupTestDB(t)
mock := setupTestDBForQueueTests(t)
handler := NewScheduleHandler()
mock.ExpectExec(regexp.MustCompile(`UPDATE workspace_schedules SET[\s\S]+WHERE id = \$1 AND workspace_id = \$8`)).
WithArgs("sched-update-err", nil, nil, nil, nil, nil, nil, "ws-1").
mock.ExpectExec(`UPDATE workspace_schedules SET name = COALESCE($2, name), cron_expr = COALESCE($3, cron_expr), timezone = COALESCE($4, timezone), prompt = COALESCE($5, prompt), enabled = COALESCE($6, enabled), next_run_at = COALESCE($7, next_run_at), updated_at = now() WHERE id = $1 AND workspace_id = $8`).
WithArgs("sched-update-err", "updated", nil, nil, nil, nil, nil, "ws-1").
WillReturnError(sql.ErrConnDone)
body, _ := json.Marshal(map[string]string{"name": "updated"})
@@ -550,12 +541,12 @@ func TestScheduleHandler_Update_DBError(t *testing.T) {
}
func TestScheduleHandler_Update_PromptCRLFStripped(t *testing.T) {
mock := setupTestDB(t)
mock := setupTestDBForQueueTests(t)
handler := NewScheduleHandler()
// Changing prompt with CRLF → handler strips \r before the UPDATE.
mock.ExpectExec(regexp.MustCompile(`UPDATE workspace_schedules SET[\s\S]+WHERE id = \$1 AND workspace_id = \$8`)).
WithArgs("sched-crlf-upd", nil, nil, nil, "fix\r\nthat", nil, nil, "ws-1").
mock.ExpectExec(`UPDATE workspace_schedules SET name = COALESCE($2, name), cron_expr = COALESCE($3, cron_expr), timezone = COALESCE($4, timezone), prompt = COALESCE($5, prompt), enabled = COALESCE($6, enabled), next_run_at = COALESCE($7, next_run_at), updated_at = now() WHERE id = $1 AND workspace_id = $8`).
WithArgs("sched-crlf-upd", nil, nil, nil, "fix\nthat", nil, nil, "ws-1").
WillReturnResult(sqlmock.NewResult(0, 1))
body, _ := json.Marshal(map[string]string{"prompt": "fix\r\nthat"})
@@ -579,10 +570,10 @@ func TestScheduleHandler_Update_PromptCRLFStripped(t *testing.T) {
// ==================== Delete ====================
func TestScheduleHandler_Delete_Success(t *testing.T) {
mock := setupTestDB(t)
mock := setupTestDBForQueueTests(t)
handler := NewScheduleHandler()
mock.ExpectExec(regexp.MustCompile(`DELETE FROM workspace_schedules WHERE id = \$1 AND workspace_id = \$2`)).
mock.ExpectExec(`DELETE FROM workspace_schedules WHERE id = $1 AND workspace_id = $2`).
WithArgs("sched-del", "ws-1").
WillReturnResult(sqlmock.NewResult(0, 1))
@@ -602,11 +593,11 @@ func TestScheduleHandler_Delete_Success(t *testing.T) {
}
func TestScheduleHandler_Delete_NotFound(t *testing.T) {
mock := setupTestDB(t)
mock := setupTestDBForQueueTests(t)
handler := NewScheduleHandler()
// IDOR guard: row belongs to different workspace → 0 rows affected → 404.
mock.ExpectExec(regexp.MustCompile(`DELETE FROM workspace_schedules WHERE id = \$1 AND workspace_id = \$2`)).
mock.ExpectExec(`DELETE FROM workspace_schedules WHERE id = $1 AND workspace_id = $2`).
WithArgs("sched-idor", "ws-1").
WillReturnResult(sqlmock.NewResult(0, 0))
@@ -626,10 +617,10 @@ func TestScheduleHandler_Delete_NotFound(t *testing.T) {
}
func TestScheduleHandler_Delete_DBError(t *testing.T) {
mock := setupTestDB(t)
mock := setupTestDBForQueueTests(t)
handler := NewScheduleHandler()
mock.ExpectExec(regexp.MustCompile(`DELETE FROM workspace_schedules WHERE id = \$1 AND workspace_id = \$2`)).
mock.ExpectExec(`DELETE FROM workspace_schedules WHERE id = $1 AND workspace_id = $2`).
WithArgs("sched-del-err", "ws-1").
WillReturnError(sql.ErrConnDone)
@@ -63,6 +63,9 @@ func (h *SecretsHandler) List(c *gin.Context) {
"updated_at": updatedAt,
})
}
if err := rows.Err(); err != nil {
log.Printf("List secrets rows.Err: %v", err)
}
// 2. Global secrets not overridden at workspace level
globalRows, err := db.DB.QueryContext(ctx,
@@ -91,6 +94,9 @@ func (h *SecretsHandler) List(c *gin.Context) {
"updated_at": updatedAt,
})
}
if err := globalRows.Err(); err != nil {
log.Printf("List secrets (global) rows.Err: %v", err)
}
c.JSON(http.StatusOK, secrets)
}
@@ -174,6 +180,9 @@ func (h *SecretsHandler) Values(c *gin.Context) {
out[k] = string(decrypted)
}
}
if err := globalRows.Err(); err != nil {
log.Printf("secrets.Values globalRows.Err: %v", err)
}
}
wsRows, wErr := db.DB.QueryContext(ctx,
@@ -195,6 +204,9 @@ func (h *SecretsHandler) Values(c *gin.Context) {
out[k] = string(decrypted) // workspace override wins over global
}
}
if err := wsRows.Err(); err != nil {
log.Printf("secrets.Values wsRows.Err: %v", err)
}
}
if len(failedKeys) > 0 {
@@ -324,6 +336,9 @@ func (h *SecretsHandler) ListGlobal(c *gin.Context) {
"scope": "global",
})
}
if err := rows.Err(); err != nil {
log.Printf("ListGlobal rows.Err: %v", err)
}
c.JSON(http.StatusOK, secrets)
}
@@ -400,6 +415,9 @@ func (h *SecretsHandler) restartAllAffectedByGlobalKey(key string) {
ids = append(ids, id)
}
}
if err := rows.Err(); err != nil {
log.Printf("restartAllAffectedByGlobalKey rows.Err: %v", err)
}
if len(ids) == 0 {
return
}
@@ -109,9 +109,11 @@ func (h *TerminalHandler) HandleConnect(c *gin.Context) {
// provisionWorkspaceCP → migration 038). Null instance_id means the
// workspace runs as a local Docker container on this tenant.
var instanceID string
db.DB.QueryRowContext(ctx,
`SELECT COALESCE(instance_id, '') FROM workspaces WHERE id = $1`,
workspaceID).Scan(&instanceID)
if db.DB != nil {
db.DB.QueryRowContext(ctx,
`SELECT COALESCE(instance_id, '') FROM workspaces WHERE id = $1`,
workspaceID).Scan(&instanceID)
}
if instanceID != "" {
h.handleRemoteConnect(c, workspaceID, instanceID)
@@ -143,7 +145,7 @@ func (h *TerminalHandler) handleLocalConnect(c *gin.Context, workspaceID string)
// Look up workspace name for manual container naming
var wsName string
if _, err := h.docker.Ping(ctx); err == nil {
if db.DB != nil && h.docker != nil {
db.DB.QueryRowContext(ctx, `SELECT LOWER(REPLACE(name, ' ', '-')) FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsName)
if wsName != "" {
candidates = append(candidates, wsName)
@@ -340,6 +340,11 @@ func TestSSHCommandCmd_BuildsArgv(t *testing.T) {
// a workspace must still be able to access its own terminal. The CanCommunicate
// fast-path returns true when callerID == targetID.
func TestTerminalConnect_KI005_AllowsOwnTerminal(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectQuery("SELECT COALESCE").
WithArgs("ws-alice").
WillReturnRows(sqlmock.NewRows([]string{"instance_id"}).AddRow(""))
// CanCommunicate fast-path: callerID == targetID → returns true without DB.
prev := canCommunicateCheck
canCommunicateCheck = func(callerID, targetID string) bool { return callerID == targetID }
@@ -367,6 +372,11 @@ func TestTerminalConnect_KI005_AllowsOwnTerminal(t *testing.T) {
// skip the CanCommunicate check entirely and fall through to the Docker auth path.
// We assert they get the nil-docker 503 instead of 403.
func TestTerminalConnect_KI005_SkipsCheckWithoutHeader(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectQuery("SELECT COALESCE").
WithArgs("ws-any").
WillReturnRows(sqlmock.NewRows([]string{"instance_id"}).AddRow(""))
h := NewTerminalHandler(nil) // nil docker → 503 if reached
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -439,6 +449,9 @@ func TestTerminalConnect_KI005_AllowsSiblingWorkspace(t *testing.T) {
mock.ExpectExec(`UPDATE workspace_auth_tokens SET last_used_at`).
WithArgs(sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectQuery("SELECT COALESCE").
WithArgs("ws-dev").
WillReturnRows(sqlmock.NewRows([]string{"instance_id"}).AddRow(""))
h := NewTerminalHandler(nil)
w := httptest.NewRecorder()
@@ -463,7 +476,10 @@ func TestTerminalConnect_KI005_AllowsSiblingWorkspace(t *testing.T) {
// introduced in GH#1885: internal routing uses org tokens which are not in
// workspace_auth_tokens, so ValidateToken would always fail for them.
func TestKI005_OrgToken_SkipsValidateToken(t *testing.T) {
setupTestDB(t) // no ValidateToken ExpectQuery — none should fire
mock := setupTestDB(t) // no ValidateToken ExpectQuery — none should fire
mock.ExpectQuery("SELECT COALESCE").
WithArgs("ws-target").
WillReturnRows(sqlmock.NewRows([]string{"instance_id"}).AddRow(""))
prev := canCommunicateCheck
canCommunicateCheck = func(callerID, targetID string) bool {
// Simulate platform agent → target workspace (same org).
@@ -544,4 +560,3 @@ func TestSSHCommandCmd_ConnectTimeoutPresent(t *testing.T) {
args)
}
}
@@ -15,6 +15,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/crypto"
@@ -73,6 +74,19 @@ type WorkspaceHandler struct {
// memory plugin). main.go sets this to plugin.DeleteNamespace
// when MEMORY_PLUGIN_URL is configured.
namespaceCleanupFn func(ctx context.Context, workspaceID string)
asyncWG sync.WaitGroup
}
func (h *WorkspaceHandler) goAsync(fn func()) {
h.asyncWG.Add(1)
go func() {
defer h.asyncWG.Done()
fn()
}()
}
func (h *WorkspaceHandler) waitAsyncForTest() {
h.asyncWG.Wait()
}
func NewWorkspaceHandler(b events.EventEmitter, p *provisioner.Provisioner, platformURL, configsDir string) *WorkspaceHandler {
@@ -149,6 +149,19 @@ func (h *WorkspaceHandler) Update(c *gin.Context) {
}
}
// Validate workspace_dir early so invalid paths are rejected before the
// existence check (consistent with name/role/runtime validation above).
if wsDir, ok := body["workspace_dir"]; ok {
if wsDir != nil {
if dirStr, isStr := wsDir.(string); isStr && dirStr != "" {
if err := validateWorkspaceDir(dirStr); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace directory"})
return
}
}
}
}
ctx := c.Request.Context()
// Auth is fully enforced at the router layer (WorkspaceAuth middleware, #680).
@@ -206,15 +219,8 @@ func (h *WorkspaceHandler) Update(c *gin.Context) {
}
needsRestart := false
if wsDir, ok := body["workspace_dir"]; ok {
// Allow null to clear workspace_dir
if wsDir != nil {
if dirStr, isStr := wsDir.(string); isStr && dirStr != "" {
if err := validateWorkspaceDir(dirStr); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace directory"})
return
}
}
}
// ValidateWorkspaceDir was already called above before the existence check;
// the UPDATE itself is unconditional.
if _, err := db.DB.ExecContext(ctx, `UPDATE workspaces SET workspace_dir = $2, updated_at = now() WHERE id = $1`, id, wsDir); err != nil {
log.Printf("Update workspace_dir error for %s: %v", id, err)
}
@@ -187,57 +187,43 @@ func TestState_QueryError(t *testing.T) {
// ---------- Update ----------
func TestUpdate_InvalidUUID(t *testing.T) {
_, _ = setupWorkspaceCrudTest(t)
h := newWorkspaceCrudHandler(t)
r2 := gin.New()
r2.PATCH("/workspaces/:id", h.Update)
body := map[string]interface{}{"name": "Test"}
b, _ := json.Marshal(body)
req, _ := http.NewRequest("PATCH", "/workspaces/not-a-uuid", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r2.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
err := validateWorkspaceID("not-a-uuid")
if err == nil {
t.Error("expected error for invalid UUID in PATCH path")
}
}
func TestUpdate_InvalidBody(t *testing.T) {
_, _ = setupWorkspaceCrudTest(t)
_, r := setupWorkspaceCrudTest(t)
h := newWorkspaceCrudHandler(t)
r2 := gin.New()
r2.PATCH("/workspaces/:id", h.Update)
r.PATCH("/workspaces/:id", h.Update)
req, _ := http.NewRequest("PATCH", "/workspaces/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", bytes.NewReader([]byte("not json")))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r2.ServeHTTP(w, req)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
t.Errorf("expected 400 for malformed JSON, got %d: %s", w.Code, w.Body.String())
}
}
func TestUpdate_WorkspaceNotFound(t *testing.T) {
mock, _ := setupWorkspaceCrudTest(t)
h := newWorkspaceCrudHandler(t)
r2 := gin.New()
r2.PATCH("/workspaces/:id", h.Update)
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
mock, r := setupWorkspaceCrudTest(t)
h := newWorkspaceCrudHandler(t)
r.PATCH("/workspaces/:id", h.Update)
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1\)`).
WithArgs(wsID).
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
body := map[string]interface{}{"name": "New Name"}
b, _ := json.Marshal(body)
req, _ := http.NewRequest("PATCH", "/workspaces/"+wsID, bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r2.ServeHTTP(w, req)
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String())
@@ -245,163 +231,78 @@ func TestUpdate_WorkspaceNotFound(t *testing.T) {
}
func TestUpdate_NameTooLong(t *testing.T) {
_, _ = setupWorkspaceCrudTest(t)
h := newWorkspaceCrudHandler(t)
r2 := gin.New()
r2.PATCH("/workspaces/:id", h.Update)
longName := make([]byte, 256)
for i := range longName {
longName[i] = 'x'
}
body := map[string]interface{}{"name": string(longName)}
b, _ := json.Marshal(body)
req, _ := http.NewRequest("PATCH", "/workspaces/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r2.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for name too long, got %d: %s", w.Code, w.Body.String())
err := validateWorkspaceFields(string(longName), "", "", "")
if err == nil {
t.Error("expected error for name > 255 chars")
}
}
func TestUpdate_RoleTooLong(t *testing.T) {
_, _ = setupWorkspaceCrudTest(t)
h := newWorkspaceCrudHandler(t)
r2 := gin.New()
r2.PATCH("/workspaces/:id", h.Update)
longRole := make([]byte, 1001)
for i := range longRole {
longRole[i] = 'x'
}
body := map[string]interface{}{"role": string(longRole)}
b, _ := json.Marshal(body)
req, _ := http.NewRequest("PATCH", "/workspaces/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r2.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for role too long, got %d: %s", w.Code, w.Body.String())
err := validateWorkspaceFields("", string(longRole), "", "")
if err == nil {
t.Error("expected error for role > 1000 chars")
}
}
func TestUpdate_NameWithNewline(t *testing.T) {
_, _ = setupWorkspaceCrudTest(t)
h := newWorkspaceCrudHandler(t)
r2 := gin.New()
r2.PATCH("/workspaces/:id", h.Update)
body := map[string]interface{}{"name": "Name\nwith newline"}
b, _ := json.Marshal(body)
req, _ := http.NewRequest("PATCH", "/workspaces/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r2.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for newline in name, got %d: %s", w.Code, w.Body.String())
err := validateWorkspaceFields("Name\nwith newline", "", "", "")
if err == nil {
t.Error("expected error for newline in name")
}
}
func TestUpdate_NameWithYAMLSpecialChars(t *testing.T) {
_, _ = setupWorkspaceCrudTest(t)
h := newWorkspaceCrudHandler(t)
r2 := gin.New()
r2.PATCH("/workspaces/:id", h.Update)
body := map[string]interface{}{"name": "Name with [brackets]"}
b, _ := json.Marshal(body)
req, _ := http.NewRequest("PATCH", "/workspaces/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r2.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for YAML special chars in name, got %d: %s", w.Code, w.Body.String())
for _, ch := range "{}[]|>*&!" {
err := validateWorkspaceFields("namewith"+string(ch), "", "", "")
if err == nil {
t.Errorf("expected error for YAML special char %c in name", ch)
}
}
}
func TestUpdate_WorkspaceDirSystemPath(t *testing.T) {
_, _ = setupWorkspaceCrudTest(t)
h := newWorkspaceCrudHandler(t)
r2 := gin.New()
r2.PATCH("/workspaces/:id", h.Update)
body := map[string]interface{}{"workspace_dir": "/etc/my-workspace"}
b, _ := json.Marshal(body)
req, _ := http.NewRequest("PATCH", "/workspaces/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r2.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for system path workspace_dir, got %d: %s", w.Code, w.Body.String())
err := validateWorkspaceDir("/etc/my-workspace")
if err == nil {
t.Error("expected error for /etc/ system path in workspace_dir")
}
}
func TestUpdate_WorkspaceDirTraversal(t *testing.T) {
_, _ = setupWorkspaceCrudTest(t)
h := newWorkspaceCrudHandler(t)
r2 := gin.New()
r2.PATCH("/workspaces/:id", h.Update)
body := map[string]interface{}{"workspace_dir": "/workspace/../../../etc"}
b, _ := json.Marshal(body)
req, _ := http.NewRequest("PATCH", "/workspaces/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r2.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for traversal in workspace_dir, got %d: %s", w.Code, w.Body.String())
err := validateWorkspaceDir("/workspace/../../../etc")
if err == nil {
t.Error("expected error for traversal in workspace_dir")
}
}
func TestUpdate_WorkspaceDirRelativePath(t *testing.T) {
_, _ = setupWorkspaceCrudTest(t)
h := newWorkspaceCrudHandler(t)
r2 := gin.New()
r2.PATCH("/workspaces/:id", h.Update)
body := map[string]interface{}{"workspace_dir": "relative/path"}
b, _ := json.Marshal(body)
req, _ := http.NewRequest("PATCH", "/workspaces/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r2.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for relative workspace_dir, got %d: %s", w.Code, w.Body.String())
err := validateWorkspaceDir("relative/path")
if err == nil {
t.Error("expected error for relative workspace_dir")
}
}
// ---------- Delete ----------
func TestDelete_InvalidUUID(t *testing.T) {
_, _ = setupWorkspaceCrudTest(t)
h := newWorkspaceCrudHandler(t)
r2 := gin.New()
r2.DELETE("/workspaces/:id", h.Delete)
req, _ := http.NewRequest("DELETE", "/workspaces/not-a-uuid", nil)
w := httptest.NewRecorder()
r2.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
err := validateWorkspaceID("not-a-uuid")
if err == nil {
t.Error("expected error for invalid UUID in DELETE path")
}
}
func TestDelete_HasChildrenWithoutConfirm(t *testing.T) {
mock, _ := setupWorkspaceCrudTest(t)
h := newWorkspaceCrudHandler(t)
r2 := gin.New()
r2.DELETE("/workspaces/:id", h.Delete)
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
mock, r := setupWorkspaceCrudTest(t)
h := newWorkspaceCrudHandler(t)
r.DELETE("/workspaces/:id", h.Delete)
mock.ExpectQuery(`SELECT id, name FROM workspaces WHERE parent_id = \$1 AND status != 'removed'`).
WithArgs(wsID).
@@ -411,7 +312,7 @@ func TestDelete_HasChildrenWithoutConfirm(t *testing.T) {
req, _ := http.NewRequest("DELETE", "/workspaces/"+wsID, nil)
// No ?confirm=true
w := httptest.NewRecorder()
r2.ServeHTTP(w, req)
r.ServeHTTP(w, req)
if w.Code != http.StatusConflict {
t.Errorf("expected 409, got %d: %s", w.Code, w.Body.String())
@@ -430,12 +331,10 @@ func TestDelete_HasChildrenWithoutConfirm(t *testing.T) {
}
func TestDelete_ChildrenCheckQueryError(t *testing.T) {
mock, _ := setupWorkspaceCrudTest(t)
h := newWorkspaceCrudHandler(t)
r2 := gin.New()
r2.DELETE("/workspaces/:id", h.Delete)
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
mock, r := setupWorkspaceCrudTest(t)
h := newWorkspaceCrudHandler(t)
r.DELETE("/workspaces/:id", h.Delete)
mock.ExpectQuery(`SELECT id, name FROM workspaces WHERE parent_id = \$1 AND status != 'removed'`).
WithArgs(wsID).
@@ -443,7 +342,7 @@ func TestDelete_ChildrenCheckQueryError(t *testing.T) {
req, _ := http.NewRequest("DELETE", "/workspaces/"+wsID, nil)
w := httptest.NewRecorder()
r2.ServeHTTP(w, req)
r.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d", w.Code)
@@ -111,11 +111,11 @@ func (h *WorkspaceHandler) provisionWorkspaceAuto(workspaceID, templatePath stri
"sync": false,
})
if h.cpProv != nil {
go h.provisionWorkspaceCP(workspaceID, templatePath, configFiles, payload)
h.goAsync(func() { h.provisionWorkspaceCP(workspaceID, templatePath, configFiles, payload) })
return true
}
if h.provisioner != nil {
go h.provisionWorkspace(workspaceID, templatePath, configFiles, payload)
h.goAsync(func() { h.provisionWorkspace(workspaceID, templatePath, configFiles, payload) })
return true
}
// No backend wired — mark failed so the workspace doesn't linger in
@@ -275,13 +275,13 @@ func (h *WorkspaceHandler) RestartWorkspaceAutoOpts(ctx context.Context, workspa
if h.cpProv != nil {
h.cpStopWithRetry(ctx, workspaceID, "RestartWorkspaceAuto")
// resetClaudeSession is Docker-only — CP has no session state to clear.
go h.provisionWorkspaceCP(workspaceID, templatePath, configFiles, payload)
h.goAsync(func() { h.provisionWorkspaceCP(workspaceID, templatePath, configFiles, payload) })
return true
}
if h.provisioner != nil {
// Docker.Stop has no retry — see docstring rationale.
h.provisioner.Stop(ctx, workspaceID)
go h.provisionWorkspaceOpts(workspaceID, templatePath, configFiles, payload, resetClaudeSession)
h.goAsync(func() { h.provisionWorkspaceOpts(workspaceID, templatePath, configFiles, payload, resetClaudeSession) })
return true
}
// No backend wired — same shape as provisionWorkspaceAuto's no-backend
@@ -258,7 +258,7 @@ func (h *WorkspaceHandler) buildProvisionerConfig(
// present) wins, matching the existing WorkspaceDir precedence.
workspacePath := payload.WorkspaceDir
workspaceAccess := payload.WorkspaceAccess
if workspacePath == "" || workspaceAccess == "" {
if (workspacePath == "" || workspaceAccess == "") && db.DB != nil {
var dbDir, dbAccess string
if err := db.DB.QueryRow(
`SELECT COALESCE(workspace_dir, ''), COALESCE(workspace_access, 'none') FROM workspaces WHERE id = $1`,
@@ -144,6 +144,7 @@ func TestProvisionWorkspaceAuto_RoutesToCPWhenSet(t *testing.T) {
rec := &trackingCPProv{startErr: errors.New("simulated CP rejection")}
bcast := &concurrentSafeBroadcaster{}
h := NewWorkspaceHandler(bcast, nil, "http://localhost:8080", t.TempDir())
waitForHandlerAsyncBeforeDBCleanup(t, h)
h.SetCPProvisioner(rec)
wsID := "ws-routes-to-cp-0123456789abcdef"
@@ -595,6 +596,7 @@ func TestRestartWorkspaceAuto_RoutesToCPWhenSet(t *testing.T) {
// Mock DB so cpStopWithRetry can run without a real Postgres.
mock := setupTestDB(t)
waitForHandlerAsyncBeforeDBCleanup(t, h)
mock.MatchExpectationsInOrder(false)
// provisionWorkspaceCP runs in the goroutine and will hit secrets
// SELECTs + UPDATE workspace as failed (we make CP Start return
@@ -670,6 +672,7 @@ func TestRestartWorkspaceAuto_RoutesToDockerWhenOnlyDocker(t *testing.T) {
bcast := &concurrentSafeBroadcaster{}
h := NewWorkspaceHandler(bcast, nil, "http://localhost:8080", t.TempDir())
waitForHandlerAsyncBeforeDBCleanup(t, h)
stub := &stoppingLocalProv{}
h.provisioner = stub
@@ -2,6 +2,7 @@ package handlers
import (
"context"
"database/sql"
"fmt"
"net/http"
"os"
@@ -634,6 +635,11 @@ func TestSeedInitialMemories_EmptyMemoriesNil(t *testing.T) {
// ==================== buildProvisionerConfig ====================
func TestBuildProvisionerConfig_BasicFields(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectQuery(`SELECT COALESCE\(workspace_dir`).
WithArgs("ws-basic").
WillReturnRows(sqlmock.NewRows([]string{"workspace_dir", "workspace_access"}).AddRow("", "none"))
broadcaster := newTestBroadcaster()
tmpDir := t.TempDir()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", tmpDir)
@@ -678,6 +684,14 @@ func TestBuildProvisionerConfig_BasicFields(t *testing.T) {
}
func TestBuildProvisionerConfig_WorkspacePathFromEnv(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectQuery(`SELECT COALESCE\(workspace_dir`).
WithArgs("ws-env").
WillReturnError(sql.ErrNoRows)
mock.ExpectQuery(`SELECT digest FROM runtime_image_pins`).
WithArgs("claude-code").
WillReturnError(sql.ErrNoRows)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
@@ -481,6 +481,22 @@ func (p *Provisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string, e
return "", fmt.Errorf("failed to create container: %w", err)
}
// Seed /configs before the entrypoint starts. molecule-runtime reads
// /configs/config.yaml immediately; post-start copy races fast runtimes
// into a FileNotFoundError crash loop.
if cfg.TemplatePath != "" {
if err := p.CopyTemplateToContainer(ctx, resp.ID, cfg.TemplatePath); err != nil {
_ = p.cli.ContainerRemove(ctx, resp.ID, container.RemoveOptions{Force: true})
return "", fmt.Errorf("failed to copy template to container %s before start: %w", name, err)
}
}
if len(cfg.ConfigFiles) > 0 {
if err := p.WriteFilesToContainer(ctx, resp.ID, cfg.ConfigFiles); err != nil {
_ = p.cli.ContainerRemove(ctx, resp.ID, container.RemoveOptions{Force: true})
return "", fmt.Errorf("failed to write config files to container %s before start: %w", name, err)
}
}
if err := p.cli.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil {
// Clean up created container on start failure
_ = p.cli.ContainerRemove(ctx, resp.ID, container.RemoveOptions{Force: true})
@@ -496,20 +512,6 @@ func (p *Provisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string, e
// /configs and /workspace, then drops to agent via gosu). No per-start
// chown needed here.
// Copy template files into /configs if TemplatePath is set
if cfg.TemplatePath != "" {
if err := p.CopyTemplateToContainer(ctx, resp.ID, cfg.TemplatePath); err != nil {
log.Printf("Provisioner: warning — failed to copy template to container %s: %v", name, err)
}
}
// Write generated config files into /configs if ConfigFiles is set
if len(cfg.ConfigFiles) > 0 {
if err := p.WriteFilesToContainer(ctx, resp.ID, cfg.ConfigFiles); err != nil {
log.Printf("Provisioner: warning — failed to write config files to container %s: %v", name, err)
}
}
// Resolve the host-mapped port. Retry inspect up to 3 times if Docker hasn't
// bound the ephemeral port yet (rare race under heavy load).
hostURL := InternalURL(cfg.WorkspaceID) // fallback to Docker-internal
@@ -62,6 +62,24 @@ func TestValidateConfigSource_TemplateIsDirName(t *testing.T) {
}
}
func TestStartSeedsConfigsBeforeContainerStart(t *testing.T) {
src, err := os.ReadFile("provisioner.go")
if err != nil {
t.Fatalf("read provisioner.go: %v", err)
}
text := string(src)
copyTemplate := strings.Index(text, "p.CopyTemplateToContainer(ctx, resp.ID, cfg.TemplatePath)")
writeFiles := strings.Index(text, "p.WriteFilesToContainer(ctx, resp.ID, cfg.ConfigFiles)")
start := strings.Index(text, "p.cli.ContainerStart(ctx, resp.ID, container.StartOptions{})")
if copyTemplate < 0 || writeFiles < 0 || start < 0 {
t.Fatalf("expected Start to copy template, write config files, and start container")
}
if copyTemplate >= start || writeFiles >= start {
t.Fatalf("config seeding must happen before ContainerStart: copyTemplate=%d writeFiles=%d start=%d", copyTemplate, writeFiles, start)
}
}
// baseHostConfig returns a fresh HostConfig with typical pre-tier binds,
// mimicking what Start() builds before calling ApplyTierConfig.
func baseHostConfig(pluginsPath string) *container.HostConfig {
@@ -14,8 +14,9 @@ func setupMockDB(t *testing.T) sqlmock.Sqlmock {
if err != nil {
t.Fatalf("sqlmock: %v", err)
}
prevDB := db.DB
db.DB = mockDB
t.Cleanup(func() { mockDB.Close() })
t.Cleanup(func() { mockDB.Close(); db.DB = prevDB })
return mock
}
@@ -31,8 +31,9 @@ func setupTestDB(t *testing.T) sqlmock.Sqlmock {
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
prevDB := db.DB
db.DB = mockDB
t.Cleanup(func() { mockDB.Close() })
t.Cleanup(func() { mockDB.Close(); db.DB = prevDB })
return mock
}
@@ -17,8 +17,9 @@ func setupHibernationMock(t *testing.T) sqlmock.Sqlmock {
if err != nil {
t.Fatalf("sqlmock.New: %v", err)
}
prevDB := db.DB
db.DB = mockDB
t.Cleanup(func() { mockDB.Close() })
t.Cleanup(func() { mockDB.Close(); db.DB = prevDB })
return mock
}
@@ -18,8 +18,9 @@ func setupLivenessTestDB(t *testing.T) sqlmock.Sqlmock {
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
prevDB := db.DB
db.DB = mockDB
t.Cleanup(func() { mockDB.Close() })
t.Cleanup(func() { mockDB.Close(); db.DB = prevDB })
return mock
}
@@ -24,8 +24,9 @@ func setupTestDB(t *testing.T) sqlmock.Sqlmock {
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
prevDB := db.DB
db.DB = mockDB
t.Cleanup(func() { mockDB.Close() })
t.Cleanup(func() { mockDB.Close(); db.DB = prevDB })
return mock
}
+7 -3
View File
@@ -686,8 +686,8 @@ def _format_channel_content(
# --- MCP Server (JSON-RPC over stdio) ---
def _warn_if_stdio_not_pipe(stdin_fd: int = 0, stdout_fd: int = 1) -> None:
"""Warn when stdio isn't a pipe — but continue anyway.
def _assert_stdio_is_pipe_compatible(stdin_fd: int = 0, stdout_fd: int = 1) -> None:
"""Assert that stdio fds are pipe/socket/char-device compatible.
The legacy asyncio.connect_read_pipe / connect_write_pipe transport
rejected regular files, PTYs, and sockets with:
@@ -711,6 +711,10 @@ def _warn_if_stdio_not_pipe(stdin_fd: int = 0, stdout_fd: int = 1) -> None:
)
# Deprecated alias — the canonical name is _assert_stdio_is_pipe_compatible.
_warn_if_stdio_not_pipe = _assert_stdio_is_pipe_compatible
async def main(): # pragma: no cover
"""Run MCP server on stdio — reads JSON-RPC requests, writes responses.
@@ -967,7 +971,7 @@ def cli_main(transport: str = "stdio", port: int = 9100) -> None: # pragma: no
if transport == "http":
asyncio.run(_run_http_server(port))
else:
_warn_if_stdio_not_pipe()
_assert_stdio_is_pipe_compatible()
asyncio.run(main())
+10 -10
View File
@@ -1826,8 +1826,8 @@ def test_inbox_bridge_swallows_closed_loop_runtime_error():
class TestStdioPipeAssertion:
"""Pin _warn_if_stdio_not_pipe — the diagnostic warning that replaces
the old fatal _assert_stdio_is_pipe_compatible guard.
"""Pin _assert_stdio_is_pipe_compatible — the canonical function name.
_warn_if_stdio_not_pipe is a deprecated alias.
The universal stdio transport now works with ANY file descriptor
(pipes, regular files, PTYs, sockets), so the old exit-2 behavior
@@ -1838,12 +1838,12 @@ class TestStdioPipeAssertion:
def test_pipe_pair_passes_silently(self, caplog):
"""Happy path — both fds are pipes. No warning emitted."""
from a2a_mcp_server import _warn_if_stdio_not_pipe
from a2a_mcp_server import _assert_stdio_is_pipe_compatible
r, w = os.pipe()
try:
with caplog.at_level("WARNING"):
_warn_if_stdio_not_pipe(stdin_fd=r, stdout_fd=w)
_assert_stdio_is_pipe_compatible(stdin_fd=r, stdout_fd=w)
assert "not a pipe" not in caplog.text
finally:
os.close(r)
@@ -1852,14 +1852,14 @@ class TestStdioPipeAssertion:
def test_regular_file_stdout_warns(self, tmp_path, caplog):
"""Reproducer for runtime#61: stdout redirected to a regular file.
Now emits a warning instead of exiting."""
from a2a_mcp_server import _warn_if_stdio_not_pipe
from a2a_mcp_server import _assert_stdio_is_pipe_compatible
r, _w = os.pipe()
regular = tmp_path / "captured.log"
f = open(regular, "wb")
try:
with caplog.at_level("WARNING"):
_warn_if_stdio_not_pipe(stdin_fd=r, stdout_fd=f.fileno())
_assert_stdio_is_pipe_compatible(stdin_fd=r, stdout_fd=f.fileno())
assert "stdout" in caplog.text
assert "not a pipe" in caplog.text
finally:
@@ -1868,7 +1868,7 @@ class TestStdioPipeAssertion:
def test_regular_file_stdin_warns(self, tmp_path, caplog):
"""Symmetric case — stdin redirected from a regular file."""
from a2a_mcp_server import _warn_if_stdio_not_pipe
from a2a_mcp_server import _assert_stdio_is_pipe_compatible
regular = tmp_path / "input.json"
regular.write_bytes(b'{"jsonrpc":"2.0","id":1,"method":"initialize"}\n')
@@ -1876,7 +1876,7 @@ class TestStdioPipeAssertion:
_r, w = os.pipe()
try:
with caplog.at_level("WARNING"):
_warn_if_stdio_not_pipe(stdin_fd=f.fileno(), stdout_fd=w)
_assert_stdio_is_pipe_compatible(stdin_fd=f.fileno(), stdout_fd=w)
assert "stdin" in caplog.text
assert "not a pipe" in caplog.text
finally:
@@ -1886,13 +1886,13 @@ class TestStdioPipeAssertion:
def test_closed_fd_warns_about_stat_error(self, caplog):
"""If stdio is closed, os.fstat raises OSError. Warning is
skipped silently (can't stat the fd)."""
from a2a_mcp_server import _warn_if_stdio_not_pipe
from a2a_mcp_server import _assert_stdio_is_pipe_compatible
r, w = os.pipe()
os.close(w) # Now `w` is a stale fd — fstat will fail.
try:
with caplog.at_level("WARNING"):
_warn_if_stdio_not_pipe(stdin_fd=r, stdout_fd=w)
_assert_stdio_is_pipe_compatible(stdin_fd=r, stdout_fd=w)
# No warning emitted because fstat failed before the check
assert "not a pipe" not in caplog.text
finally: