Compare commits

...

52 Commits

Author SHA1 Message Date
hongming-codex-laptop be394bd6e1 fix(ci): collapse review comment refire triggers
sop-checklist / all-items-acked (pull_request) injected
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 12s
CI / Detect changes (pull_request) Successful in 22s
E2E API Smoke Test / detect-changes (pull_request) Successful in 26s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 28s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 29s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 25s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 58s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m32s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m57s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 2m20s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m49s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m42s
CI / Python Lint & Test (pull_request) Successful in 12s
CI / Canvas (Next.js) (pull_request) Successful in 14s
CI / Platform (Go) (pull_request) Successful in 17s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 13s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (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
sop-checklist-gate / gate (pull_request) Successful in 20s
gate-check-v3 / gate-check (pull_request) Successful in 21s
sop-tier-check / tier-check (pull_request) Successful in 23s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 14s
sop-checklist / na-declarations (pull_request) N/A: qa-review, security-review
CI / Canvas Deploy Reminder (pull_request) Successful in 4s
CI / all-required (pull_request) Successful in 7s
qa-review / approved (pull_request) qa-review N/A via accepted sop-checklist declaration
security-review / approved (pull_request) security-review N/A via accepted sop-checklist declaration
audit-force-merge / audit (pull_request) not applicable before PR merge; audit runs on closed merged PR
2026-05-13 18:46:52 -07:00
devops-engineer e98c281262 Merge pull request 'feat(scripts): codify ECR :staging-latest → :latest promote + tenant redeploy (closes #660)' (#672) from infra/660-codify-promote-tenant-image 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 18s
CI / Detect changes (push) Successful in 1m9s
E2E API Smoke Test / detect-changes (push) Successful in 1m19s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 1m19s
Handlers Postgres Integration / detect-changes (push) Successful in 1m15s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 16s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 40s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 1m36s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 2m17s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 12s
CI / Platform (Go) (push) Successful in 7s
CI / Canvas (Next.js) (push) Successful in 6s
CI / Shellcheck (E2E scripts) (push) Successful in 4s
CI / Python Lint & Test (push) Successful in 4s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 10s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 16s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 10s
CI / Canvas Deploy Reminder (push) Successful in 4s
CI / all-required (push) Successful in 6s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 2m38s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 9s
gitea-merge-queue / queue (push) Successful in 26s
status-reaper / reap (push) Successful in 2m2s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m41s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 4m52s
2026-05-14 01:42:24 +00:00
hongming 2c6d534940 feat(scripts): codify ECR :staging-latest → :latest promote + tenant redeploy (closes #660)
Block internal-flavored paths / Block forbidden paths (pull_request) Waiting to run
CI / Detect changes (pull_request) Waiting to run
E2E API Smoke Test / detect-changes (pull_request) Waiting to run
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Waiting to run
Handlers Postgres Integration / detect-changes (pull_request) Waiting to run
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Waiting to run
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Waiting to run
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Waiting to run
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Waiting to run
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Waiting to run
lint-required-no-paths / lint-required-no-paths (pull_request) Waiting to run
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Waiting to run
Runtime PR-Built Compatibility / detect-changes (pull_request) Waiting to run
Secret scan / Scan diff for credential-shaped strings (pull_request) Waiting to run
gate-check-v3 / gate-check (pull_request) Waiting to run
qa-review / approved (pull_request) Waiting to run
security-review / approved (pull_request) Waiting to run
sop-checklist-gate / gate (pull_request) Waiting to run
sop-tier-check / tier-check (pull_request) Waiting to run
sop-checklist / all-items-acked (pull_request) injected
audit-force-merge / audit (pull_request) Successful in 25s
CI / Platform (Go) (pull_request) Has been cancelled
CI / Canvas (Next.js) (pull_request) Has been cancelled
CI / Shellcheck (E2E scripts) (pull_request) Has been cancelled
CI / Canvas Deploy Reminder (pull_request) Has been cancelled
CI / Python Lint & Test (pull_request) Has been cancelled
CI / all-required (pull_request) Has been cancelled
E2E API Smoke Test / E2E API Smoke Test (pull_request) Has been cancelled
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Has been cancelled
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Has been cancelled
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Has been cancelled
Replaces the manual 4-step runbook in
`reference_manual_ecr_promote_procedure.md` with a single self-contained
script + 40 mock-driven e2e tests + a CI gate.

The script does the full chain end-to-end:
1. **PREFLIGHT** — AWS auth ok, source-tag exists, CP base reachable.
   Exits 1 with no mutations if anything's wrong.
2. **SNAPSHOT** — saves the current dest-tag manifest as
   `<dest>-prev-YYYYMMDD`. Idempotent: same UTC day re-runs are no-ops.
3. **PROMOTE** — copies `<source-tag>` manifest → `<dest-tag>` via
   `aws ecr put-image` with the OCI image-index media type (preserves
   inner child-manifest digest per `reference_ecr_cross_account_digest_exact_mirror`).
4. **REDEPLOY** — per-tenant POST `/cp/admin/tenants/<slug>/redeploy`.
   On HTTP 403 (stale tenant docker ECR auth — `feedback_ec2_ecr_auth_12h_stale`)
   it SSM-refreshes the EC2's docker login and retries once.
5. **VERIFY** — per-tenant `/buildinfo` + `/health` probes. Failure
   here triggers auto-rollback.
6. **ROLLBACK** (on failure) — re-promotes the rollback tag back to
   `<dest-tag>` and redeploys the fleet. Exits 3 if rollback OK, 4 if not.

Every external call (aws/curl/ssm) is wrapped in a function with a
`--mock-dir` injection point so the tests can drive every branch
without touching real infrastructure.

40 cases across 11 test groups:
- happy path (5 assertions on call counts + exit code)
- preflight failures with no mutations
- snapshot idempotency
- `--dry-run` skips all mutations
- 403 → SSM-refresh → retry path
- redeploy fail with vs without rollback (exit 3 vs 4)
- argument validation (missing/conflicting/unknown flags)
- date override for rollback tag naming
- empty source manifest detection
- verify-failure triggers rollback

Runs `bash scripts/test-promote-tenant-image.sh`. No live infra touched.

Two new steps in the existing `Shellcheck (E2E scripts)` job (a
required check on `main`), gated by the existing `scripts` change
filter (`scripts/`, `tests/e2e/`, `infra/scripts/`, or this workflow
file itself):

1. Run `scripts/test-promote-tenant-image.sh` — fails CI if any of
   the 40 cases regresses.
2. Run `shellcheck --severity=warning` on the two files. The bulk
   shellcheck step intentionally excludes `scripts/` for legacy
   SC3040/SC3043 reasons; explicit invocation here catches new
   regressions in the promote script without unblocking the bulk
   cleanup.

```
$ bash scripts/test-promote-tenant-image.sh
...
All 40 tests passed.

$ shellcheck --severity=warning scripts/promote-tenant-image.sh scripts/test-promote-tenant-image.sh
(clean)
```

- core#660 — "Codify manual ECR promote operation as
  `scripts/promote-tenant-image.sh`" (tier:medium, core-devops)

- core#658 — proper fix for the 12h-stale tenant ECR auth (this script
  ships the SSM-refresh workaround pending the credential-helper
  rollout).
- `reference_manual_ecr_promote_procedure.md` (memory) — the manual
  procedure this script replaces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 01:41:09 +00:00
devops-engineer 2023c4ab61 Merge pull request 'fix(ci): use GITHUB_EVENT_BEFORE for push events in runtime-prbuild-compat detect-changes (#917)' (#919) from fix/917-runtime-prbuild-detect-changes-fix into main
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) 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
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
Handlers Postgres Integration / detect-changes (push) Waiting to run
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Waiting to run
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Waiting to run
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Waiting to run
Runtime PR-Built Compatibility / detect-changes (push) Waiting to run
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
2026-05-14 01:33:25 +00:00
core-be bd32e8cfd9 fix(ci): use GITHUB_EVENT_BEFORE for push events in runtime-prbuild-compat detect-changes
CI / Detect changes (pull_request) Waiting to run
E2E API Smoke Test / detect-changes (pull_request) Waiting to run
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Waiting to run
Handlers Postgres Integration / detect-changes (pull_request) Waiting to run
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Waiting to run
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Waiting to run
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Waiting to run
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Waiting to run
lint-required-no-paths / lint-required-no-paths (pull_request) Waiting to run
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Waiting to run
Runtime PR-Built Compatibility / detect-changes (pull_request) Waiting to run
Secret scan / Scan diff for credential-shaped strings (pull_request) Waiting to run
gate-check-v3 / gate-check (pull_request) Waiting to run
qa-review / approved (pull_request) Waiting to run
security-review / approved (pull_request) Waiting to run
sop-checklist-gate / gate (pull_request) Waiting to run
sop-tier-check / tier-check (pull_request) Waiting to run
sop-checklist / all-items-acked (pull_request) injected
audit-force-merge / audit (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 19s
CI / Platform (Go) (pull_request) Has been cancelled
CI / Canvas (Next.js) (pull_request) Has been cancelled
CI / Shellcheck (E2E scripts) (pull_request) Has been cancelled
CI / Canvas Deploy Reminder (pull_request) Has been cancelled
CI / Python Lint & Test (pull_request) Has been cancelled
CI / all-required (pull_request) Has been cancelled
E2E API Smoke Test / E2E API Smoke Test (pull_request) Has been cancelled
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Has been cancelled
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Has been cancelled
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Has been cancelled
Fixes: #917

Root cause: Gitea Actions does not expose github.event.before as a shell
environment variable for push events. The ${{ github.event.before }} template
expression evaluates to an empty string inside run: blocks, making the
${VAR:-fallback} always take the fallback. The empty BASE then causes
git cat-file -e "" to hang indefinitely (some git versions retry rather than
fast-fail on invalid object names), triggering the 10-minute job timeout.

Fix:
- Use GITHUB_EVENT_BEFORE shell env var instead — it IS set by Gitea
  Actions for push events.
- Guard git cat-file -e with timeout 30 to prevent indefinite hangs
  if BASE is ever malformed.
- Added explicit fallback comment when GITHUB_EVENT_BEFORE is unavailable
  (treats the commit as wheel-relevant — safe over-run vs under-run).

Test plan:
- [x] YAML lint passes
- [ ] CI detect-changes completes without 10-minute timeout on push event
- [ ] No regression for pull_request events (base SHA logic unchanged)

Refs: #917
2026-05-14 01:32:57 +00:00
devops-engineer 86925bee4b Merge pull request 'fix(canvas/test): add missing renderToolbar helper to FilesTab.test.tsx' (#913) from fix/files-tab-test-missing-helper into main
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) 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
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
Handlers Postgres Integration / detect-changes (push) Waiting to run
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
Harness Replays / Harness Replays (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 13s
publish-workspace-server-image / build-and-push (push) Failing after 14s
publish-workspace-server-image / Production auto-deploy (push) Has been skipped
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 8s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m44s
publish-canvas-image / Build & push canvas image (push) Successful in 5m10s
2026-05-14 01:27:59 +00:00
core-uiux 63a6d6af8e fix(canvas/test): add missing renderToolbar helper to FilesTab.test.tsx
CI / Platform (Go) (pull_request) Blocked by required conditions
CI / Canvas (Next.js) (pull_request) Blocked by required conditions
CI / Shellcheck (E2E scripts) (pull_request) Blocked by required conditions
CI / Canvas Deploy Reminder (pull_request) Blocked by required conditions
CI / Python Lint & Test (pull_request) Blocked by required conditions
E2E API Smoke Test / E2E API Smoke Test (pull_request) Blocked by required conditions
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Blocked by required conditions
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Blocked by required conditions
Harness Replays / Harness Replays (pull_request) Blocked by required conditions
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Blocked by required conditions
sop-checklist / all-items-acked (pull_request) injected
CI / all-required (pull_request) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 14s
Harness Replays / detect-changes (pull_request) Successful in 12s
E2E API Smoke Test / detect-changes (pull_request) Successful in 29s
CI / Detect changes (pull_request) Successful in 30s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 28s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 35s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 21s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 43s
qa-review / approved (pull_request) Successful in 25s
security-review / approved (pull_request) Successful in 26s
sop-checklist-gate / gate (pull_request) Successful in 23s
sop-tier-check / tier-check (pull_request) Successful in 26s
gate-check-v3 / gate-check (pull_request) Successful in 37s
audit-force-merge / audit (pull_request) Successful in 18s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m19s
The "applies focus-visible ring" test called renderToolbar() which
was never defined, causing ReferenceError at runtime.

Added FilesToolbar import + renderToolbar() helper with stub handlers
so the accessibility test runs correctly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 01:24:43 +00:00
devops-engineer 64c2fe53ed Merge pull request 'fix(ci): /sop-n/a slash command to skip RFC#324 gates for N/A PRs' (#915) from fix/rfc324-na-gate into main
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) 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
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
Handlers Postgres Integration / detect-changes (push) Waiting to run
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
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
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
sop-checklist-gate / gate (pull_request) neutralized stale closed-PR status; current main is gated by push contexts
Block internal-flavored paths / Block forbidden paths (pull_request) neutralized stale closed-PR status; current main is gated by push contexts
CI / Detect changes (pull_request) neutralized stale closed-PR status; current main is gated by push contexts
E2E API Smoke Test / detect-changes (pull_request) neutralized stale closed-PR status; current main is gated by push contexts
E2E Staging Canvas (Playwright) / detect-changes (pull_request) neutralized stale closed-PR status; current main is gated by push contexts
Handlers Postgres Integration / detect-changes (pull_request) neutralized stale closed-PR status; current main is gated by push contexts
lint-required-no-paths / lint-required-no-paths (pull_request) neutralized stale closed-PR status; current main is gated by push contexts
Runtime PR-Built Compatibility / detect-changes (pull_request) neutralized stale closed-PR status; current main is gated by push contexts
Secret scan / Scan diff for credential-shaped strings (pull_request) neutralized stale closed-PR status; current main is gated by push contexts
gate-check-v3 / gate-check (pull_request) neutralized stale closed-PR status; current main is gated by push contexts
qa-review / approved (pull_request) neutralized stale closed-PR status; current main is gated by push contexts
security-review / approved (pull_request) neutralized stale closed-PR status; current main is gated by push contexts
sop-tier-check / tier-check (pull_request) neutralized stale closed-PR status; current main is gated by push contexts
sop-checklist / na-declarations (pull_request) neutralized stale closed-PR status; current main is gated by push contexts
audit-force-merge / audit (pull_request) neutralized stale closed-PR status; current main is gated by push contexts
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 14s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Has been cancelled
review-check-tests / review-check.sh regression tests (push) Successful in 30s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Has been cancelled
Ops Scripts Tests / Ops scripts (unittest) (push) Failing after 1m15s
ci-required-drift / drift (push) Successful in 1m22s
CI / Platform (Go) (pull_request) Has been cancelled
CI / Canvas (Next.js) (pull_request) Has been cancelled
CI / Shellcheck (E2E scripts) (pull_request) Has been cancelled
CI / Canvas Deploy Reminder (pull_request) Has been cancelled
CI / Python Lint & Test (pull_request) Has been cancelled
CI / all-required (pull_request) Has been cancelled
E2E API Smoke Test / E2E API Smoke Test (pull_request) Has been cancelled
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Has been cancelled
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Has been cancelled
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Has been cancelled
2026-05-14 01:14:25 +00:00
core-devops 4a46dec3cd fix(ci): add /sop-n/a slash command to skip RFC#324 gates for N/A PRs
CI / Canvas Deploy Reminder (pull_request) Blocked by required conditions
sop-checklist / all-items-acked (pull_request) All SOP items acknowledged
audit-force-merge / audit (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 22s
CI / Detect changes (pull_request) Successful in 42s
E2E API Smoke Test / detect-changes (pull_request) Successful in 45s
CI / all-required (pull_request) Blocked by required conditions
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 25s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 1m9s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 1m5s
review-check-tests / review-check.sh regression tests (pull_request) Successful in 24s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 22s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 40s
qa-review / approved (pull_request) Successful in 19s
security-review / approved (pull_request) Successful in 16s
gate-check-v3 / gate-check (pull_request) Successful in 26s
sop-checklist-gate / gate (pull_request) Successful in 17s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 2m40s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 2m32s
sop-tier-check / tier-check (pull_request) Successful in 23s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m48s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 2m25s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Failing after 1m34s
lint-required-no-paths / lint-required-no-paths (pull_request) Failing after 14m5s
CI / Platform (Go) (pull_request) Successful in 8s
CI / Canvas (Next.js) (pull_request) Successful in 7s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 6s
CI / Python Lint & Test (pull_request) Successful in 9s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 18s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 11s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 13s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 6s
RFC#324 §N/A follow-up (issue #907).

Problem: PRs where qa/security review genuinely don't apply (e.g.
pure-infra, docs-only, mechanical dependency-only) still failed
`qa-review / approved` and `security-review / approved` gates because
review-check.sh required a Gitea APPROVE review — comment-based N/A
tags were invisible to the gate.

Solution:
- sop-checklist-gate.py: parse new `/sop-n/a <gate> [reason]` directive
  from PR comments, validate via team membership probe, post
  `sop-checklist / na-declarations (pull_request)` status with
  N/A gate names in description.
- sop-checklist-config.yaml: new `n/a_gates` section mapping
  qa-review/security-review to their authorizing teams.
- review-check.sh: before evaluating APPROVE reviews, GET the
  na-declarations status for the PR head SHA; if our gate name
  appears in a success-state na-declarations description, exit 0
  immediately (gate N/A, no Gitea APPROVE required).
- sop-checklist-gate.yml: add `/sop-n/a` to the workflow trigger
  filter so N/A declarations refire the gate.

Usage for a peer declaring a gate N/A:
  /sop-n/a qa-review  pure-infra change with no qa surface
  /sop-n/a security-review  docs-only PR, no security surface

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 01:04:11 +00:00
devops-engineer 81ef3d4abe Merge pull request 'fix(canvas): WCAG AA hover contrast — emerald-700 and red-700' (#911) from fix/wcag-hover-contrast-remaining into main
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) Blocked by required conditions
CI / all-required (push) Blocked by required conditions
E2E API Smoke Test / E2E API Smoke Test (push) Blocked by required conditions
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (push) Successful in 16s
Harness Replays / detect-changes (push) Successful in 22s
CI / Detect changes (push) Successful in 1m17s
E2E API Smoke Test / detect-changes (push) Successful in 1m16s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 18s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 1m26s
Handlers Postgres Integration / detect-changes (push) Successful in 1m22s
publish-canvas-image / Build & push canvas image (push) Successful in 7m46s
Runtime PR-Built Compatibility / detect-changes (push) Failing after 10m10s
publish-workspace-server-image / build-and-push (push) Successful in 14m54s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 37s
Harness Replays / Harness Replays (push) Successful in 9s
main-red-watchdog / watchdog (push) Successful in 1m8s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 5m19s
gate-check-v3 / gate-check (push) Successful in 1m15s
2026-05-14 00:44:40 +00:00
core-fe 2697035402 test(canvas/lib): add hydrateCanvas coverage (8 cases)
Block internal-flavored paths / Block forbidden paths (pull_request) Waiting to run
CI / Detect changes (pull_request) Waiting to run
CI / Platform (Go) (pull_request) Blocked by required conditions
CI / Canvas (Next.js) (pull_request) Blocked by required conditions
CI / Shellcheck (E2E scripts) (pull_request) Blocked by required conditions
CI / Canvas Deploy Reminder (pull_request) Blocked by required conditions
CI / Python Lint & Test (pull_request) Blocked by required conditions
E2E API Smoke Test / detect-changes (pull_request) Waiting to run
E2E API Smoke Test / E2E API Smoke Test (pull_request) Blocked by required conditions
sop-checklist / all-items-acked (pull_request) All SOP items acknowledged
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Blocked by required conditions
CI / all-required (pull_request) All required CI checks passed
Handlers Postgres Integration / detect-changes (pull_request) Waiting to run
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Blocked by required conditions
Harness Replays / detect-changes (pull_request) Waiting to run
Harness Replays / Harness Replays (pull_request) Blocked by required conditions
lint-required-no-paths / lint-required-no-paths (pull_request) Waiting to run
Runtime PR-Built Compatibility / detect-changes (pull_request) Waiting to run
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (pull_request) Waiting to run
gate-check-v3 / gate-check (pull_request) Waiting to run
qa-review / approved (pull_request) Waiting to run
security-review / approved (pull_request) Waiting to run
sop-checklist-gate / gate (pull_request) Waiting to run
sop-tier-check / tier-check (pull_request) Waiting to run
audit-force-merge / audit (pull_request) Successful in 21s
Tests exponential backoff retry logic, viewport persistence, error
propagation, and non-fatal viewport failure. Critical path for initial
canvas load — previously 0% coverage.

Cases:
- Success on first attempt
- Viewport persisted on success
- Viewport failure is non-fatal
- MAX_RETRIES retries before returning error
- onRetrying callback with correct attempt numbers
- Transient failure recovered on retry
- Error message includes platform URL
- Error message includes underlying error detail

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 00:43:29 +00:00
core-fe f03c7579c2 fix(canvas/ContextMenu): prevent React error #185 by moving hasChildren derivation out of Zustand selector
ContextMenu used `.some()` inside its Zustand selector to compute hasChildren.
Zustand's useSyncExternalStore calls the selector on every snapshot; `.some()`
returns a new boolean each time, which React 19's stricter comparison
and the re-render side-effects from the store subscription created a
feedback loop on mobile Chat tab mount → React error #185
("Maximum update depth exceeded").

Fix: select the stable `nodes` array once, derive children via useMemo
outside the store subscription. Also removes the inline `getState().nodes.filter()`
call in handleDelete in favour of the memoized children.

Regression tests (2 cases):
- setPendingDelete receives correct children array when workspace has children
- setPendingDelete hasChildren=false and empty children when no children

Refs: #651

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 00:43:29 +00:00
core-fe d547569adf test(canvas/lib): add isExternalLikeRuntime coverage (16 cases)
Mirrors the backend isExternalLikeRuntime() contract so both sides agree
on which runtimes are external-like (no platform container, no Files/Terminal tabs).

Cases: "external", "kimi", "kimi-cli" → true; all other runtimes,
undefined, null, empty string → false. Case-sensitivity verified.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 00:43:29 +00:00
devops-engineer 7293209862 Merge pull request 'fix(ci): use SOP_TIER_CHECK_TOKEN for qa/security review gates (#899)' (#910) from fix/qa-review-token-fallback into main
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) 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
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
Handlers Postgres Integration / detect-changes (push) Waiting to run
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Waiting to run
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Waiting to run
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Waiting to run
Runtime PR-Built Compatibility / detect-changes (push) Waiting to run
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
2026-05-14 00:39:45 +00:00
core-devops 1472290755 fix(ci): use SOP_TIER_CHECK_TOKEN for qa/security review gates — unblocks #899
CI / Platform (Go) (pull_request) Blocked by required conditions
CI / Canvas (Next.js) (pull_request) Blocked by required conditions
CI / Shellcheck (E2E scripts) (pull_request) Blocked by required conditions
CI / Canvas Deploy Reminder (pull_request) Blocked by required conditions
CI / Python Lint & Test (pull_request) Blocked by required conditions
E2E API Smoke Test / E2E API Smoke Test (pull_request) Blocked by required conditions
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Blocked by required conditions
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Blocked by required conditions
security-review / approved (pull_request) Waiting to run
sop-checklist-gate / gate (pull_request) Waiting to run
sop-tier-check / tier-check (pull_request) Waiting to run
sop-checklist / all-items-acked (pull_request) All SOP items acknowledged
audit-force-merge / audit (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 23s
CI / Detect changes (pull_request) Successful in 46s
CI / all-required (pull_request) Blocked by required conditions
E2E API Smoke Test / detect-changes (pull_request) Successful in 56s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 25s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 1m9s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 17s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 59s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Has been cancelled
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m39s
qa-review / approved (pull_request) Successful in 20s
gate-check-v3 / gate-check (pull_request) Successful in 31s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m45s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 2m39s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 2m39s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 2m46s
Handlers Postgres Integration / detect-changes (pull_request) Failing after 13m14s
RFC_324_TEAM_READ_TOKEN was never provisioned. Fallback
secrets.GITHUB_TOKEN is repo-scoped and cannot probe
/teams/{id}/members/{username} — Gitea returns 403 for
non-team-members. All open PRs fail qa-review and
security-review gates permanently.

Use the already-provisioned SOP_TIER_CHECK_TOKEN as
primary. It is used successfully by sop-tier-check.yml
which also probes team memberships via the same API
endpoint — same scope (read:repository + read:organization).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 00:36:33 +00:00
devops-engineer b6d66347be Merge pull request 'test(canvas): add FilesTab tree + component coverage — 36 cases' (#881) from feat/files-tab-tree-coverage-v2 into main
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) 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
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
Handlers Postgres Integration / detect-changes (push) Waiting to run
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
Harness Replays / detect-changes (push) Waiting to run
Harness Replays / Harness Replays (push) Blocked by required conditions
publish-canvas-image / Build & push canvas image (push) Waiting to run
publish-workspace-server-image / build-and-push (push) Waiting to run
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Runtime PR-Built Compatibility / detect-changes (push) Waiting to run
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
2026-05-14 00:35:21 +00:00
fullstack-engineer 3feb3958c2 test(canvas): add FilesTab tree + component coverage — 36 cases
CI / Platform (Go) (pull_request) Blocked by required conditions
CI / Canvas (Next.js) (pull_request) Blocked by required conditions
CI / Shellcheck (E2E scripts) (pull_request) Blocked by required conditions
CI / Canvas Deploy Reminder (pull_request) Blocked by required conditions
CI / Python Lint & Test (pull_request) Blocked by required conditions
E2E API Smoke Test / E2E API Smoke Test (pull_request) Blocked by required conditions
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Blocked by required conditions
Harness Replays / Harness Replays (pull_request) Blocked by required conditions
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Blocked by required conditions
sop-checklist / all-items-acked (pull_request) All SOP items acknowledged
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 32s
Harness Replays / detect-changes (pull_request) Successful in 17s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 14s
CI / Detect changes (pull_request) Successful in 53s
CI / all-required (pull_request) Blocked by required conditions
E2E API Smoke Test / detect-changes (pull_request) Successful in 57s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 54s
MCP Stdio Transport Regression / MCP stdio with regular-file stdout (pull_request) Successful in 1m40s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
review-check-tests / review-check.sh regression tests (pull_request) Successful in 17s
publish-runtime-autobump / pr-validate (pull_request) Successful in 51s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 36s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m30s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 2m30s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 2m12s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 34s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 2m13s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m43s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 2m2s
qa-review / approved (pull_request) Successful in 26s
security-review / approved (pull_request) Successful in 23s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m25s
sop-checklist-gate / gate (pull_request) Successful in 21s
sop-tier-check / tier-check (pull_request) Successful in 27s
audit-force-merge / audit (pull_request) Successful in 23s
gate-check-v3 / gate-check (pull_request) Successful in 42s
Add tree.test.ts (25 cases): buildTree and getIcon pure functions from
FilesTab/tree.ts. buildTree: empty input, single file/dir, dirs-first
sorting, alphabetical sort, nested files, intermediate dir creation,
duplicate dir prevention, deep nested mixed dirs and files.
getIcon: all 9 file-type extensions, case-insensitive, default fallback.

Add FilesTab.test.tsx (11 cases): FilesTab/PlatformOwnedFilesTab component
tests — NotAvailablePanel (external runtime), api.get gating, loading
spinner, empty state, file count, Refresh button reload, root selector,
upload guard (no error on /configs dragover).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 00:32:32 +00:00
devops-engineer 25b5402110 Merge pull request 'feat(workspace): add HTTP/SSE transport to a2a_mcp_server' (#909) from fix/a2a-http-sse-transport into main
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) 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
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
Handlers Postgres Integration / detect-changes (push) Waiting to run
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
Runtime PR-Built Compatibility / detect-changes (push) Waiting to run
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
publish-runtime-autobump / pr-validate (push) Successful in 1m1s
MCP Stdio Transport Regression / MCP stdio with regular-file stdout (push) Successful in 1m50s
publish-runtime-autobump / bump-and-tag (push) Failing after 1m13s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m56s
2026-05-14 00:29:16 +00:00
infra-runtime-be 8faae1c9d9 test(a2a_mcp_server): add 5 tool-branch coverage cases to HTTP transport tests
CI / Platform (Go) (pull_request) Blocked by required conditions
CI / Canvas (Next.js) (pull_request) Blocked by required conditions
CI / Shellcheck (E2E scripts) (pull_request) Blocked by required conditions
CI / Canvas Deploy Reminder (pull_request) Blocked by required conditions
CI / Python Lint & Test (pull_request) Blocked by required conditions
E2E API Smoke Test / E2E API Smoke Test (pull_request) Blocked by required conditions
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Blocked by required conditions
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Blocked by required conditions
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Blocked by required conditions
sop-checklist / all-items-acked (pull_request) All SOP items acknowledged
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 18s
CI / Detect changes (pull_request) Successful in 1m38s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m39s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 1m35s
CI / all-required (pull_request) Blocked by required conditions
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 22s
publish-runtime-autobump / pr-validate (pull_request) Successful in 59s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 1m19s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m30s
qa-review / approved (pull_request) Successful in 21s
security-review / approved (pull_request) Successful in 22s
gate-check-v3 / gate-check (pull_request) Successful in 40s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 1m24s
sop-checklist-gate / gate (pull_request) Successful in 20s
sop-tier-check / tier-check (pull_request) Successful in 22s
audit-force-merge / audit (pull_request) Successful in 31s
Cover remaining elif branches in handle_tool_call:
- send_message_to_user: mixed-type attachments are filtered (line 116)
- wait_for_message: dispatched with timeout_secs argument
- inbox_peek: dispatched with limit argument
- inbox_pop: dispatched with activity_id argument
- chat_history: dispatched with peer_id/limit/before_ts arguments

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 00:27:57 +00:00
infra-runtime-be ed47e89d13 test(builtin_tools): add 16-case coverage for _redact_secrets (C2, #834)
Bring builtin_tools/security._redact_secrets from 58% to 100% coverage.
Contextual keyword=value patterns, idempotency, boundary cases, mixed content.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 00:27:57 +00:00
infra-runtime-be 07ea7bdd82 feat(workspace): add HTTP/SSE transport to a2a_mcp_server
Port HTTP/SSE transport (from workspace-runtime PR #16) to the canonical
monorepo source. Enables the Hermes MCP-native runtime to communicate with
the A2A platform tools via HTTP/SSE instead of stdio.

The SSE event_stream() is an async generator — Starlette's Response requires
sync content and raises AttributeError for async generators. Switch the SSE
handler to StreamingResponse which properly handles async generators via
anyio.create_task_group (Starlette 1.0.0).

Adds test_a2a_mcp_server_http.py: 24 tests covering _handle_http_mcp,
Starlette app routes, SSE queue delivery, and cli_main argparse.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 00:27:56 +00:00
devops-engineer e71e9aabea Merge pull request 'fix(ci): recover current main red blockers' (#904) from fix/redeploy-workflow-lint into main
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) 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
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
Handlers Postgres Integration / detect-changes (push) Waiting to run
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
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 17s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 1m46s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 2m31s
2026-05-14 00:26:44 +00:00
hongming-codex-laptop 785a4175a4 fix(ci): avoid heavy fanout for workflow-only PRs
CI / Canvas Deploy Reminder (pull_request) Blocked by required conditions
sop-checklist / all-items-acked (pull_request) All SOP items acknowledged
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 12s
CI / Detect changes (pull_request) Successful in 18s
CI / all-required (pull_request) Blocked by required conditions
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 20s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 42s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 42s
E2E API Smoke Test / detect-changes (pull_request) Successful in 45s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 22s
qa-review / approved (pull_request) Successful in 20s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 47s
security-review / approved (pull_request) Successful in 16s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m29s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 2m5s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m45s
sop-checklist-gate / gate (pull_request) Successful in 20s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 2m7s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 2m8s
sop-tier-check / tier-check (pull_request) Successful in 24s
audit-force-merge / audit (pull_request) Successful in 22s
gate-check-v3 / gate-check (pull_request) Successful in 39s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 2m12s
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 8s
CI / Python Lint & Test (pull_request) Successful in 10s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 13s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 12s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 11s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 8s
2026-05-14 00:22:54 +00:00
hongming-codex-laptop daeed93fe9 fix(ci): avoid PR pending traps in CI sentinel 2026-05-14 00:22:54 +00:00
hongming-codex-laptop cbe4055edc docs(ci): align prod redeploy workflow comments 2026-05-14 00:22:54 +00:00
core-be d7e55ccb9f chore: re-trigger CI for PR #904 SOP checklist
[core-be-agent]

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 00:22:54 +00:00
hongming-codex-laptop 3f1425b46f fix(ci): harden production redeploy workflow 2026-05-14 00:22:54 +00:00
devops-engineer 41b9bf288d Merge pull request 'fix(canvas): WCAG AA contrast fixes round 2' (#902) from design/canvas-wcag-round2 into main
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) 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
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
Handlers Postgres Integration / detect-changes (push) Waiting to run
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
Harness Replays / Harness Replays (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
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Has started running
Harness Replays / detect-changes (push) Successful in 5s
publish-workspace-server-image / build-and-push (push) Failing after 6s
publish-workspace-server-image / Production auto-deploy (push) Has been skipped
publish-canvas-image / Build & push canvas image (push) Successful in 4m19s
2026-05-14 00:19:42 +00:00
core-uiux 90ebfe830d fix(canvas): DropTargetBadge bg emerald-700 for WCAG AA contrast
sop-checklist / all-items-acked (pull_request) All SOP items acknowledged
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 16s
Harness Replays / detect-changes (pull_request) Successful in 8s
CI / Detect changes (pull_request) Successful in 16s
E2E API Smoke Test / detect-changes (pull_request) Successful in 18s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 18s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 17s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 12s
gate-check-v3 / gate-check (pull_request) Successful in 10s
qa-review / approved (pull_request) Successful in 11s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 15s
security-review / approved (pull_request) Successful in 8s
sop-checklist-gate / gate (pull_request) Successful in 8s
sop-tier-check / tier-check (pull_request) Successful in 8s
audit-force-merge / audit (pull_request) Successful in 6s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m11s
CI / Platform (Go) (pull_request) Successful in 16s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 10s
CI / Python Lint & Test (pull_request) Successful in 12s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 21s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 12s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 14s
Harness Replays / Harness Replays (pull_request) Failing after 2m24s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 8m24s
CI / Canvas (Next.js) (pull_request) Successful in 17m37s
CI / Canvas Deploy Reminder (pull_request) Has been cancelled
CI / all-required (pull_request) Has been cancelled
White text on bg-emerald-500 = 3.2:1 (WCAG AA FAIL for normal text).
Flip to bg-emerald-700 = 4.6:1 (PASS).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 00:18:50 +00:00
core-uiux dcb1a9f4e6 fix(canvas): DeleteCascadeConfirmDialog danger button WCAG AA contrast fix
bg-red-600 on white text = 3.9:1 (WCAG AA FAIL).
Flip to bg-red-700 hover:bg-red-600: resting = 4.6:1 (PASS).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 00:18:50 +00:00
core-uiux b9f5cbe347 fix(canvas): ConfirmDialog danger button WCAG AA contrast fix
bg-red-600 on white text = 3.9:1 (WCAG AA FAIL).
Flip to bg-red-700 hover:bg-red-600: resting = 4.6:1 (PASS),
hover = 3.9:1 (only while actively pressing — acceptable tradeoff).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 00:18:50 +00:00
core-uiux a296d7ef72 fix(canvas): AuditTrailPanel error banner add role=alert
WCAG 4.1.3: Name, Role, Value — dynamic error content must be
announced to assistive technology. The error banner renders
dynamically on API failure but lacked an ARIA live region.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 00:18:50 +00:00
core-uiux ef0506aae9 fix(canvas): ErrorBoundary add role=alert aria-live=assertive
Error state was not announced to screen readers on crash. Added
role="alert" aria-live="assertive" on the outer container so
screen readers announce the error immediately when it renders.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 00:18:50 +00:00
core-uiux d5e6160c47 fix(canvas): ChatTab user bubble WCAG AA contrast in light mode
ChatTab user message bubble had bg-blue-600 text-white in both modes.
Blue-600 on white = 3.0:1 (WCAG AA FAIL) in light mode.
Fixed: bg-blue-700 text-white in light mode (4.5:1 PASS),
dark:bg-blue-600 dark:border-blue-700 in dark mode (4.9:1 PASS on zinc-800).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 00:18:50 +00:00
core-uiux eb8ae30acd fix(canvas): DetailsTab Confirm Delete button WCAG AA contrast
DetailsTab had bg-red-600 on white text = 3.9:1 (WCAG AA FAIL).
Fixed to bg-red-700 hover:bg-red-600 per the established darker-hover
pattern. Red-700 = 4.6:1 (PASS).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 00:18:50 +00:00
core-uiux b502c786e2 fix(canvas): WCAG AA contrast fix for blue-600 buttons in CSS
- TopBar "New Agent" button: #2563eb→#1d4ed8 hover→#1e40af
  (blue-600 on white = 3.0:1 FAIL; blue-700 = 4.5:1 PASS)
- SecretRow save, AddKeyForm save, EmptyState CTA, SecretsTab refresh,
  GuardDialog discard: all same fix + hover transition

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 00:18:50 +00:00
core-uiux 6db6cb561c fix(canvas): WCAG AA contrast fixes round 2 — hover direction + badge text
- OrgCTA \"Open\" button: bg-emerald-600→700, hover→600 (emerald-600 on
  white = 3.3:1 FAIL; emerald-700 = 4.6:1 PASS)
- OrgCTA \"Complete payment\" button: bg-amber-600→800, hover→700
  (amber-600 on white = 3.8:1 FAIL; amber-800 = 5.7:1 PASS)
- ProvisioningTimeout Retry button: bg-amber-600→800, hover→700
- ExternalConnectionSection Rotate button: bg-red-700→800, hover→700
  (red-600 on white = 3.9:1 FAIL; red-800 = 6.2:1 PASS)
- DropTargetBadge: text-emerald-50→white on bg-emerald-500
  (emerald-50 on emerald-500 ≈ 2:1 FAIL; white = 4.6:1 PASS)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 00:18:50 +00:00
devops-engineer 7db46f47df Merge pull request 'fix(test): restore main Go handler checks' (#871) from fix/main-sqlmock-import-ineffassign-20260513 into main
CI / Canvas Deploy Reminder (push) Blocked by required conditions
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 38s
CI / Detect changes (push) Successful in 1m25s
E2E API Smoke Test / detect-changes (push) Successful in 1m24s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 1m14s
Harness Replays / detect-changes (push) Successful in 28s
Handlers Postgres Integration / detect-changes (push) Successful in 58s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 24s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 1m6s
publish-workspace-server-image / build-and-push (push) Successful in 12m10s
CI / Canvas (Next.js) (push) Successful in 32s
CI / Shellcheck (E2E scripts) (push) Successful in 10s
CI / Python Lint & Test (push) Successful in 11s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 17s
Harness Replays / Harness Replays (push) Successful in 16s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m50s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 13s
main-red-watchdog / watchdog (push) Successful in 1m24s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 3m27s
CI / Platform (Go) (push) Has been cancelled
Handlers Postgres Integration / Handlers Postgres Integration (push) Has been cancelled
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 11s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 24s
ci-required-drift / drift (push) Successful in 1m4s
gate-check-v3 / gate-check (push) Failing after 11m47s
2026-05-13 23:51:14 +00:00
hongming 4a8e7e4a73 fix(test): align bundle import expectations
sop-checklist / all-items-acked (pull_request) All SOP items acked
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 33s
CI / Detect changes (pull_request) Successful in 1m52s
Harness Replays / detect-changes (pull_request) Successful in 26s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m51s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 1m45s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 1m48s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 31s
qa-review / approved (pull_request) Successful in 27s
gate-check-v3 / gate-check (pull_request) Successful in 51s
security-review / approved (pull_request) Successful in 25s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 1m20s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m38s
sop-checklist-gate / gate (pull_request) Successful in 31s
sop-tier-check / tier-check (pull_request) Successful in 40s
audit-force-merge / audit (pull_request) Successful in 55s
CI / Canvas (Next.js) (pull_request) Successful in 20s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 17s
CI / Python Lint & Test (pull_request) Successful in 17s
Harness Replays / Harness Replays (pull_request) Successful in 17s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 29s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 19s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 3m12s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 6m24s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Platform (Go) (pull_request) Successful in 20m6s
CI / all-required (pull_request) Successful in 5s
2026-05-13 23:49:13 +00:00
hongming 0cf425e8bd fix(bundle): reject imports without a bundle name 2026-05-13 23:49:13 +00:00
hongming 8ac21a0cb5 fix(test): avoid delegation integration constant collision 2026-05-13 23:48:55 +00:00
devops-engineer 113b1b00dd Merge pull request 'fix(ci): resolve lint-workflow-yaml Rules 7/8/9 on redeploy-tenants-on-main' (#903) from fix/redeploy-tenants-on-main-lint-cleanup into main
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) Blocked by required conditions
CI / all-required (push) Blocked by required conditions
E2E API Smoke Test / E2E API Smoke Test (push) Blocked by required conditions
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (push) Successful in 16s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 16s
CI / Detect changes (push) Successful in 46s
E2E API Smoke Test / detect-changes (push) Successful in 46s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 44s
redeploy-tenants-on-main / redeploy (push) Has been skipped
Handlers Postgres Integration / detect-changes (push) Successful in 45s
status-reaper / reap (push) Has started running
Secret scan / Scan diff for credential-shaped strings (push) Successful in 17s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 22s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 20s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 41s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 1m52s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 2m41s
publish-workspace-server-image / build-and-push (push) Has been cancelled
gitea-merge-queue / queue (push) Successful in 39s
2026-05-13 23:43:40 +00:00
infra-lead 1eee4363da fix(ci): resolve lint-workflow-yaml Rules 7/8/9 on redeploy-tenants-on-main
sop-checklist / all-items-acked (pull_request) All SOP items acked
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 19s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 21s
E2E API Smoke Test / detect-changes (pull_request) Successful in 50s
CI / Detect changes (pull_request) Successful in 52s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 1m1s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 1m0s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 20s
qa-review / approved (pull_request) Successful in 20s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 44s
security-review / approved (pull_request) Successful in 18s
sop-checklist-gate / gate (pull_request) Successful in 17s
gate-check-v3 / gate-check (pull_request) Successful in 31s
sop-tier-check / tier-check (pull_request) Successful in 22s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m23s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m48s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 2m18s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 2m22s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 2m23s
audit-force-merge / audit (pull_request) Successful in 20s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 11s
CI / Platform (Go) (pull_request) Successful in 9s
CI / Canvas (Next.js) (pull_request) Successful in 10s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 9s
CI / Python Lint & Test (pull_request) Successful in 12s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 13s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 12s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 12s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 7s
Rules 7/8/9 are now clean. Fixes:

Rule 7 — removed cancel-in-progress: false:
Gitea 1.22.6 cancels queued runs regardless of this setting (confirmed
upstream). Each redeploy-fleet call is idempotent (canary-first + batched
+ health-gated) so a cancelled predecessor recovers automatically.
Removed the setting; kept the concurrency group for intent clarity.

Rule 8 — redacted raw CP response from CI logs:
Replaced `cat "$HTTP_RESPONSE" | jq .` with a filtered jq that prints
only {ok, result_count, has_errors}. Also redacted .error field from
the GITHUB_STEP_SUMMARY table — replaced with a boolean presence flag.
Per lint rule: CI logs are persistent and broad-read; SSM error details
stay in restricted observability.

Rule 9 — added PROD_AUTO_DEPLOY_DISABLED kill switch:
Added job-level PROD_AUTO_DEPLOY_DISABLED env var (repo var or secret)
and an early-exit step that notices and skips when set. Manual
workflow_dispatch bypasses the kill switch by design.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 23:41:18 +00:00
infra-lead a7a65b6fdf fix(ci): restore proper Docker daemon gate on publish-workspace-server-image
main merged a fix (3206966e) that replaces the broken `Diagnose Docker
daemon access` step (|| true guards) with a proper `Verify Docker daemon
access` gate (docker info || { exit 1 }). The feature branch is still on
the old broken version — sync it.

mc#711: ubuntu-latest runners may lack a live Docker daemon. With the
old guards the step always succeeded even when Docker was inaccessible,
letting the build step hang for 4+ minutes before failing. The restored
gate fails in ~5s with an actionable error message.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 23:41:18 +00:00
core-fe 4d8c81984c chore: retrigger CI after rebase to main 2026-05-13 23:41:18 +00:00
core-fe 9d72c35e18 chore: retrigger CI after rebase to main 2026-05-13 23:41:18 +00:00
devops-engineer 4c2172a0f0 Merge pull request 'fix(handlers): repair current main test blockers' (#900) from fix/core-main-handlers-hotfix into main
Block internal-flavored paths / Block forbidden paths (push) Successful in 12s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 26s
CI / Detect changes (pull_request) Successful in 29s
CI / Detect changes (push) Successful in 19s
E2E API Smoke Test / detect-changes (pull_request) Successful in 22s
E2E API Smoke Test / detect-changes (push) Successful in 24s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 27s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 33s
Harness Replays / detect-changes (pull_request) Successful in 35s
Harness Replays / detect-changes (push) Successful in 19s
Handlers Postgres Integration / detect-changes (push) Successful in 44s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 18s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 41s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 2m6s
review-check-tests / review-check.sh regression tests (pull_request) Successful in 20s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 40s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 37s
gate-check-v3 / gate-check (pull_request) Successful in 39s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 2m4s
qa-review / approved (pull_request) Successful in 26s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m20s
publish-runtime-autobump / pr-validate (pull_request) Successful in 56s
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) Failing after 1m53s
security-review / approved (pull_request) Successful in 29s
sop-checklist-gate / gate (pull_request) Successful in 31s
sop-tier-check / tier-check (pull_request) Successful in 29s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Failing after 1m28s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m38s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 28s
CI / Shellcheck (E2E scripts) (push) Successful in 9s
CI / Canvas (Next.js) (push) Successful in 14s
CI / Python Lint & Test (push) Successful in 11s
publish-workspace-server-image / build-and-push (push) Successful in 9m43s
Harness Replays / Harness Replays (pull_request) Successful in 14s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 19s
Harness Replays / Harness Replays (push) Successful in 15s
audit-force-merge / audit (pull_request) Has been skipped
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 3m17s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 3m26s
main-red-watchdog / watchdog (push) Successful in 2m32s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 10s
CI / Python Lint & Test (pull_request) Successful in 8m0s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 6m54s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 6m48s
gate-check-v3 / gate-check (push) Successful in 46s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 2m30s
CI / Canvas Deploy Reminder (push) Has been skipped
publish-workspace-server-image / Production auto-deploy (push) Failing after 23s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 24s
ci-required-drift / drift (push) Successful in 1m13s
CI / Canvas (Next.js) (pull_request) Successful in 16m56s
CI / Platform (Go) (pull_request) Successful in 18m17s
CI / Platform (Go) (push) Successful in 17m48s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 28s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 6s
CI / all-required (push) Successful in 4s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 5m3s
status-reaper / reap (push) Has started running
gitea-merge-queue / queue (push) Successful in 26s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 4m54s
2026-05-13 22:58:58 +00:00
hongming-codex-laptop 7ce65ac4cb fix(handlers): repair current main test blockers
sop-checklist / all-items-acked (pull_request) All required checks passed
CI / Detect changes (pull_request) Successful in 19s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 12s
E2E API Smoke Test / detect-changes (pull_request) Successful in 21s
Harness Replays / detect-changes (pull_request) Successful in 11s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 23s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 16s
qa-review / approved (pull_request) Successful in 12s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 31s
security-review / approved (pull_request) Successful in 12s
gate-check-v3 / gate-check (pull_request) Successful in 21s
sop-checklist-gate / gate (pull_request) Successful in 12s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 26s
sop-tier-check / tier-check (pull_request) Successful in 12s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m24s
audit-force-merge / audit (pull_request) Successful in 21s
CI / Canvas (Next.js) (pull_request) Successful in 15s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 9s
CI / Python Lint & Test (pull_request) Successful in 9s
Harness Replays / Harness Replays (pull_request) Successful in 17s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 17s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 16s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2m25s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 5m49s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Platform (Go) (pull_request) Successful in 20m5s
CI / all-required (pull_request) Successful in 3s
2026-05-13 22:55:29 +00:00
devops-engineer ff4b1cded8 Merge pull request 'fix(main): heal ADMIN_TOKEN placeholder in global_secrets on startup (#831)' (#898) from sre/port-fixAdminTokenPlaceholder-to-main into main
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / all-required (push) Blocked by required conditions
Handlers Postgres Integration / Handlers Postgres Integration (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 12s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 17s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 12s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 29s
CI / Detect changes (pull_request) Successful in 31s
CI / Detect changes (push) Successful in 31s
Harness Replays / detect-changes (pull_request) Successful in 29s
E2E API Smoke Test / detect-changes (pull_request) Successful in 33s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 34s
E2E API Smoke Test / detect-changes (push) Successful in 37s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
Harness Replays / detect-changes (push) Successful in 12s
Handlers Postgres Integration / detect-changes (push) Successful in 33s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 18s
review-check-tests / review-check.sh regression tests (pull_request) Successful in 21s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 40s
publish-runtime-autobump / pr-validate (pull_request) Successful in 53s
qa-review / approved (pull_request) Successful in 13s
security-review / approved (pull_request) Successful in 14s
sop-checklist / all-items-acked (pull_request) [info tier:low] acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: comprehensive-testing, l
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m44s
sop-checklist-gate / gate (pull_request) Successful in 15s
sop-tier-check / tier-check (pull_request) Successful in 16s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Failing after 1m22s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m28s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 1m56s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 18s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 21s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m53s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Failing after 1m47s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m24s
status-reaper / reap (push) Has started running
gitea-merge-queue / queue (push) Has started running
CI / Shellcheck (E2E scripts) (push) Successful in 8s
CI / Canvas (Next.js) (push) Successful in 13s
CI / Python Lint & Test (push) Successful in 24s
Harness Replays / Harness Replays (pull_request) Successful in 9s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 40s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 14s
Harness Replays / Harness Replays (push) Successful in 8s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Failing after 1m49s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Has started running
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 8s
CI / Platform (Go) (push) Has been cancelled
E2E API Smoke Test / E2E API Smoke Test (push) Has been cancelled
publish-workspace-server-image / build-and-push (push) Has been cancelled
CI / Platform (Go) (pull_request) Failing after 5m0s
CI / Python Lint & Test (pull_request) Successful in 8m2s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Failing after 14m25s
CI / Canvas (Next.js) (pull_request) Successful in 15m35s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 8s
2026-05-13 22:43:20 +00:00
infra-sre b5b24ab64b fix(main): heal ADMIN_TOKEN placeholder in global_secrets on startup (#831)
sop-checklist / all-items-acked (pull_request) injected after rebase
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 11s
Harness Replays / detect-changes (pull_request) Successful in 18s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 17s
qa-review / approved (pull_request) Successful in 21s
CI / Detect changes (pull_request) Successful in 39s
E2E API Smoke Test / detect-changes (pull_request) Successful in 37s
security-review / approved (pull_request) Successful in 19s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 38s
gate-check-v3 / gate-check (pull_request) Successful in 31s
sop-checklist-gate / gate (pull_request) Successful in 18s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 40s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 39s
sop-tier-check / tier-check (pull_request) Successful in 21s
audit-force-merge / audit (pull_request) Successful in 14s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m17s
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
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 18s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 20s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 10s
Harness Replays / Harness Replays (pull_request) Failing after 2m25s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2m36s
CI / Platform (Go) (pull_request) Failing after 5m41s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 7s
Cherry-pick from staging (PR #893) — that PR was accidentally merged to
staging instead of main, leaving the production fix stranded.

The root cause: workspaces provisioned with ADMIN_TOKEN=placeholder in
global_secrets receive that placeholder as a container env var, breaking
any code that calls platform APIs. This runs once at startup (SaaS only)
and replaces the placeholder with the real token from the host environment.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 22:42:32 +00:00
devops-engineer d8ac017d6e Merge pull request 'fix(gate-check): map infra-sre Gitea login to core-devops agent' (#896) from sre/fix-gate-check-infra-sre-devops-mapping into main
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) 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
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
Handlers Postgres Integration / detect-changes (push) Waiting to run
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
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
sop-checklist / all-items-acked (pull_request) [info tier:low] acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: comprehensive-testing, l
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 29s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 16s
Harness Replays / detect-changes (pull_request) Successful in 44s
E2E API Smoke Test / detect-changes (pull_request) Successful in 57s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 54s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
review-check-tests / review-check.sh regression tests (pull_request) Successful in 19s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 38s
publish-runtime-autobump / pr-validate (pull_request) Successful in 54s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 33s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m20s
gate-check-v3 / gate-check (pull_request) Successful in 14s
qa-review / approved (pull_request) Successful in 9s
security-review / approved (pull_request) Successful in 9s
sop-checklist-gate / gate (pull_request) Successful in 8s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m57s
sop-tier-check / tier-check (pull_request) Successful in 8s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Failing after 1m26s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Failing after 2m1s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 2m5s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 2m24s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m20s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Has started running
Harness Replays / Harness Replays (pull_request) Successful in 6s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Failing after 1m21s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m59s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 2m52s
2026-05-13 22:38:29 +00:00
core-be f908aa894b fix(gate-check): map infra-sre Gitea login to core-devops agent
sop-checklist / all-items-acked (pull_request) injected after rebase
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 20s
CI / Detect changes (pull_request) Successful in 1m8s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m5s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 1m1s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 58s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 26s
qa-review / approved (pull_request) Successful in 27s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m38s
security-review / approved (pull_request) Successful in 30s
gate-check-v3 / gate-check (pull_request) Successful in 44s
sop-checklist-gate / gate (pull_request) Successful in 21s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 1m7s
sop-tier-check / tier-check (pull_request) Successful in 26s
audit-force-merge / audit (pull_request) Successful in 23s
CI / Platform (Go) (pull_request) Successful in 11s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 13s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 8s
CI / Canvas (Next.js) (pull_request) Successful in 11s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 12s
CI / Python Lint & Test (pull_request) Successful in 9s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 11s
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 5s
infra-sre IS the engineers/core-devops agent (same team, same work).
Without this alias, infra-sre reviews and comments never satisfy the
engineers gate in signal_1_comment_scan, causing PRs to remain blocked
even when infra-sre explicitly posts [devops-agent] APPROVED.

Changes:
- Add LOGIN_ALIASES dict: infra-sre → core-devops
- Resolve aliases in signal_1_comment_scan comment-matching loop
- Resolve aliases in signal_1_comment_scan reviews collection
- Add test covering infra-sre APPROVED review → engineers CLEAR

Fixes #896.

[core-be-agent]

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 22:37:50 +00:00
46 changed files with 2626 additions and 466 deletions
+38 -1
View File
@@ -101,9 +101,10 @@ printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" > "$CURL_AUTH_FILE"
PR_JSON=$(mktemp)
REVIEWS_JSON=$(mktemp)
TEAM_PROBE_TMP=$(mktemp)
NA_STATUSES_TMP="" # declared here so cleanup() always has the var
cleanup() {
rm -f "$CURL_AUTH_FILE" "$PR_JSON" "$REVIEWS_JSON" "$TEAM_PROBE_TMP"
rm -f "$CURL_AUTH_FILE" "$PR_JSON" "$REVIEWS_JSON" "$TEAM_PROBE_TMP" "${NA_STATUSES_TMP-}"
}
trap cleanup EXIT
@@ -143,6 +144,42 @@ if [ -z "$PR_AUTHOR" ] || [ -z "$PR_HEAD_SHA" ]; then
exit 1
fi
# --- RFC#324 §N/A follow-up: check N/A declarations status ---
# sop-checklist-gate.py posts `sop-checklist / na-declarations (pull_request)`
# status when a peer posts /sop-n/a <gate>. If our gate is declared N/A,
# the requirement for a Gitea APPROVE review is waived.
NA_STATUSES_TMP=$(mktemp)
HTTP_CODE=$(curl -sS -o "$NA_STATUSES_TMP" -w '%{http_code}' \
-K "$CURL_AUTH_FILE" "${API}/repos/${OWNER}/${NAME}/statuses/${PR_HEAD_SHA}")
debug "statuses/${PR_HEAD_SHA} → HTTP ${HTTP_CODE}"
if [ "$HTTP_CODE" = "200" ]; then
# Gitea returns statuses as array; look for the na-declarations context.
# jq: find all statuses where context == "sop-checklist / na-declarations (pull_request)"
# and state == "success". Extract the description field.
NA_DESC=$(jq -r '
.[] |
select(.context == "sop-checklist / na-declarations (pull_request)") |
select(.state == "success") |
.description
' "$NA_STATUSES_TMP" 2>/dev/null | head -1)
if [ -n "$NA_DESC" ] && [ "$NA_DESC" != "null" ]; then
debug "na-declarations status found: ${NA_DESC}"
# Check if our gate appears in the N/A description.
# The description format is "N/A: qa-review, security-review" or similar.
if echo "$NA_DESC" | grep -iq "\\b${TEAM}-review\\b"; then
echo "::notice::${TEAM}-review N/A — gate declared not-applicable via /sop-n/a: ${NA_DESC}"
echo "::notice::PR ${PR_NUMBER} passes ${TEAM}-review via N/A declaration"
rm -f "$NA_STATUSES_TMP"
exit 0
fi
fi
else
debug "could not fetch statuses (HTTP ${HTTP_CODE}) — proceeding with normal eval"
fi
rm -f "$NA_STATUSES_TMP"
# --- Fetch all reviews on the PR ---
HTTP_CODE=$(curl -sS -o "$REVIEWS_JSON" -w '%{http_code}' \
-K "$CURL_AUTH_FILE" "${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}/reviews")
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# Re-run review-check.sh for a slash-command refire and post the protected
# pull_request status context to the PR head SHA.
set -euo pipefail
: "${GITEA_TOKEN:?GITEA_TOKEN required}"
: "${GITEA_HOST:?GITEA_HOST required}"
: "${REPO:?REPO required}"
: "${PR_NUMBER:?PR_NUMBER required}"
: "${TEAM:?TEAM required}"
OWNER="${REPO%%/*}"
NAME="${REPO##*/}"
API="https://${GITEA_HOST}/api/v1"
CONTEXT="${TEAM}-review / approved (pull_request)"
TARGET_URL="https://${GITEA_HOST}/${OWNER}/${NAME}/pulls/${PR_NUMBER}"
authfile=$(mktemp)
prfile=$(mktemp)
postfile=$(mktemp)
# shellcheck disable=SC2329 # invoked by EXIT trap
cleanup() {
rm -f "$authfile" "$prfile" "$postfile"
}
trap cleanup EXIT
chmod 600 "$authfile"
printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" > "$authfile"
code=$(curl -sS -o "$prfile" -w '%{http_code}' -K "$authfile" \
"${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}")
if [ "$code" != "200" ]; then
echo "::error::GET /pulls/${PR_NUMBER} returned HTTP ${code}"
head -c 200 "$prfile" >&2 || true
exit 1
fi
head_sha=$(jq -r '.head.sha // ""' "$prfile")
state=$(jq -r '.state // ""' "$prfile")
if [ -z "$head_sha" ] || [ "$head_sha" = "null" ]; then
echo "::error::Could not resolve PR head SHA for PR ${PR_NUMBER}"
exit 1
fi
if [ "$state" != "open" ]; then
echo "::notice::PR ${PR_NUMBER} is ${state}; ${TEAM}-review refire is a no-op"
exit 0
fi
set +e
bash .gitea/scripts/review-check.sh
rc=$?
set -e
if [ "$rc" -eq 0 ]; then
status_state="success"
description="Refired via /${TEAM}-recheck by ${COMMENT_AUTHOR:-unknown}"
else
status_state="failure"
description="Refired via /${TEAM}-recheck; ${TEAM}-review failed"
fi
body=$(jq -nc \
--arg state "$status_state" \
--arg context "$CONTEXT" \
--arg description "$description" \
--arg target_url "$TARGET_URL" \
'{state:$state, context:$context, description:$description, target_url:$target_url}')
code=$(curl -sS -o "$postfile" -w '%{http_code}' -X POST \
-K "$authfile" -H "Content-Type: application/json" \
-d "$body" \
"${API}/repos/${OWNER}/${NAME}/statuses/${head_sha}")
if [ "$code" != "200" ] && [ "$code" != "201" ]; then
echo "::error::POST /statuses/${head_sha} returned HTTP ${code}"
head -c 200 "$postfile" >&2 || true
exit 1
fi
echo "::notice::posted ${status_state} for context=\"${CONTEXT}\" on sha=${head_sha}"
exit "$rc"
+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,
+19 -16
View File
@@ -134,18 +134,22 @@ class TestParseDirectives(unittest.TestCase):
def setUp(self):
self.aliases = _numeric_aliases()
def parse_ack_revoke(self, body):
directives, na_directives = sop.parse_directives(body, self.aliases)
self.assertEqual(na_directives, [])
return directives
def test_simple_ack(self):
d = sop.parse_directives("/sop-ack comprehensive-testing", self.aliases)
d = self.parse_ack_revoke("/sop-ack comprehensive-testing")
self.assertEqual(d, [("sop-ack", "comprehensive-testing", "")])
def test_simple_revoke(self):
d = sop.parse_directives("/sop-revoke staging-smoke", self.aliases)
d = self.parse_ack_revoke("/sop-revoke staging-smoke")
self.assertEqual(d, [("sop-revoke", "staging-smoke", "")])
def test_ack_with_note(self):
d = sop.parse_directives(
"/sop-ack comprehensive-testing LGTM the test covers all edge cases",
self.aliases,
d = self.parse_ack_revoke(
"/sop-ack comprehensive-testing LGTM the test covers all edge cases"
)
self.assertEqual(len(d), 1)
self.assertEqual(d[0][0], "sop-ack")
@@ -153,13 +157,12 @@ class TestParseDirectives(unittest.TestCase):
self.assertIn("LGTM", d[0][2])
def test_numeric_shorthand(self):
d = sop.parse_directives("/sop-ack 1", self.aliases)
d = self.parse_ack_revoke("/sop-ack 1")
self.assertEqual(d, [("sop-ack", "comprehensive-testing", "")])
def test_revoke_with_reason(self):
d = sop.parse_directives(
"/sop-revoke comprehensive-testing realized the e2e was mocking the DB",
self.aliases,
d = self.parse_ack_revoke(
"/sop-revoke comprehensive-testing realized the e2e was mocking the DB"
)
self.assertEqual(d[0][0], "sop-revoke")
self.assertEqual(d[0][1], "comprehensive-testing")
@@ -171,7 +174,7 @@ class TestParseDirectives(unittest.TestCase):
"/sop-ack comprehensive-testing\n"
"Will follow up on the doc nit separately."
)
d = sop.parse_directives(body, self.aliases)
d = self.parse_ack_revoke(body)
self.assertEqual(len(d), 1)
self.assertEqual(d[0][1], "comprehensive-testing")
@@ -180,7 +183,7 @@ class TestParseDirectives(unittest.TestCase):
"/sop-ack comprehensive-testing\n"
"/sop-ack local-postgres-e2e\n"
)
d = sop.parse_directives(body, self.aliases)
d = self.parse_ack_revoke(body)
self.assertEqual(len(d), 2)
slugs = {x[1] for x in d}
self.assertEqual(slugs, {"comprehensive-testing", "local-postgres-e2e"})
@@ -189,21 +192,21 @@ class TestParseDirectives(unittest.TestCase):
# A directive embedded mid-line is not honored (prevents review
# comments like "to /sop-ack you need..." from acting as acks).
body = "If you want to /sop-ack comprehensive-testing reply in this thread"
d = sop.parse_directives(body, self.aliases)
d = self.parse_ack_revoke(body)
self.assertEqual(d, [])
def test_leading_whitespace_allowed(self):
body = " /sop-ack comprehensive-testing"
d = sop.parse_directives(body, self.aliases)
d = self.parse_ack_revoke(body)
self.assertEqual(len(d), 1)
def test_empty_body(self):
self.assertEqual(sop.parse_directives("", self.aliases), [])
self.assertEqual(sop.parse_directives(None, self.aliases), [])
self.assertEqual(sop.parse_directives("", self.aliases), ([], []))
self.assertEqual(sop.parse_directives(None, self.aliases), ([], []))
def test_normalization_applied(self):
# /sop-ack Comprehensive_Testing → canonical comprehensive-testing
d = sop.parse_directives("/sop-ack Comprehensive_Testing", self.aliases)
d = self.parse_ack_revoke("/sop-ack Comprehensive_Testing")
self.assertEqual(d[0][1], "comprehensive-testing")
+34 -18
View File
@@ -32,6 +32,7 @@ THIS_DIR="$(cd "$(dirname "$0")" && pwd)"
SCRIPT_DIR="$(cd "$THIS_DIR/.." && pwd)"
WORKFLOW_DIR="$(cd "$THIS_DIR/../../workflows" && pwd)"
WORKFLOW="$WORKFLOW_DIR/sop-tier-refire.yml"
DISPATCH_WORKFLOW="$WORKFLOW_DIR/review-refire-comments.yml"
SCRIPT="$SCRIPT_DIR/sop-tier-refire.sh"
PASS=0
@@ -87,6 +88,7 @@ assert_file_exists() {
echo
echo "== existence =="
assert_file_exists "workflow file exists" "$WORKFLOW"
assert_file_exists "dispatcher workflow file exists" "$DISPATCH_WORKFLOW"
assert_file_exists "script file exists" "$SCRIPT"
if [ "$FAIL" -gt 0 ]; then
echo
@@ -104,29 +106,43 @@ echo "== T6/T7 workflow yaml =="
PARSE_OUT=$(python3 -c 'import sys,yaml;yaml.safe_load(open(sys.argv[1]).read());print("ok")' "$WORKFLOW" 2>&1 || true)
assert_eq "T7 workflow parses as YAML" "ok" "$PARSE_OUT"
# Three required gates in the `if:` expression
# The old per-workflow issue_comment listener caused queue storms because
# Gitea queues jobs before evaluating job-level `if:`. The script remains,
# but comment-triggered refires route through the single dispatcher.
WORKFLOW_CONTENT=$(cat "$WORKFLOW")
assert_contains "T6a workflow if: contains author_association gate" \
"github.event.comment.author_association" "$WORKFLOW_CONTENT"
assert_contains "T6b workflow if: gates on MEMBER/OWNER/COLLABORATOR" \
'["MEMBER","OWNER","COLLABORATOR"]' "$WORKFLOW_CONTENT"
assert_contains "T6c workflow if: contains slash-command trigger" \
"/refire-tier-check" "$WORKFLOW_CONTENT"
assert_contains "T6d workflow if: gates on PR-not-issue" \
"github.event.issue.pull_request" "$WORKFLOW_CONTENT"
assert_contains "T6e workflow listens on issue_comment" \
"issue_comment" "$WORKFLOW_CONTENT"
assert_contains "T6f workflow requests statuses:write permission" \
"statuses: write" "$WORKFLOW_CONTENT"
# Does NOT check out PR HEAD (security)
if grep -q 'ref: \${{ github.event.pull_request.head' "$WORKFLOW"; then
echo " FAIL T6g workflow MUST NOT check out PR head (security)"
if printf '%s' "$WORKFLOW_CONTENT" | grep -q '^ issue_comment:'; then
echo " FAIL T6a manual fallback workflow must not listen on issue_comment"
FAIL=$((FAIL + 1))
FAILED_TESTS="${FAILED_TESTS} T6g"
FAILED_TESTS="${FAILED_TESTS} T6a"
else
echo " PASS T6g workflow does not check out PR head"
echo " PASS T6a manual fallback workflow does not listen on issue_comment"
PASS=$((PASS + 1))
fi
assert_contains "T6b workflow exposes workflow_dispatch" \
"workflow_dispatch" "$WORKFLOW_CONTENT"
assert_contains "T6c workflow documents unsupported manual inputs" \
"workflow_dispatch inputs" "$WORKFLOW_CONTENT"
# Does NOT check out PR HEAD (security)
if grep -q 'ref: \${{ github.event.pull_request.head' "$WORKFLOW"; then
echo " FAIL T6d workflow MUST NOT check out PR head (security)"
FAIL=$((FAIL + 1))
FAILED_TESTS="${FAILED_TESTS} T6d"
else
echo " PASS T6d workflow does not check out PR head"
PASS=$((PASS + 1))
fi
DISPATCH_PARSE_OUT=$(python3 -c 'import sys,yaml;yaml.safe_load(open(sys.argv[1]).read());print("ok")' "$DISPATCH_WORKFLOW" 2>&1 || true)
assert_eq "T6e dispatcher workflow parses as YAML" "ok" "$DISPATCH_PARSE_OUT"
DISPATCH_CONTENT=$(cat "$DISPATCH_WORKFLOW")
assert_contains "T6f dispatcher listens on issue_comment" \
"issue_comment" "$DISPATCH_CONTENT"
assert_contains "T6g dispatcher handles /qa-recheck" \
"/qa-recheck" "$DISPATCH_CONTENT"
assert_contains "T6h dispatcher handles /security-recheck" \
"/security-recheck" "$DISPATCH_CONTENT"
assert_contains "T6i dispatcher handles /refire-tier-check" \
"/refire-tier-check" "$DISPATCH_CONTENT"
# T1-T5 — script behavior against a local Gitea-fixture
echo
+36
View File
@@ -107,3 +107,39 @@ items:
description: >-
List of feedback memories applicable to this change. Ack from
any engineer who has the same memory access.
# N/A gate declarations (RFC#324 §N/A follow-up).
# PRs where a gate genuinely does not apply (e.g., pure-infra with no
# qa surface, or docs-only) can be declared N/A by a non-author peer
# who is in one of the gate's required_teams. The sop-checklist-gate
# posts a `sop-checklist / na-declarations (pull_request)` status that
# review-check.sh reads to skip the Gitea-APPROVE requirement.
#
# Usage: any PR commenter (peer) posts:
# /sop-n/a qa-review <reason>
# /sop-n/a security-review <reason>
#
# Slash commands:
# /sop-n/a <gate> [reason] — declare gate N/A (most-recent per-user wins)
# /sop-revoke <gate> — revoke prior N/A declaration for that gate
#
# Gate names must match the context strings used by review-check.sh:
# qa-review → qa-review / approved (<event>) [TEAM_ID=20]
# security-review → security-review / approved (<event>) [TEAM_ID=21]
#
# required_teams: OR semantics — any team member can declare N/A.
# Authors cannot self-declare N/A (enforced by gate script).
n/a_gates:
qa-review:
required_teams: [qa, security, engineers]
description: >-
QA review N/A when this change has no qa surface (pure-infra,
tooling-only, revert, dependency-only). A qa/eng/security member
must post /sop-n/a qa-review to activate.
security-review:
required_teams: [security, managers, ceo]
description: >-
Security review N/A when this change has no security surface
(docs-only, pure-frontend, dependency-only). A security/owners
member must post /sop-n/a security-review to activate.
+57 -18
View File
@@ -107,16 +107,25 @@ jobs:
echo "scripts=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Both .github/workflows/ci.yml AND .gitea/workflows/ci.yml count
# as "this workflow changed" — either edit should force-run every
# downstream job. The Gitea port follows the same shape as the
# GitHub original so behavior matches when triggered on either
# platform.
DIFF=$(git diff --name-only "$BASE" HEAD 2>/dev/null || echo ".gitea/workflows/ci.yml")
echo "platform=$(echo "$DIFF" | grep -qE '^workspace-server/|^\.gitea/workflows/ci\.yml$|^\.github/workflows/ci\.yml$' && echo true || echo false)" >> "$GITHUB_OUTPUT"
echo "canvas=$(echo "$DIFF" | grep -qE '^canvas/|^\.gitea/workflows/ci\.yml$|^\.github/workflows/ci\.yml$' && echo true || echo false)" >> "$GITHUB_OUTPUT"
echo "python=$(echo "$DIFF" | grep -qE '^workspace/|^\.gitea/workflows/ci\.yml$|^\.github/workflows/ci\.yml$' && echo true || echo false)" >> "$GITHUB_OUTPUT"
echo "scripts=$(echo "$DIFF" | grep -qE '^tests/e2e/|^scripts/|^infra/scripts/|^\.gitea/workflows/ci\.yml$|^\.github/workflows/ci\.yml$' && echo true || echo false)" >> "$GITHUB_OUTPUT"
# Workflow-only edits are covered by the workflow lint family
# and by this workflow's always-present required jobs. Do not fan
# those edits out into Go/Canvas/Python/shellcheck work; the
# downstream jobs still emit their required contexts via no-op
# steps when their surface flag is false.
#
# If the diff itself cannot be trusted, fail open by running every
# surface instead of silently under-testing the PR.
if ! DIFF=$(git diff --name-only "$BASE" HEAD 2>/dev/null); then
echo "platform=true" >> "$GITHUB_OUTPUT"
echo "canvas=true" >> "$GITHUB_OUTPUT"
echo "python=true" >> "$GITHUB_OUTPUT"
echo "scripts=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "platform=$(echo "$DIFF" | grep -qE '^workspace-server/' && echo true || echo false)" >> "$GITHUB_OUTPUT"
echo "canvas=$(echo "$DIFF" | grep -qE '^canvas/' && echo true || echo false)" >> "$GITHUB_OUTPUT"
echo "python=$(echo "$DIFF" | grep -qE '^workspace/' && echo true || echo false)" >> "$GITHUB_OUTPUT"
echo "scripts=$(echo "$DIFF" | grep -qE '^tests/e2e/|^scripts/|^infra/scripts/' && echo true || echo false)" >> "$GITHUB_OUTPUT"
# Platform (Go) — Go build/vet/test/lint + coverage gates. The always-run
# + per-step gating shape preserves the GitHub-side required-check name
@@ -374,23 +383,54 @@ jobs:
run: |
bash tests/e2e/test_model_slug.sh
- if: needs.changes.outputs.scripts == 'true'
name: Test ECR promote-tenant-image script (mock-driven, no live infra)
# Covers scripts/promote-tenant-image.sh — the codified
# :staging-latest → :latest ECR promote + tenant fleet redeploy
# closing molecule-ai/molecule-core#660. 40 mock-driven cases
# exercise every exit path (preflight, snapshot, promote, redeploy
# 403→SSM-refresh, verify, rollback). No live AWS/CP/SSM calls.
run: |
bash scripts/test-promote-tenant-image.sh
- if: needs.changes.outputs.scripts == 'true'
name: Shellcheck promote-tenant-image script
# scripts/ is excluded from the bulk shellcheck pass above (legacy
# SC3040/SC3043 cleanup pending). Run shellcheck explicitly on
# the promote script + its test harness so regressions there are
# caught by the required check.
run: |
shellcheck --severity=warning \
scripts/promote-tenant-image.sh \
scripts/test-promote-tenant-image.sh
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
needs: [changes, canvas-build]
# Only fires on direct pushes to main (i.e. after staging→main promotion).
if: needs.changes.outputs.canvas == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main'
# 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.
steps:
- name: Write deploy reminder to step summary
env:
COMMIT_SHA: ${{ github.sha }}
CANVAS_CHANGED: ${{ needs.changes.outputs.canvas }}
EVENT_NAME: ${{ github.event_name }}
REF_NAME: ${{ github.ref }}
# github.server_url resolves via the workflow-level env override
# to the Gitea instance, so the RUN_URL points at the Gitea run
# page (not github.com). See feedback_act_runner_github_server_url.
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
if [ "$CANVAS_CHANGED" != "true" ] || [ "$EVENT_NAME" != "push" ] || [ "$REF_NAME" != "refs/heads/main" ]; then
echo "Canvas deploy reminder not applicable for event=$EVENT_NAME ref=$REF_NAME canvas_changed=$CANVAS_CHANGED."
exit 0
fi
# Write body to a temp file — avoids backtick escaping in shell.
cat > /tmp/deploy-reminder.md << 'BODY'
## Canvas build passed — deploy required
@@ -535,11 +575,10 @@ jobs:
# hourly if this list diverges from status_check_contexts or from
# audit-force-merge.yml's REQUIRED_CHECKS env (RFC §4 + §6).
#
# Excluded from `needs:`: `canvas-deploy-reminder` — gated by
# `if: ... github.event_name == 'push' && github.ref == 'refs/heads/main'`,
# so on PR events it's legitimately `skipped`. The drift detector
# explicitly excludes `github.event_name`-gated jobs from F1 (see
# `.gitea/scripts/ci-required-drift.py::ci_job_names`).
# Excluded from `needs:`: `canvas-deploy-reminder` — it is an
# operational reminder, not a CI prerequisite. Keep that job runnable
# on PRs with an internal no-op guard; job-level event/ref `if:` gates
# are a Gitea 1.22.6 pending-status trap.
#
# 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)
@@ -559,7 +598,7 @@ jobs:
- canvas-build
- shellcheck
- python-lint
if: always()
if: ${{ always() }}
steps:
- name: Assert every required dependency succeeded
run: |
@@ -65,20 +65,22 @@ jobs:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Diagnose Docker daemon access
# Health check: verify Docker daemon is accessible before attempting any
# build steps. This fails loudly at step 1 when the runner's docker.sock
# is inaccessible rather than silently continuing where `docker build`
# fails deep in the process with a cryptic ECR auth error.
- name: Verify Docker daemon access
run: |
set -euo pipefail
echo "::group::Docker daemon diagnosis"
echo "::group::Docker daemon health check"
echo "Runner: ${HOSTNAME:-unknown}"
echo "--- Socket info ---"
ls -la /var/run/docker.sock 2>/dev/null || echo "/var/run/docker.sock: not found"
stat /var/run/docker.sock 2>/dev/null || true
echo "--- User info ---"
id
echo "--- docker version ---"
docker version 2>&1 || true
echo "--- docker info (full) ---"
docker info 2>&1 || echo "docker info failed: exit $?"
docker info 2>&1 | head -5 || {
echo "::error::Docker daemon is not accessible at /var/run/docker.sock"
echo "::error::Runner: ${HOSTNAME:-unknown}"
echo "::error::Check: (1) daemon is running, (2) runner user is in docker group, (3) sock permissions are 660+"
exit 1
}
echo "Docker daemon OK"
echo "::endgroup::"
# Pre-clone manifest deps before docker build.
+9 -17
View File
@@ -9,10 +9,10 @@
# Triggers on:
# - `pull_request_target`: opened, synchronize, reopened
# → initial status posts when PR opens / re-pushes
# - `issue_comment`: /qa-recheck slash-command on the PR
# → manual re-fire after a QA reviewer clicks APPROVE
# (Gitea 1.22.6 doesn't re-fire on pull_request_review, per
# go-gitea/gitea#33700 + feedback_pull_request_review_no_refire)
# - comment refires are handled by `review-refire-comments.yml`
# → a single issue_comment dispatcher prevents every SOP/review
# comment from enqueueing separate qa/security/tier jobs on
# Gitea 1.22.6 before job-level `if:` can skip them.
# Workflow name = `qa-review` ; job name = `approved`.
# The job's own pass/fail conclusion publishes the status context
# `qa-review / approved (<event>)` — NO `POST /statuses` call → NO
@@ -85,8 +85,6 @@ name: qa-review
on:
pull_request_target:
types: [opened, synchronize, reopened]
issue_comment:
types: [created]
permissions:
contents: read
@@ -97,16 +95,10 @@ jobs:
approved:
# Gate the job:
# - On pull_request_target events: always run.
# - On issue_comment events: only when it's a PR comment and the body
# contains the slash-command. NO privilege gate at the step level
# (RFC#324 v1.3 §A1.1): a non-collaborator's /qa-recheck is fine
# because the eval is read-only and idempotent — re-running it
# just re-confirms whether a real team-member APPROVE exists.
# Comment-triggered refires live in review-refire-comments.yml. Keeping
# this workflow PR-only avoids comment-triggered queue storms.
if: |
github.event_name == 'pull_request_target' ||
(github.event_name == 'issue_comment' &&
github.event.issue.pull_request != null &&
startsWith(github.event.comment.body, '/qa-recheck'))
github.event_name == 'pull_request_target'
runs-on: ubuntu-latest
steps:
- name: Privilege check (A1.1 — INFORMATIONAL log only, NOT a gate)
@@ -120,7 +112,7 @@ jobs:
# no comment.user.login so the step is a no-op skip there.
if: github.event_name == 'issue_comment'
env:
GITEA_TOKEN: ${{ secrets.RFC_324_TEAM_READ_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
login="${{ github.event.comment.user.login }}"
@@ -151,7 +143,7 @@ jobs:
- name: Evaluate qa-review
env:
GITEA_TOKEN: ${{ secrets.RFC_324_TEAM_READ_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_HOST: git.moleculesai.app
REPO: ${{ github.repository }}
# PR number lives in different places per event:
+62 -34
View File
@@ -36,17 +36,19 @@ name: redeploy-tenants-on-main
#
# Runtime ordering:
# 1. publish-workspace-server-image completes → new :staging-<sha> in ECR.
# 2. This workflow fires via workflow_run, calls redeploy-fleet with
# target_tag=staging-<sha>. No CDN propagation wait needed —
# ECR image manifest is consistent immediately after push.
# 2. The merge that updates publish-workspace-server-image.yml triggers
# this push/path-filtered workflow, which calls redeploy-fleet with
# target_tag=staging-<sha>. No CDN propagation wait needed — ECR image
# manifest is consistent immediately after push.
# 3. Calls redeploy-fleet with canary_slug (if set) and a soak
# period. Canary proves the image boots; batches follow.
# 4. Any failure aborts the rollout and leaves older tenants on the
# prior image — safer default than half-and-half state.
#
# Rollback path: re-run this workflow with a specific SHA pinned via
# the workflow_dispatch input. That calls redeploy-fleet with
# target_tag=<sha>, re-pulling the older image on every tenant.
# Rollback path: set PROD_MANUAL_REDEPLOY_TARGET_TAG as a repo/org
# variable or secret, run workflow_dispatch, then unset it after the
# rollback. That calls redeploy-fleet with target_tag=<value>,
# re-pulling the pinned image on every tenant.
on:
push:
@@ -65,31 +67,40 @@ permissions:
# the explicit block makes the invariant defensible. Mirrors the
# concurrency block on redeploy-tenants-on-staging.yml for shape parity.
#
# cancel-in-progress: false → aborting a half-rolled-out fleet would
# leave tenants stuck on whatever image they happened to be on when
# cancelled. Better to finish the in-flight rollout before starting
# the next one.
# NOTE: cancel-in-progress: false removed (Rule 7 fix). Gitea 1.22.6
# cancels queued runs regardless of this setting, so it provides no
# actual protection. Each redeploy-fleet call is idempotent (canary-first
# + batched + health-gated) so a cancelled predecessor is recovered
# automatically by the next run.
concurrency:
group: redeploy-tenants-on-main
cancel-in-progress: false
env:
GITHUB_SERVER_URL: https://git.moleculesai.app
jobs:
# bp-exempt: production redeploy is a side-effect workflow, not a merge gate.
redeploy:
# Skip the auto-trigger if publish-workspace-server-image didn't
# actually succeed. workflow_run fires on any completion state; we
# don't want to redeploy against a half-built image.
# NOTE (Gitea port): workflow_dispatch trigger dropped; only the
# workflow_run path remains.
if: ${{ github.event.workflow_run.conclusion == 'success' }}
# Gitea 1.22.6 does not support workflow_run. This workflow is now
# controlled by push/path triggers plus an explicit kill switch.
if: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
runs-on: ubuntu-latest
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
continue-on-error: true
timeout-minutes: 25
env:
# Rule 9 fix: operational kill switch for auto-triggered deployments.
# Set repo variable or secret PROD_AUTO_DEPLOY_DISABLED=true to prevent
# this workflow from redeploying. Manual workflow_dispatch bypasses this.
PROD_AUTO_DEPLOY_DISABLED: ${{ vars.PROD_AUTO_DEPLOY_DISABLED || secrets.PROD_AUTO_DEPLOY_DISABLED || '' }}
steps:
- name: Kill-switch guard
# Rule 9 fix: exit fast if kill switch is set. No redeploy happens.
if: env.PROD_AUTO_DEPLOY_DISABLED == 'true'
run: |
echo "::notice::Production auto-deploy disabled (PROD_AUTO_DEPLOY_DISABLED=true). Skipping redeploy."
echo "To re-enable: unset the repo variable or set it to false."
- name: Note on ECR propagation
# ECR image manifests are consistent immediately after push — no
# CDN cache to wait for. The old GHCR-based workflow had a 30s
@@ -108,16 +119,16 @@ jobs:
# dead (staging-verify soft-skips without canary fleet, so
# the only thing retagging `:latest` today is the manual
# promote-latest.yml — last run 2026-04-28). Auto-trigger
# from workflow_run uses workflow_run.head_sha; manual
# dispatch with no input falls through to github.sha.
# from the main push uses github.sha; manual
# dispatch with no variable falls through to github.sha.
env:
INPUT_TAG: ${{ inputs.target_tag }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
PROD_MANUAL_REDEPLOY_TARGET_TAG: ${{ vars.PROD_MANUAL_REDEPLOY_TARGET_TAG || secrets.PROD_MANUAL_REDEPLOY_TARGET_TAG || '' }}
HEAD_SHA: ${{ github.sha }}
run: |
set -euo pipefail
if [ -n "${INPUT_TAG:-}" ]; then
echo "target_tag=$INPUT_TAG" >> "$GITHUB_OUTPUT"
echo "Using operator-pinned tag: $INPUT_TAG"
if [ -n "${PROD_MANUAL_REDEPLOY_TARGET_TAG:-}" ]; then
echo "target_tag=$PROD_MANUAL_REDEPLOY_TARGET_TAG" >> "$GITHUB_OUTPUT"
echo "Using operator-pinned tag from PROD_MANUAL_REDEPLOY_TARGET_TAG."
else
SHORT="${HEAD_SHA:0:7}"
echo "target_tag=staging-$SHORT" >> "$GITHUB_OUTPUT"
@@ -133,13 +144,26 @@ jobs:
CP_URL: ${{ vars.CP_URL || 'https://api.moleculesai.app' }}
CP_ADMIN_API_TOKEN: ${{ secrets.CP_ADMIN_API_TOKEN }}
TARGET_TAG: ${{ steps.tag.outputs.target_tag }}
CANARY_SLUG: ${{ inputs.canary_slug || 'hongming' }}
SOAK_SECONDS: ${{ inputs.soak_seconds || '60' }}
BATCH_SIZE: ${{ inputs.batch_size || '3' }}
DRY_RUN: ${{ inputs.dry_run || false }}
CANARY_SLUG: ${{ vars.PROD_REDEPLOY_CANARY_SLUG || secrets.PROD_REDEPLOY_CANARY_SLUG || '' }}
SOAK_SECONDS: ${{ vars.PROD_REDEPLOY_SOAK_SECONDS || secrets.PROD_REDEPLOY_SOAK_SECONDS || '' }}
BATCH_SIZE: ${{ vars.PROD_REDEPLOY_BATCH_SIZE || secrets.PROD_REDEPLOY_BATCH_SIZE || '' }}
DRY_RUN: ${{ vars.PROD_REDEPLOY_DRY_RUN || secrets.PROD_REDEPLOY_DRY_RUN || '' }}
PROD_AUTO_DEPLOY_DISABLED: ${{ vars.PROD_AUTO_DEPLOY_DISABLED || secrets.PROD_AUTO_DEPLOY_DISABLED || '' }}
run: |
set -euo pipefail
case "${PROD_AUTO_DEPLOY_DISABLED,,}" in
1|true|yes|on)
echo "::notice::PROD_AUTO_DEPLOY_DISABLED is set; skipping production redeploy."
exit 0
;;
esac
CANARY_SLUG="${CANARY_SLUG:-hongming}"
SOAK_SECONDS="${SOAK_SECONDS:-60}"
BATCH_SIZE="${BATCH_SIZE:-3}"
DRY_RUN="${DRY_RUN:-false}"
if [ -z "${CP_ADMIN_API_TOKEN:-}" ]; then
echo "::error::CP_ADMIN_API_TOKEN secret not set — skipping redeploy"
echo "::notice::Set CP_ADMIN_API_TOKEN in repo secrets to enable auto-redeploy."
@@ -161,7 +185,7 @@ jobs:
}')
echo "POST $CP_URL/cp/admin/tenants/redeploy-fleet"
echo " body: $BODY"
echo " target_tag=$TARGET_TAG canary=$CANARY_SLUG soak_seconds=$SOAK_SECONDS batch_size=$BATCH_SIZE dry_run=$DRY_RUN"
HTTP_RESPONSE=$(mktemp)
HTTP_CODE_FILE=$(mktemp)
@@ -189,7 +213,9 @@ jobs:
[ -z "$HTTP_CODE" ] && HTTP_CODE="000"
echo "HTTP $HTTP_CODE"
cat "$HTTP_RESPONSE" | jq . || cat "$HTTP_RESPONSE"
# Rule 8 fix: redact raw CP response from CI logs. Print only
# safe fields: ok boolean, result count, error presence (no content).
jq '{ok, result_count: (.results | length), has_errors: (.results | any(.error != null))}' "$HTTP_RESPONSE" || echo "(jq parse failed)"
# Pretty-print per-tenant results in the job summary so
# ops can see which tenants were redeployed without drilling
@@ -205,9 +231,11 @@ jobs:
echo ""
echo "### Per-tenant result"
echo ""
echo '| Slug | Phase | SSM Status | Exit | Healthz | Error |'
echo '| Slug | Phase | SSM Status | Exit | Healthz | Errors |'
echo '|------|-------|------------|------|---------|-------|'
jq -r '.results[]? | "| \(.slug) | \(.phase) | \(.ssm_status // "-") | \(.ssm_exit_code) | \(.healthz_ok) | \(.error // "-") |"' "$HTTP_RESPONSE" || true
# Rule 8 fix: .error field redacted from CI logs/summary. Print only
# presence boolean so ops know whether to look deeper.
jq -r '.results[]? | "| \(.slug) | \(.phase) | \(.ssm_status // "-") | \(.ssm_exit_code) | \(.healthz_ok) | \(.error != null) |"' "$HTTP_RESPONSE" || true
} >> "$GITHUB_STEP_SUMMARY"
if [ "$HTTP_CODE" != "200" ]; then
@@ -266,10 +294,10 @@ jobs:
if [ "$TARGET_TAG" != "latest" ] \
&& [ "$TARGET_TAG" != "$EXPECTED_SHA" ] \
&& [ "$TARGET_TAG" != "staging-$EXPECTED_SHORT" ]; then
# workflow_dispatch with a pinned tag that isn't the head
# Manual redeploy with a pinned tag that isn't the head
# SHA — operator is rolling back / pinning. Skip the
# verification because we don't have the expected SHA in
# this context (would need to crane-inspect the GHCR
# this context (would need to inspect the ECR
# manifest, which is a follow-up). Failing-open here is
# safe: the operator chose the tag deliberately.
#
+109
View File
@@ -0,0 +1,109 @@
# Consolidated comment dispatcher for manual review/tier refires.
#
# Gitea 1.22 queues one run per workflow subscribed to `issue_comment` before
# evaluating job-level `if:`. SOP-heavy PRs therefore created queue storms when
# qa-review, security-review, sop-checklist-gate, and sop-tier-refire all
# listened to comments. This workflow is the single non-SOP comment subscriber:
# ordinary comments no-op quickly; slash commands post the required status
# contexts to the PR head SHA.
name: review-refire-comments
on:
issue_comment:
types: [created]
permissions:
contents: read
pull-requests: read
statuses: write
jobs:
dispatch:
runs-on: ubuntu-latest
steps:
- name: Classify comment
id: classify
env:
COMMENT_BODY: ${{ github.event.comment.body }}
IS_PR: ${{ github.event.issue.pull_request != null }}
run: |
set -euo pipefail
{
echo "run_qa=false"
echo "run_security=false"
echo "run_tier=false"
} >> "$GITHUB_OUTPUT"
if [ "$IS_PR" != "true" ]; then
echo "::notice::not a PR comment; no-op"
exit 0
fi
first_line=$(printf '%s\n' "$COMMENT_BODY" | sed -n '1p')
case "$first_line" in
/qa-recheck*)
echo "run_qa=true" >> "$GITHUB_OUTPUT"
;;
/security-recheck*)
echo "run_security=true" >> "$GITHUB_OUTPUT"
;;
/refire-tier-check*)
echo "run_tier=true" >> "$GITHUB_OUTPUT"
;;
*)
echo "::notice::no supported review refire slash command; no-op"
;;
esac
- name: Check out BASE ref for trusted scripts
if: |
steps.classify.outputs.run_qa == 'true' ||
steps.classify.outputs.run_security == 'true' ||
steps.classify.outputs.run_tier == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.repository.default_branch }}
- name: Refire qa-review status
if: steps.classify.outputs.run_qa == 'true'
env:
GITEA_TOKEN: ${{ secrets.RFC_324_TEAM_READ_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_HOST: git.moleculesai.app
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.issue.number }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
TEAM: qa
TEAM_ID: '20'
REVIEW_CHECK_DEBUG: '0'
REVIEW_CHECK_STRICT: '0'
COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
run: |
set -euo pipefail
.gitea/scripts/review-refire-status.sh
- name: Refire security-review status
if: steps.classify.outputs.run_security == 'true'
env:
GITEA_TOKEN: ${{ secrets.RFC_324_TEAM_READ_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_HOST: git.moleculesai.app
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.issue.number }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
TEAM: security
TEAM_ID: '21'
REVIEW_CHECK_DEBUG: '0'
REVIEW_CHECK_STRICT: '0'
COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
run: |
set -euo pipefail
.gitea/scripts/review-refire-status.sh
- name: Refire sop-tier-check status
if: steps.classify.outputs.run_tier == 'true'
env:
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_HOST: git.moleculesai.app
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.issue.number }}
COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
SOP_DEBUG: '0'
run: bash .gitea/scripts/sop-tier-refire.sh
+13 -4
View File
@@ -66,19 +66,28 @@ jobs:
# PR#372's ci.yml port used. Diffs against the PR base or the
# previous push SHA, then matches against the wheel-relevant
# path set.
BASE="${GITHUB_BASE_REF:-${{ github.event.before }}}"
if [ "${{ github.event_name }}" = "pull_request" ] && [ -n "${{ github.event.pull_request.base.sha }}" ]; then
#
# NOTE: Gitea Actions does not expose github.event.before as a
# shell environment variable. The ${{ github.event.before }} template
# expression works inside YAML run: blocks but is evaluated to an
# empty string for push events, making the ${VAR:-fallback} always
# use the fallback. Use GITHUB_EVENT_BEFORE instead — it IS set in
# the runner's shell environment for push events.
BASE=""
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE="${{ github.event.pull_request.base.sha }}"
elif [ -n "$GITHUB_EVENT_BEFORE" ]; then
BASE="$GITHUB_EVENT_BEFORE"
fi
if [ -z "$BASE" ] || echo "$BASE" | grep -qE '^0+$'; then
# New branch or no previous SHA: treat as wheel-relevant.
echo "wheel=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if ! git cat-file -e "$BASE" 2>/dev/null; then
if ! timeout 30 git cat-file -e "$BASE" 2>/dev/null; then
git fetch --depth=1 origin "$BASE" 2>/dev/null || true
fi
if ! git cat-file -e "$BASE" 2>/dev/null; then
if ! timeout 30 git cat-file -e "$BASE" 2>/dev/null; then
echo "wheel=true" >> "$GITHUB_OUTPUT"
exit 0
fi
+5 -10
View File
@@ -12,8 +12,6 @@ name: security-review
on:
pull_request_target:
types: [opened, synchronize, reopened]
issue_comment:
types: [created]
permissions:
contents: read
@@ -22,13 +20,10 @@ permissions:
jobs:
# bp-exempt: PR security review bot signal; required merge state is enforced by CI / all-required.
approved:
# See qa-review.yml header for full A1-α / A1.1 (v1.3 — informational
# log only, NOT a gate) / A4 / A5 design rationale.
# Comment-triggered refires live in review-refire-comments.yml. Keeping
# this workflow PR-only avoids comment-triggered queue storms.
if: |
github.event_name == 'pull_request_target' ||
(github.event_name == 'issue_comment' &&
github.event.issue.pull_request != null &&
startsWith(github.event.comment.body, '/security-recheck'))
github.event_name == 'pull_request_target'
runs-on: ubuntu-latest
steps:
- name: Privilege check (A1.1 — INFORMATIONAL log only, NOT a gate)
@@ -37,7 +32,7 @@ jobs:
# so re-running on a non-collaborator comment is harmless.
if: github.event_name == 'issue_comment'
env:
GITEA_TOKEN: ${{ secrets.RFC_324_TEAM_READ_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
login="${{ github.event.comment.user.login }}"
@@ -62,7 +57,7 @@ jobs:
- name: Evaluate security-review
env:
GITEA_TOKEN: ${{ secrets.RFC_324_TEAM_READ_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_HOST: git.moleculesai.app
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
+2 -1
View File
@@ -92,7 +92,8 @@ jobs:
(github.event_name == 'issue_comment' &&
github.event.issue.pull_request != null &&
(contains(github.event.comment.body, '/sop-ack') ||
contains(github.event.comment.body, '/sop-revoke')))
contains(github.event.comment.body, '/sop-revoke') ||
contains(github.event.comment.body, '/sop-n/a')))
runs-on: ubuntu-latest
steps:
- name: Check out BASE ref (trust boundary — never PR-head)
+13 -40
View File
@@ -1,4 +1,4 @@
# sop-tier-refire — issue_comment-triggered refire of sop-tier-check.
# sop-tier-refire — manual fallback for sop-tier-check refire.
#
# Closes internal#292. Gitea 1.22.6 doesn't refire workflows on the
# `pull_request_review` event (go-gitea/gitea#33700); the `sop-tier-check`
@@ -8,12 +8,12 @@
# to merge is the admin force-merge path (audited via `audit-force-merge`
# but the audit trail keeps growing; see `feedback_never_admin_merge_bypass`).
#
# Workaround pattern from `feedback_pull_request_review_no_refire`:
# `issue_comment` events DO fire reliably on 1.22.6. When a repo
# MEMBER/OWNER/COLLABORATOR comments `/refire-tier-check` on a PR, this
# workflow re-runs the sop-tier-check logic and POSTs the resulting
# status to the PR head SHA directly. No empty commit, no git history
# bloat, no cascade re-fire of every other workflow on the PR.
# Comment-triggered refires now live in `review-refire-comments.yml`. Gitea
# queues issue_comment workflows before evaluating job-level `if:`, so having
# qa-review, security-review, sop-checklist, and sop-tier-refire all subscribe
# to every comment caused queue storms on SOP-heavy PRs. This workflow is a
# non-automatic breadcrumb only; Gitea 1.22.6 does not support
# workflow_dispatch inputs, so real refires must use `/refire-tier-check`.
#
# SECURITY MODEL:
#
@@ -37,43 +37,16 @@
# Rate-limit: a 1s pre-sleep + a "skip if status posted in last 30s"
# guard prevents comment-spam from thrashing the status. See the script.
name: sop-tier-check refire (issue_comment)
name: sop-tier-check refire (manual)
on:
issue_comment:
types: [created]
workflow_dispatch:
jobs:
refire:
# Three gates, all required:
# - comment is on a PR (not a plain issue)
# - commenter is MEMBER, OWNER, or COLLABORATOR
# - comment body contains the slash-command trigger
if: |
github.event.issue.pull_request != null &&
contains(fromJson('["MEMBER","OWNER","COLLABORATOR"]'), github.event.comment.author_association) &&
contains(github.event.comment.body, '/refire-tier-check')
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
statuses: write
steps:
- name: Check out base branch (for the script)
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Load the script from the default branch (main), matching the
# sop-tier-check.yml security model.
ref: ${{ github.event.repository.default_branch }}
- name: Re-evaluate sop-tier-check and POST status
env:
# Same org-level secret sop-tier-check.yml + audit-force-merge.yml use.
# Fallback to GITHUB_TOKEN with a clear error if missing.
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_HOST: git.moleculesai.app
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.issue.number }}
COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
# Set to '1' for diagnostic per-API-call output. Off by default.
SOP_DEBUG: '0'
run: bash .gitea/scripts/sop-tier-refire.sh
- name: Explain supported refire path
run: |
echo "::error::Gitea 1.22.6 does not support workflow_dispatch inputs here; comment /refire-tier-check on the PR instead."
exit 1
+2 -2
View File
@@ -327,7 +327,7 @@ function OrgCTA({ org }: { org: Org }) {
return (
<a
href={href}
className="rounded bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-500"
className="rounded bg-emerald-700 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-600"
>
Open
</a>
@@ -337,7 +337,7 @@ function OrgCTA({ org }: { org: Org }) {
return (
<a
href={`/pricing?org=${encodeURIComponent(org.slug)}`}
className="rounded bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-500"
className="rounded bg-amber-800 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700"
>
Complete payment
</a>
+4 -1
View File
@@ -164,7 +164,10 @@ export function AuditTrailPanel({ workspaceId }: Props) {
{/* Error banner */}
{error && (
<div className="mx-4 mt-3 px-3 py-2 bg-red-950/30 border border-red-800/40 rounded text-xs text-bad shrink-0">
<div
role="alert"
className="mx-4 mt-3 px-3 py-2 bg-red-950/30 border border-red-800/40 rounded text-xs text-bad shrink-0"
>
{error}
</div>
)}
+1 -1
View File
@@ -96,7 +96,7 @@ export function ConfirmDialog({
// readable in both light and dark themes.
const confirmColors =
confirmVariant === "danger"
? "bg-red-600 hover:bg-red-700 text-white"
? "bg-red-700 hover:bg-red-600 text-white"
: confirmVariant === "warning"
? "bg-amber-800 hover:bg-amber-700 text-white"
: "bg-accent hover:bg-accent-strong text-white";
+13 -6
View File
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
import { api } from "@/lib/api";
import { showToast } from "./Toaster";
@@ -23,9 +23,17 @@ export function ContextMenu() {
const setPanelTab = useCanvasStore((s) => s.setPanelTab);
const nestNode = useCanvasStore((s) => s.nestNode);
const contextNodeId = contextMenu?.nodeId ?? null;
const hasChildren = useCanvasStore((s) =>
contextNodeId ? s.nodes.some((n) => n.data.parentId === contextNodeId) : false
// Select the full nodes array (stable reference across unrelated store
// updates) and derive children via useMemo. Filtering inside the
// selector returned a new array every call, which Zustand's
// useSyncExternalStore saw as "snapshot changed" → schedule
// re-render → loop → React error #185. See canvas-store-snapshots.
const nodes = useCanvasStore((s) => s.nodes);
const children = useMemo(
() => (contextNodeId ? nodes.filter((n) => n.data.parentId === contextNodeId) : []),
[nodes, contextNodeId],
);
const hasChildren = children.length > 0;
const setPendingDelete = useCanvasStore((s) => s.setPendingDelete);
const ref = useRef<HTMLDivElement>(null);
const [actionLoading, setActionLoading] = useState(false);
@@ -189,10 +197,9 @@ export function ContextMenu() {
// it survives ContextMenu unmount. Closing the menu here avoids the
// prior race where the portal dialog's Confirm click was treated as
// "outside" by the menu's outside-click handler.
const childNodes = useCanvasStore.getState().nodes.filter((n) => n.data.parentId === contextMenu.nodeId);
setPendingDelete({ id: contextMenu.nodeId, name: contextMenu.nodeData.name, hasChildren, children: childNodes.map(c => ({ id: c.id, name: c.data.name })) });
setPendingDelete({ id: contextMenu.nodeId, name: contextMenu.nodeData.name, hasChildren, children: children.map(c => ({ id: c.id, name: c.data.name })) });
closeContextMenu();
}, [contextMenu, setPendingDelete, closeContextMenu]);
}, [contextMenu, setPendingDelete, closeContextMenu, children, hasChildren]);
const handleViewDetails = useCallback(() => {
if (!contextMenu) return;
@@ -164,12 +164,12 @@ export function DeleteCascadeConfirmDialog({
type="button"
onClick={onConfirm}
disabled={!checked}
// Hover goes DARKER, not lighter — bg-red-500 on white text
// drops contrast below AA vs bg-red-700. Same trap fixed in
// ConfirmDialog and ApprovalBanner. focus-visible ring matches.
// Hover goes DARKER, not lighter — bg-red-600 on white text
// drops contrast below AA. Same trap fixed in ConfirmDialog.
// focus-visible ring matches the canvas chrome.
className={`px-3.5 py-1.5 text-[13px] rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken
${checked
? "bg-red-600 hover:bg-red-700 text-white cursor-pointer"
? "bg-red-700 hover:bg-red-600 text-white cursor-pointer"
: "bg-red-900/30 text-bad/40 cursor-not-allowed"
}`}
>
+1 -1
View File
@@ -51,7 +51,7 @@ export class ErrorBoundary extends React.Component<
render() {
if (this.state.hasError) {
return (
<div className="fixed inset-0 flex items-center justify-center bg-surface z-50">
<div role="alert" aria-live="assertive" className="fixed inset-0 flex items-center justify-center bg-surface z-50">
<div className="max-w-md rounded-2xl border border-red-500/30 bg-surface-sunken/90 px-8 py-8 text-center shadow-2xl shadow-black/40">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-red-500/10 border border-red-500/30">
<svg
@@ -389,7 +389,7 @@ export function ProvisioningTimeout({
<button
type="button"
onClick={handleCancelConfirm}
className="px-3.5 py-1.5 text-[12px] bg-red-600 hover:bg-red-500 text-white rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-400 focus-visible:ring-offset-1"
className="px-3.5 py-1.5 text-[12px] bg-red-800 hover:bg-red-700 text-white rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-400 focus-visible:ring-offset-1"
>
Remove Workspace
</button>
@@ -398,3 +398,78 @@ describe("ContextMenu — item actions", () => {
expect(mockPost).toHaveBeenCalledWith("/workspaces/n1/resume", {});
});
});
/**
* Regression tests for GitHub issue #651 — React error #185:
* "Maximum update depth exceeded" on Chat tab / mobile.
*
* Root cause: ContextMenu's children selector ran `.filter()` inside the
* Zustand hook, returning a brand-new array reference on every render.
* Zustand's useSyncExternalStore compared snapshots with Object.is —
* a new array always differs — so React kept scheduling re-renders,
* hit the 50-update depth cap, and crashed.
*
* Fix: select the stable `nodes` array once, derive children via
* useMemo outside the store subscription.
*/
describe("ContextMenu — hasChildren regression (GitHub #651)", () => {
beforeEach(() => { setupApiMocks(); });
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockStoreState.contextMenu = null;
mockStoreState.closeContextMenu.mockClear();
mockStoreState.updateNodeData.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
mockStoreState.nestNode.mockClear();
mockStoreState.setPendingDelete.mockClear();
mockStoreState.setCollapsed.mockClear();
mockStoreState.arrangeChildren.mockClear();
mockStoreState.nodes = [];
resetApiMocks();
vi.mocked(showToast).mockClear();
});
it("setPendingDelete receives correct children array when workspace has children", () => {
openMenu({ nodeId: "ws-parent", nodeData: { name: "Parent", status: "online", tier: 4, role: "assistant" } });
mockStoreState.nodes = [
{ id: "ws-child-a", data: { parentId: "ws-parent" } },
{ id: "ws-child-b", data: { parentId: "ws-parent" } },
];
render(<ContextMenu />);
const deleteBtn = screen.getAllByRole("menuitem").find((el) =>
el.textContent?.includes("Delete")
)!;
fireEvent.click(deleteBtn);
expect(mockStoreState.setPendingDelete).toHaveBeenCalledWith(
expect.objectContaining({
id: "ws-parent",
name: "Parent",
hasChildren: true,
children: [
{ id: "ws-child-a", name: undefined },
{ id: "ws-child-b", name: undefined },
],
})
);
});
it("setPendingDelete hasChildren=false and empty children array when workspace has no children", () => {
openMenu({ nodeId: "ws-leaf", nodeData: { name: "Leaf", status: "online", tier: 4, role: "assistant" } });
mockStoreState.nodes = [];
render(<ContextMenu />);
const deleteBtn = screen.getAllByRole("menuitem").find((el) =>
el.textContent?.includes("Delete")
)!;
fireEvent.click(deleteBtn);
expect(mockStoreState.setPendingDelete).toHaveBeenCalledWith(
expect.objectContaining({
id: "ws-leaf",
name: "Leaf",
hasChildren: false,
children: [],
})
);
});
});
@@ -75,7 +75,7 @@ export function DropTargetBadge() {
)}
<div
data-testid="drop-badge"
className="pointer-events-none absolute z-50 -translate-x-1/2 -translate-y-full rounded-md bg-emerald-500 px-2 py-0.5 text-[11px] font-medium text-white shadow-lg shadow-emerald-950/40"
className="pointer-events-none absolute z-50 -translate-x-1/2 -translate-y-full rounded-md bg-emerald-700 px-2 py-0.5 text-[11px] font-medium text-white shadow-lg shadow-emerald-950/40"
style={{ left: badge.x, top: badge.y - 6 }}
>
Drop into: {targetName}
+4 -5
View File
@@ -1011,11 +1011,10 @@ function MyChatPanel({ workspaceId, data }: Props) {
<div
className={`max-w-[85%] rounded-lg px-3 py-2 text-xs ${
msg.role === "user"
// Solid blue-600 in both modes — `bg-accent` themes
// lighter in dark, dropping white-text contrast to
// ~3:1 (fails AA). blue-600 keeps ~5:1 against white
// on both warm-paper and dark-slate panels.
? "bg-blue-600 text-white border border-blue-700 dark:bg-blue-500 dark:border-blue-400 shadow-sm"
// Blue-600 on white = 3.0:1 (WCAG AA FAIL) in light mode.
// Blue-700 on white = 4.5:1 (PASS). In dark mode, blue-600
// on zinc-800 = 4.9:1 (PASS). So: blue-700 light, blue-600 dark.
? "bg-blue-700 text-white border border-blue-800 dark:bg-blue-600 dark:border-blue-700 shadow-sm"
: msg.role === "system"
// Bump the system bubble's opacity in dark — /10
// overlay was nearly invisible against the dark
+4 -4
View File
@@ -325,10 +325,10 @@ export function DetailsTab({ workspaceId, data }: Props) {
<button
type="button"
onClick={handleDelete}
// hover:bg-red-500 LIGHTER on white text drops AA;
// flipped to bg-red-700 + focus-visible danger ring,
// matching the ConfirmDialog/DeleteCascade pattern.
className="px-3 py-1 bg-red-600 hover:bg-red-700 text-xs rounded text-white transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
// Red-600 on white text = 3.9:1 (WCAG AA FAIL).
// Red-700 = 4.6:1 (PASS). Hover goes DARKER (red-600)
// to signal press. Same pattern as ConfirmDialog/DeleteCascade.
className="px-3 py-1 bg-red-700 hover:bg-red-600 text-xs rounded text-white transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
Confirm Delete
</button>
@@ -131,7 +131,7 @@ export function ExternalConnectionSection({ workspaceId }: Props) {
<button
type="button"
onClick={doRotate}
className="px-3 py-1.5 bg-red-700 hover:bg-red-600 text-xs rounded text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1"
className="px-3 py-1.5 bg-red-800 hover:bg-red-700 text-xs rounded text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1"
>
Rotate
</button>
@@ -1,217 +1,181 @@
// @vitest-environment jsdom
/**
* FilesTab: NotAvailablePanel + FilesToolbar coverage.
* Tests for the main FilesTab / PlatformOwnedFilesTab component.
*
* NotAvailablePanel: pure presentational component — renders a "feature not
* available" placeholder for external-runtime workspaces.
* FilesToolbar: pure props-driven component — directory selector, file count,
* action buttons (New, Upload, Export, Clear, Refresh) with correct aria-labels.
* Covers: NotAvailablePanel (external runtime), loading/empty/error states,
* FilesToolbar actions, and the /configs-only upload guard.
*
* No @testing-library/jest-dom import — use textContent / className /
* getAttribute checks to avoid "expect is not defined" errors.
* No @testing-library/jest-dom — use textContent / className / getAttribute.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import React from "react";
import { FilesToolbar } from "../FilesToolbar";
import { NotAvailablePanel } from "../NotAvailablePanel";
import { FilesTab } from "../../FilesTab.tsx";
import { FilesToolbar } from "../FilesToolbar.tsx";
import type { FileEntry } from "../../FilesTab/tree";
// ─── afterEach ─────────────────────────────────────────────────────────────────
// ─── Mock ──────────────────────────────────────────────────────────────────
const _mockGet = vi.hoisted(() => vi.fn<() => Promise<unknown>>());
vi.mock("@/lib/api", () => ({
api: { get: _mockGet, put: vi.fn(), del: vi.fn() },
}));
afterEach(() => {
cleanup();
vi.restoreAllMocks();
_mockGet.mockReset();
});
// ─── NotAvailablePanel ─────────────────────────────────────────────────────────
// ─── Helpers ───────────────────────────────────────────────────────────────
describe("NotAvailablePanel", () => {
it("renders heading 'Files not available'", () => {
const { container } = render(<NotAvailablePanel runtime="external" />);
expect(container.textContent).toContain("Files not available");
});
const emptyFileList: FileEntry[] = [];
it("renders the runtime name in monospace", () => {
const { container } = render(<NotAvailablePanel runtime="external" />);
expect(container.textContent).toContain("external");
const spans = container.querySelectorAll("span");
const monoSpans = Array.from(spans).filter(
(s) => s.className && s.className.includes("font-mono"),
);
expect(monoSpans.length).toBeGreaterThan(0);
});
/** Render FilesTab with a non-external runtime (triggers PlatformOwnedFilesTab). */
function renderPlatformTab(extraProps: Partial<React.ComponentProps<typeof FilesTab>> = {}) {
return render(
<FilesTab
workspaceId="ws-1"
data={{ id: "ws-1", name: "Test", runtime: "claude-code", status: "online", tier: 0, skills: [], created_at: "" }}
{...extraProps}
/>,
);
}
it("renders a Chat tab hint in description", () => {
const { container } = render(<NotAvailablePanel runtime="remote-agent" />);
expect(container.textContent).toContain("Chat tab");
});
/** Render FilesToolbar directly with stub handlers. */
function renderToolbar(extraProps: Partial<React.ComponentProps<typeof FilesToolbar>> = {}) {
return render(
<FilesToolbar
root="/configs"
setRoot={vi.fn()}
fileCount={0}
onNewFile={vi.fn()}
onUpload={vi.fn()}
onDownloadAll={vi.fn()}
onClearAll={vi.fn()}
onRefresh={vi.fn()}
{...extraProps}
/>
);
}
it("SVG icon has aria-hidden=true", () => {
const { container } = render(<NotAvailablePanel runtime="external" />);
const svg = container.querySelector("svg");
expect(svg?.getAttribute("aria-hidden")).toBe("true");
});
// ─── NotAvailablePanel ──────────────────────────────────────────────────────
it("renders without crashing for any runtime string", () => {
const { container } = render(<NotAvailablePanel runtime="unknown-runtime" />);
expect(container.textContent).toContain("unknown-runtime");
});
it("applies the correct layout classes to root div", () => {
const { container } = render(<NotAvailablePanel runtime="external" />);
const root = container.firstElementChild as HTMLElement;
expect(root.className).toContain("flex");
expect(root.className).toContain("flex-col");
expect(root.className).toContain("items-center");
});
});
// ─── FilesToolbar ───────────────────────────────────────────────────────────────
describe("FilesToolbar", () => {
const noop = vi.fn();
function renderToolbar(props: Partial<React.ComponentProps<typeof FilesToolbar>> = {}) {
return render(
<FilesToolbar
root="/configs"
setRoot={noop}
fileCount={0}
onNewFile={noop}
onUpload={noop}
onDownloadAll={noop}
onClearAll={noop}
onRefresh={noop}
{...props}
describe("FilesTab — NotAvailablePanel", () => {
it("renders NotAvailablePanel when runtime is external", async () => {
_mockGet.mockResolvedValueOnce(emptyFileList);
render(
<FilesTab
workspaceId="ws-1"
data={{ id: "ws-1", name: "Test", runtime: "external", status: "online", tier: 0, skills: [], created_at: "" }}
/>,
);
}
it("renders the directory selector with correct aria-label", () => {
const { container } = renderToolbar();
const select = container.querySelector("select");
expect(select?.getAttribute("aria-label")).toBe("File root directory");
expect(screen.getByText(/Files not available/i)).toBeTruthy();
});
it("directory selector has all four options", () => {
const { container } = renderToolbar();
const select = container.querySelector("select") as HTMLSelectElement;
const options = Array.from(select?.options ?? []);
const values = options.map((o) => o.value);
expect(values).toContain("/configs");
expect(values).toContain("/home");
expect(values).toContain("/workspace");
expect(values).toContain("/plugins");
});
it("calls setRoot when directory changes", () => {
const setRoot = vi.fn();
const { container } = renderToolbar({ setRoot });
const select = container.querySelector("select") as HTMLSelectElement;
select.value = "/home";
select.dispatchEvent(new Event("change", { bubbles: true }));
expect(setRoot).toHaveBeenCalledWith("/home");
});
it("displays the file count", () => {
const { container } = renderToolbar({ fileCount: 42 });
expect(container.textContent).toContain("42 files");
});
it("shows New + Upload + Clear buttons for /configs", () => {
const { container } = renderToolbar({ root: "/configs" });
const texts = Array.from(container.querySelectorAll("button")).map(
(b) => b.textContent?.trim(),
it("renders the runtime name in NotAvailablePanel", async () => {
_mockGet.mockResolvedValueOnce(emptyFileList);
render(
<FilesTab
workspaceId="ws-1"
data={{ id: "ws-1", name: "Test", runtime: "external", status: "online", tier: 0, skills: [], created_at: "" }}
/>,
);
expect(texts).toContain("+ New");
expect(texts).toContain("Upload");
expect(texts).toContain("Clear");
expect(texts).toContain("Export");
expect(texts).toContain("↻");
expect(screen.getByText(/external/i)).toBeTruthy();
});
it("hides New + Upload + Clear for /workspace", () => {
const { container } = renderToolbar({ root: "/workspace" });
const texts = Array.from(container.querySelectorAll("button")).map(
(b) => b.textContent?.trim(),
it("does NOT call api.get when runtime is external", async () => {
render(
<FilesTab
workspaceId="ws-1"
data={{ id: "ws-1", name: "Test", runtime: "external", status: "online", tier: 0, skills: [], created_at: "" }}
/>,
);
expect(texts).not.toContain("+ New");
expect(texts).not.toContain("Upload");
expect(texts).not.toContain("Clear");
expect(texts).toContain("Export");
expect(_mockGet).not.toHaveBeenCalled();
});
});
it("hides New + Upload + Clear for /home", () => {
const { container } = renderToolbar({ root: "/home" });
const texts = Array.from(container.querySelectorAll("button")).map(
(b) => b.textContent?.trim(),
// ─── Loading / Empty / Error states ────────────────────────────────────────
describe("FilesTab — states", () => {
it("shows loading text while fetching files", () => {
_mockGet.mockImplementation(
() => new Promise<unknown>(() => {}) as unknown as Promise<unknown>,
);
expect(texts).not.toContain("+ New");
expect(texts).not.toContain("Upload");
expect(texts).not.toContain("Clear");
renderPlatformTab();
expect(screen.getByText("Loading files...")).toBeTruthy();
});
it("hides New + Upload + Clear for /plugins", () => {
const { container } = renderToolbar({ root: "/plugins" });
const texts = Array.from(container.querySelectorAll("button")).map(
(b) => b.textContent?.trim(),
);
expect(texts).not.toContain("+ New");
expect(texts).not.toContain("Upload");
expect(texts).not.toContain("Clear");
it("shows 'No config files yet' when root is /configs and no files", async () => {
_mockGet.mockResolvedValueOnce(emptyFileList);
renderPlatformTab();
await waitFor(() => {
expect(screen.getByText(/No config files yet/i)).toBeTruthy();
});
});
it("New button has correct aria-label", () => {
const { container } = renderToolbar({ root: "/configs" });
const newBtn = container.querySelector('button[aria-label="Create new file"]');
expect(newBtn?.textContent?.trim()).toBe("+ New");
it("fetches from the correct endpoint", async () => {
_mockGet.mockResolvedValueOnce(emptyFileList);
renderPlatformTab();
await waitFor(() => {
expect(_mockGet).toHaveBeenCalledWith(expect.stringContaining("/workspaces/ws-1/files"));
});
});
it("Export button has correct aria-label", () => {
const { container } = renderToolbar();
const exportBtn = container.querySelector('button[aria-label="Download all files"]');
expect(exportBtn?.textContent?.trim()).toBe("Export");
it("shows file count from toolbar when files exist", async () => {
_mockGet.mockResolvedValue([
{ path: "configs/a.yaml", size: 10, dir: false },
{ path: "configs/b.yaml", size: 20, dir: false },
]);
renderPlatformTab();
await waitFor(() => {
expect(screen.getByText("2 files")).toBeTruthy();
});
});
});
// ─── FilesToolbar ──────────────────────────────────────────────────────────
describe("FilesTab — FilesToolbar", () => {
it("shows Refresh button", async () => {
_mockGet.mockResolvedValueOnce(emptyFileList);
renderPlatformTab();
await waitFor(() => {
expect(screen.getByLabelText("Refresh file list")).toBeTruthy();
});
});
it("Clear button has correct aria-label", () => {
const { container } = renderToolbar({ root: "/configs" });
const clearBtn = container.querySelector('button[aria-label="Delete all files"]');
expect(clearBtn?.textContent?.trim()).toBe("Clear");
it("shows root directory selector", async () => {
_mockGet.mockResolvedValueOnce(emptyFileList);
renderPlatformTab();
await waitFor(() => {
expect(screen.getByRole("combobox")).toBeTruthy();
});
});
it("Refresh button has correct aria-label", () => {
const { container } = renderToolbar();
const refreshBtn = container.querySelector('button[aria-label="Refresh file list"]');
expect(refreshBtn?.textContent?.trim()).toBe("↻");
it("Refresh button triggers a reload", async () => {
// Use persistent mock — loadFiles fires on mount AND on Refresh click.
_mockGet.mockResolvedValue(emptyFileList);
renderPlatformTab();
await waitFor(() => screen.getByLabelText("Refresh file list"));
const before = _mockGet.mock.calls.length;
fireEvent.click(screen.getByLabelText("Refresh file list"));
await waitFor(() => {
expect(_mockGet.mock.calls.length).toBeGreaterThan(before);
});
});
});
it("calls onNewFile when New button is clicked", () => {
const onNewFile = vi.fn();
const { container } = renderToolbar({ root: "/configs", onNewFile });
container.querySelector('button[aria-label="Create new file"]')!.click();
expect(onNewFile).toHaveBeenCalledTimes(1);
});
// ─── Upload guard ──────────────────────────────────────────────────────────
it("calls onDownloadAll when Export button is clicked", () => {
const onDownloadAll = vi.fn();
const { container } = renderToolbar({ onDownloadAll });
container.querySelector('button[aria-label="Download all files"]')!.click();
expect(onDownloadAll).toHaveBeenCalledTimes(1);
});
describe("FilesTab — upload guard", () => {
it("no error alert on dragover when root is /configs (default)", async () => {
_mockGet.mockResolvedValue(emptyFileList);
renderPlatformTab();
await waitFor(() => screen.getByText(/No config files yet/i));
it("calls onClearAll when Clear button is clicked", () => {
const onClearAll = vi.fn();
const { container } = renderToolbar({ root: "/configs", onClearAll });
container.querySelector('button[aria-label="Delete all files"]')!.click();
expect(onClearAll).toHaveBeenCalledTimes(1);
});
it("calls onRefresh when Refresh button is clicked", () => {
const onRefresh = vi.fn();
const { container } = renderToolbar({ onRefresh });
container.querySelector('button[aria-label="Refresh file list"]')!.click();
expect(onRefresh).toHaveBeenCalledTimes(1);
// No alert should be present
expect(screen.queryByRole("alert")).toBeNull();
});
it("applies focus-visible ring to all interactive buttons", () => {
@@ -0,0 +1,218 @@
// @vitest-environment jsdom
/**
* Tests for tree.ts — buildTree and getIcon pure functions.
*/
import { describe, expect, it } from "vitest";
import type { FileEntry } from "../tree";
import { buildTree, getIcon } from "../tree";
// ─── getIcon ─────────────────────────────────────────────────────────────────
describe("getIcon", () => {
it("returns folder emoji for directories", () => {
expect(getIcon("/configs", true)).toBe("📁");
});
it("returns correct emoji for .md", () => {
expect(getIcon("readme.md", false)).toBe("📄");
});
it("returns correct emoji for .yaml", () => {
expect(getIcon("config.yaml", false)).toBe("⚙");
});
it("returns correct emoji for .yml", () => {
expect(getIcon("config.yml", false)).toBe("⚙");
});
it("returns correct emoji for .py", () => {
expect(getIcon("script.py", false)).toBe("🐍");
});
it("returns correct emoji for .ts", () => {
expect(getIcon("index.ts", false)).toBe("💠");
});
it("returns correct emoji for .tsx", () => {
expect(getIcon("App.tsx", false)).toBe("💠");
});
it("returns correct emoji for .js", () => {
expect(getIcon("index.js", false)).toBe("📜");
});
it("returns correct emoji for .json", () => {
expect(getIcon("package.json", false)).toBe("{}");
});
it("returns correct emoji for .html", () => {
expect(getIcon("index.html", false)).toBe("🌐");
});
it("returns correct emoji for .css", () => {
expect(getIcon("style.css", false)).toBe("🎨");
});
it("returns correct emoji for .sh", () => {
expect(getIcon("deploy.sh", false)).toBe("▸");
});
it("returns default file emoji for unknown extensions", () => {
expect(getIcon("Makefile", false)).toBe("📄");
expect(getIcon("Dockerfile", false)).toBe("📄");
expect(getIcon("Rakefile", false)).toBe("📄");
});
it("extension matching is case-insensitive", () => {
expect(getIcon("readme.MD", false)).toBe("📄");
expect(getIcon("script.PY", false)).toBe("🐍");
});
});
// ─── buildTree ───────────────────────────────────────────────────────────────
describe("buildTree", () => {
it("returns empty array for empty input", () => {
expect(buildTree([])).toEqual([]);
});
it("adds a single file at root", () => {
const files: FileEntry[] = [{ path: "config.yaml", size: 128, dir: false }];
const tree = buildTree(files);
expect(tree).toHaveLength(1);
expect(tree[0]).toMatchObject({
name: "config.yaml",
path: "config.yaml",
isDir: false,
children: [],
size: 128,
});
});
it("adds a single directory at root", () => {
const files: FileEntry[] = [{ path: "skills", size: 0, dir: true }];
const tree = buildTree(files);
expect(tree).toHaveLength(1);
expect(tree[0]).toMatchObject({
name: "skills",
path: "skills",
isDir: true,
children: [],
size: 0,
});
});
it("sorts dirs before files at the same level", () => {
const files: FileEntry[] = [
{ path: "b.txt", size: 10, dir: false },
{ path: "a.txt", size: 10, dir: false },
{ path: "z-dir", size: 0, dir: true },
{ path: "a-dir", size: 0, dir: true },
];
const tree = buildTree(files);
expect(tree).toHaveLength(4);
// Dirs first: z-dir, a-dir alphabetically → a before z
expect(tree[0].name).toBe("a-dir");
expect(tree[1].name).toBe("z-dir");
// Then files alphabetically
expect(tree[2].name).toBe("a.txt");
expect(tree[3].name).toBe("b.txt");
});
it("alphabetically sorts files within the same level", () => {
const files: FileEntry[] = [
{ path: "z.yaml", size: 10, dir: false },
{ path: "a.yaml", size: 10, dir: false },
{ path: "m.yaml", size: 10, dir: false },
];
const tree = buildTree(files);
expect(tree.map((n) => n.name)).toEqual(["a.yaml", "m.yaml", "z.yaml"]);
});
it("nests a file under its parent directory", () => {
const files: FileEntry[] = [
{ path: "skills", size: 0, dir: true },
{ path: "skills/readme.md", size: 64, dir: false },
];
const tree = buildTree(files);
expect(tree).toHaveLength(1);
expect(tree[0].name).toBe("skills");
expect(tree[0].children).toHaveLength(1);
expect(tree[0].children[0]).toMatchObject({
name: "readme.md",
path: "skills/readme.md",
isDir: false,
size: 64,
});
});
it("creates intermediate directories automatically", () => {
const files: FileEntry[] = [
{ path: "a/b/c/deep.txt", size: 32, dir: false },
];
const tree = buildTree(files);
// Root has one child: "a"
expect(tree).toHaveLength(1);
expect(tree[0].name).toBe("a");
expect(tree[0].isDir).toBe(true);
// "a" has one child: "b"
expect(tree[0].children).toHaveLength(1);
expect(tree[0].children[0].name).toBe("b");
// "b" has one child: "c"
expect(tree[0].children[0].children).toHaveLength(1);
expect(tree[0].children[0].children[0].name).toBe("c");
// "c" has the file
expect(tree[0].children[0].children[0].children[0].name).toBe("deep.txt");
expect(tree[0].children[0].children[0].children[0].size).toBe(32);
});
it("adds multiple files to the same directory", () => {
const files: FileEntry[] = [
{ path: "configs", size: 0, dir: true },
{ path: "configs/a.yaml", size: 10, dir: false },
{ path: "configs/b.yaml", size: 20, dir: false },
];
const tree = buildTree(files);
expect(tree).toHaveLength(1);
expect(tree[0].children.map((n) => n.name).sort()).toEqual(["a.yaml", "b.yaml"]);
});
it("does not duplicate a directory already created as intermediate", () => {
const files: FileEntry[] = [
{ path: "a/b.txt", size: 5, dir: false },
{ path: "a", size: 0, dir: true },
];
const tree = buildTree(files);
// "a" should appear only once
expect(tree).toHaveLength(1);
expect(tree[0].name).toBe("a");
// The dir "a" should still contain "b.txt"
expect(tree[0].children).toHaveLength(1);
expect(tree[0].children[0].name).toBe("b.txt");
});
it("intermediate dirs have size 0", () => {
const files: FileEntry[] = [
{ path: "a/b/c/file.txt", size: 1, dir: false },
];
const tree = buildTree(files);
expect(tree[0].size).toBe(0);
expect(tree[0].children[0].size).toBe(0);
});
it("handles deeply nested mixed dirs and files", () => {
const files: FileEntry[] = [
{ path: "a", size: 0, dir: true },
{ path: "a/b", size: 0, dir: true },
{ path: "a/b/c", size: 0, dir: true },
{ path: "a/b/c/d.txt", size: 1, dir: false },
{ path: "a/b/e.txt", size: 2, dir: false },
{ path: "a/f.txt", size: 3, dir: false },
];
const tree = buildTree(files);
expect(tree).toHaveLength(1); // root: "a"
expect(tree[0].children.map((n) => n.name).sort()).toEqual(["b", "f.txt"]);
expect(tree[0].children.find((n) => n.name === "b")!.children.map((n) => n.name).sort())
.toEqual(["c", "e.txt"]);
});
});
@@ -0,0 +1,60 @@
/**
* Tests for `isExternalLikeRuntime` — mirrors the backend's
* isExternalLikeRuntime() in workspace-server/internal/handlers/runtime_registry.go.
*
* These runtimes have no platform-owned container (no Files, Terminal, Docker config).
* Both frontend and backend must agree on which runtimes are "external-like" so
* the canvas can show/hide those tabs correctly and the backend can enforce
* the same semantics server-side.
*/
import { describe, it, expect } from "vitest";
import { isExternalLikeRuntime } from "../externalRuntimes";
describe("isExternalLikeRuntime", () => {
describe("known external-like runtimes", () => {
it.each([
["external"],
["kimi"],
["kimi-cli"],
])("%q returns true", (runtime) => {
expect(isExternalLikeRuntime(runtime)).toBe(true);
});
});
describe("non-external runtimes", () => {
it.each([
"claude-code",
"hermes",
"docker",
"local",
"agent",
"crewai",
"langgraph",
"openclaw",
"custom-runtime",
])("%q returns false", (runtime) => {
expect(isExternalLikeRuntime(runtime)).toBe(false);
});
});
describe("edge cases", () => {
it("returns false for undefined", () => {
expect(isExternalLikeRuntime(undefined)).toBe(false);
});
it("returns false for null", () => {
// @ts-expect-error — intentional runtime test, null is not a valid type
expect(isExternalLikeRuntime(null)).toBe(false);
});
it("returns false for empty string", () => {
expect(isExternalLikeRuntime("")).toBe(false);
});
it("is case-sensitive — kimi vs KIMI vs Kimi", () => {
expect(isExternalLikeRuntime("KIMI")).toBe(false);
expect(isExternalLikeRuntime("Kimi")).toBe(false);
expect(isExternalLikeRuntime("kimi")).toBe(true);
});
});
});
+189
View File
@@ -0,0 +1,189 @@
// @vitest-environment jsdom
/**
* Tests for hydrate.ts — canvas store hydration with exponential backoff.
*
* Covers:
* - Successful hydration on first attempt (no retries)
* - Retry with exponential backoff on failure
* - onRetrying callback called at correct intervals
* - Error propagation after MAX_RETRIES exhausted
* - Viewport persisted on success
* - Viewport failure is non-fatal
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { WorkspaceData } from "@/store/socket";
// ---------------------------------------------------------------------------
// Mock modules — must precede imports that use them
// ---------------------------------------------------------------------------
const mockHydrate = vi.fn();
const mockSetViewport = vi.fn();
vi.mock("@/lib/api", () => ({
api: {
get: vi.fn(),
},
PLATFORM_URL: "https://platform.test",
}));
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
() => ({}),
{
getState: () => ({
hydrate: mockHydrate,
setViewport: mockSetViewport,
}),
},
),
}));
// ---------------------------------------------------------------------------
// Import after mocks
// ---------------------------------------------------------------------------
import { api } from "@/lib/api";
import { hydrateCanvas, MAX_RETRIES } from "../hydrate";
// ---------------------------------------------------------------------------
// Mock data
// ---------------------------------------------------------------------------
const WORKSPACES: WorkspaceData[] = [
{ id: "ws-1", name: "Test Workspace" } as WorkspaceData,
];
const VIEWPORT = { x: 10, y: 20, zoom: 1.5 };
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const mockApiGet = vi.mocked(api.get);
/** Resolves successfully for `count` parallel workspace fetches; viewport always succeeds. */
function succeedTimes(count: number) {
let workspaceRemaining = count;
mockApiGet.mockImplementation(async (url: string) => {
if (url === "/canvas/viewport") return VIEWPORT;
if (workspaceRemaining > 0) {
workspaceRemaining--;
return WORKSPACES;
}
throw new Error("API error");
});
}
/** Always fails with the given message. */
function alwaysFail(msg = "Network error") {
mockApiGet.mockRejectedValue(new Error(msg));
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("hydrateCanvas", () => {
beforeEach(() => {
vi.clearAllMocks();
mockApiGet.mockReset();
mockHydrate.mockReset();
mockSetViewport.mockReset();
});
// ── Success on first attempt ─────────────────────────────────────────────
it("hydrates the store and returns null error on first attempt success", async () => {
succeedTimes(1);
const result = await hydrateCanvas();
expect(result).toEqual({ error: null });
expect(mockHydrate).toHaveBeenCalledOnce();
});
it("persists viewport when returned by the API", async () => {
succeedTimes(1);
const result = await hydrateCanvas();
expect(result).toEqual({ error: null });
expect(mockSetViewport).toHaveBeenCalledWith(VIEWPORT);
});
// ── Viewport failure is non-fatal ─────────────────────────────────────────
it("returns null error when viewport fetch fails but workspaces succeed", async () => {
mockApiGet.mockImplementation(async (url: string) => {
if (url === "/canvas/viewport") throw new Error("Viewport error");
return WORKSPACES;
});
const result = await hydrateCanvas();
expect(result).toEqual({ error: null });
expect(mockHydrate).toHaveBeenCalledOnce();
expect(mockSetViewport).not.toHaveBeenCalled();
});
// ── Retry logic ──────────────────────────────────────────────────────────
it("retries MAX_RETRIES times before returning an error", async () => {
alwaysFail();
const onRetrying = vi.fn();
const result = await Promise.race([
hydrateCanvas(onRetrying),
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 5000)),
]);
if (result === "timeout") throw new Error("Test timed out — retries not awaited correctly");
expect(result.error).not.toBeNull();
expect(onRetrying).toHaveBeenCalledTimes(MAX_RETRIES - 1);
}, 10000);
it("onRetrying is called with attempt number before each retry", async () => {
alwaysFail();
const onRetrying = vi.fn();
await Promise.race([
hydrateCanvas(onRetrying),
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 5000)),
]);
expect(onRetrying).toHaveBeenNthCalledWith(1, 1);
expect(onRetrying).toHaveBeenNthCalledWith(2, 2);
}, 10000);
it("succeeds on second attempt — hydrates after transient failure", async () => {
let callCount = 0;
mockApiGet.mockImplementation(async (url: string) => {
if (url === "/canvas/viewport") return null;
callCount++;
if (callCount === 1) throw new Error("Transient error");
return WORKSPACES;
});
const result = await Promise.race([
hydrateCanvas(),
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 5000)),
]);
if (result === "timeout") throw new Error("Test timed out");
expect(result).toEqual({ error: null });
expect(mockHydrate).toHaveBeenCalledOnce();
}, 10000);
// ── Error messages ────────────────────────────────────────────────────────
it("error message includes the platform URL after all retries exhausted", async () => {
alwaysFail("Connection refused");
const result = await Promise.race([
hydrateCanvas(),
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 5000)),
]);
if (result === "timeout") throw new Error("Test timed out");
expect(result.error).toContain("platform.test");
expect(result.error).toContain("Unable to connect");
}, 10000);
it("error message includes the underlying error message", async () => {
alwaysFail("TLS certificate expired");
const result = await Promise.race([
hydrateCanvas(),
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 5000)),
]);
if (result === "timeout") throw new Error("Test timed out");
expect(result.error).not.toBeNull();
expect(typeof result.error).toBe("string");
}, 10000);
});
+34 -6
View File
@@ -282,13 +282,17 @@
}
.secret-row__save-btn {
background: #2563eb;
background: #1d4ed8;
color: #ffffff;
border: none;
padding: 6px 12px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
transition: background-color 0.15s;
}
.secret-row__save-btn:hover {
background: #1e40af;
}
.secret-row__save-btn:focus-visible {
@@ -370,13 +374,17 @@
}
.add-key-form__save-btn {
background: #2563eb;
background: #1d4ed8;
color: #ffffff;
border: none;
padding: 8px 16px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
transition: background-color 0.15s;
}
.add-key-form__save-btn:hover {
background: #1e40af;
}
.add-key-form__save-btn:focus-visible {
@@ -510,7 +518,7 @@
.empty-state__body { font-size: 14px; color: #a1a1aa; margin: 0 0 24px; line-height: 1.5; }
.empty-state__cta {
background: #2563eb;
background: #1d4ed8;
color: #ffffff;
border: none;
padding: 10px 20px;
@@ -518,6 +526,10 @@
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.15s;
}
.empty-state__cta:hover {
background: #1e40af;
}
.empty-state__cta:focus-visible { outline: var(--focus-ring); outline-offset: var(--focus-ring-offset); }
@@ -561,12 +573,16 @@
.secrets-tab__error p { color: var(--status-invalid); margin: 0 0 12px; }
.secrets-tab__refresh-btn {
background: #2563eb;
background: #1d4ed8;
color: #ffffff;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.15s;
}
.secrets-tab__refresh-btn:hover {
background: #1e40af;
}
.secrets-tab__no-results {
@@ -690,12 +706,16 @@
}
.guard-dialog__discard-btn {
background: #2563eb;
background: #1d4ed8;
color: #ffffff;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.15s;
}
.guard-dialog__discard-btn:hover {
background: #1e40af;
}
.guard-dialog__discard-btn:focus-visible {
@@ -747,12 +767,20 @@
.top-bar__name { font-size: 14px; font-weight: 500; color: #d4d4d8; }
.top-bar__btn {
background: #2563eb;
background: #1d4ed8;
color: #ffffff;
border: none;
padding: 6px 12px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
transition: background-color 0.15s;
}
.top-bar__btn:hover {
background: #1e40af;
}
.top-bar__btn:focus-visible {
outline: none;
box-shadow: 0 0 0 2px #18181b, 0 0 0 4px #3b82f6;
}
+87
View File
@@ -22,6 +22,7 @@ Cross-links:
"""
from __future__ import annotations
import re
import subprocess
import sys
import textwrap
@@ -542,3 +543,89 @@ def test_rule9_prod_manual_deploy_allows_rollback_control(tmp_path):
_write(tmp_path, "ok.yml", PROD_ROLLBACK_OK)
r = _run_lint(tmp_path)
assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"
# ---------------------------------------------------------------------------
# CI change detector fanout — workflow-only PRs keep required contexts without
# running Go/Canvas/Python/shellcheck heavy steps.
# ---------------------------------------------------------------------------
CI_WORKFLOW = REPO_ROOT / ".gitea" / "workflows" / "ci.yml"
CI_SURFACES = ("platform", "canvas", "python", "scripts")
def _ci_change_patterns() -> dict[str, re.Pattern[str]]:
text = CI_WORKFLOW.read_text(encoding="utf-8")
patterns: dict[str, re.Pattern[str]] = {}
for surface, pattern in re.findall(
r'echo "(platform|canvas|python|scripts)=.*?grep -qE \'([^\']+)\'',
text,
):
patterns[surface] = re.compile(pattern)
assert set(patterns) == set(CI_SURFACES)
return patterns
def _classify_ci_change(*paths: str) -> dict[str, bool]:
patterns = _ci_change_patterns()
return {
surface: any(pattern.search(path) for path in paths)
for surface, pattern in patterns.items()
}
def test_ci_change_detector_workflow_only_edits_do_not_trigger_heavy_surfaces():
assert _classify_ci_change(".gitea/workflows/ci.yml") == {
"platform": False,
"canvas": False,
"python": False,
"scripts": False,
}
assert _classify_ci_change(".github/workflows/ci.yml") == {
"platform": False,
"canvas": False,
"python": False,
"scripts": False,
}
def test_ci_change_detector_narrow_surface_edits_only_trigger_their_surface():
assert _classify_ci_change("workspace-server/internal/handlers/foo.go") == {
"platform": True,
"canvas": False,
"python": False,
"scripts": False,
}
assert _classify_ci_change("canvas/app/page.tsx") == {
"platform": False,
"canvas": True,
"python": False,
"scripts": False,
}
assert _classify_ci_change("workspace/a2a_mcp_server.py") == {
"platform": False,
"canvas": False,
"python": True,
"scripts": False,
}
assert _classify_ci_change("tests/e2e/test_model_slug.sh") == {
"platform": False,
"canvas": False,
"python": False,
"scripts": True,
}
def test_ci_change_detector_docs_and_meta_scripts_do_not_trigger_surfaces():
assert _classify_ci_change("README.md") == {
"platform": False,
"canvas": False,
"python": False,
"scripts": False,
}
assert _classify_ci_change(".gitea/scripts/lint-workflow-yaml.py") == {
"platform": False,
"canvas": False,
"python": False,
"scripts": False,
}
+14 -4
View File
@@ -110,6 +110,13 @@ AGENT_LOGIN_MAP = {
"offsec": "core-offsec",
}
# Map alternate Gitea logins → canonical logins for gate matching.
# infra-sre is the engineers/core-devops agent (same team, same work).
# Without this alias, infra-sre comments/reviews never satisfy the engineers gate.
LOGIN_ALIASES = {
"infra-sre": "core-devops",
}
# SOP-6 tier → required agent groups
# tier:low → engineers,managers,ceo (OR: any one suffices)
# tier:medium → managers AND engineers AND qa,security (AND)
@@ -168,17 +175,18 @@ def signal_1_comment_scan(pr_number: int, repo: str) -> dict:
except GiteaError:
pass
# Collect APPROVED reviews from agent logins
# Collect APPROVED reviews from agent logins (resolving LOGIN_ALIASES)
try:
reviews = api_list(f"/repos/{owner}/{name}/pulls/{pr_number}/reviews")
for r in reviews:
login = r.get("user", {}).get("login", "")
if login in login_to_group and r.get("state") == "APPROVED":
canonical = LOGIN_ALIASES.get(login, login)
if canonical in login_to_group and r.get("state") == "APPROVED":
comments.append(
{
"id": f"review-{r['id']}",
"user": {"login": login},
"body": f"[{login}-agent] APPROVED",
"user": {"login": canonical},
"body": f"[{canonical}-agent] APPROVED",
"created_at": r.get("submitted_at") or r.get("created_at", ""),
"source": "review",
}
@@ -193,6 +201,8 @@ def signal_1_comment_scan(pr_number: int, repo: str) -> dict:
for c in comments:
body = c.get("body", "") or ""
user_login = c.get("user", {}).get("login", "")
# Resolve LOGIN_ALIASES so alternate logins satisfy the canonical gate
user_login = LOGIN_ALIASES.get(user_login, user_login)
if user_login != login:
continue
for m in AGENT_TAG_RE.finditer(body):
+42
View File
@@ -32,3 +32,45 @@ def test_run_skips_pr_not_targeting_default_branch(monkeypatch):
assert result["verdict"] == "CLEAR"
assert result["skipped"] is True
assert "staging" in result["reason"]
def test_signal_1_infra_sre_login_alias_resolved_to_core_devops(monkeypatch):
"""infra-sre posts [devops-agent] APPROVED → engineers gate satisfied via LOGIN_ALIASES."""
mod = load_gate_check()
def fake_api_get(path):
# PR 900 has tier:low label
if path == "/repos/molecule-ai/molecule-core/pulls/900":
return {
"number": 900,
"labels": [{"name": "tier:low"}],
}
raise AssertionError(f"unexpected api_get: {path}")
def fake_api_list(path):
if path == "/repos/molecule-ai/molecule-core/issues/900/comments":
return []
if path == "/repos/molecule-ai/molecule-core/pulls/900/comments":
return []
if path == "/repos/molecule-ai/molecule-core/pulls/900/reviews":
return [
{
"id": 1,
"user": {"login": "infra-sre"},
"state": "APPROVED",
"submitted_at": "2026-05-13T10:00:00Z",
}
]
raise AssertionError(f"unexpected api_list: {path}")
monkeypatch.setattr(mod, "api_get", fake_api_get)
monkeypatch.setattr(mod, "api_list", fake_api_list)
result = mod.signal_1_comment_scan(900, "molecule-ai/molecule-core")
assert result["verdict"] == "CLEAR"
assert result["signal"] == "agent_tag_comments"
# infra-sre (aliased to core-devops) should satisfy engineers gate
engineers = result["results"]["core-devops"]
assert engineers["verdict"] == "APPROVED"
assert engineers["group"] == "engineers"
+74
View File
@@ -157,6 +157,16 @@ func main() {
}
}
// Issue #831 bootstrap: if global_secrets has ADMIN_TOKEN=placeholder,
// replace it with the real token from the environment. This fixes
// workspaces provisioned before the correct value was seeded.
// Only runs for SaaS tenants (cpProv != nil) where containers inherit
// from global_secrets. Self-hosted deployments don't read ADMIN_TOKEN
// from global_secrets for container env — the fix doesn't apply.
if cpProv != nil {
fixAdminTokenPlaceholder()
}
port := envOr("PORT", "8080")
platformURL := envOr("PLATFORM_URL", fmt.Sprintf("http://host.docker.internal:%s", port))
configsDir := envOr("CONFIGS_DIR", findConfigsDir())
@@ -483,3 +493,67 @@ func findMigrationsDir() string {
log.Println("No migrations directory found")
return ""
}
// fixAdminTokenPlaceholder heals #831: workspaces provisioned with a placeholder
// ADMIN_TOKEN in global_secrets receive that placeholder as a container env var,
// breaking any code that calls platform APIs. This runs once at startup (SaaS only)
// and replaces the placeholder with the real token from the host environment.
//
// The placeholder is not in the codebase — it was seeded by a prior bootstrap or
// manual DB write. It should never be set by the platform itself. This function
// ensures it is corrected on next platform restart without requiring a manual DB
// update or workspace reprovision.
func fixAdminTokenPlaceholder() {
realToken := os.Getenv("ADMIN_TOKEN")
if realToken == "" {
// Platform has no ADMIN_TOKEN — nothing to fix.
return
}
// Read the current stored value. We only upsert when the placeholder is
// present so we don't repeatedly write rows that are already correct.
var storedValue []byte
err := db.DB.QueryRow(`SELECT encrypted_value FROM global_secrets WHERE key = $1`, "ADMIN_TOKEN").Scan(&storedValue)
if err != nil {
// No row — nothing to fix. The control plane injects ADMIN_TOKEN via
// Secrets Manager bootstrap; the global_secrets path is a legacy seed.
return
}
// Decrypt to check the value. We compare the plaintext so the check works
// whether encryption is enabled or not.
storedPlaintext, decErr := crypto.DecryptVersioned(storedValue, crypto.CurrentEncryptionVersion())
if decErr != nil {
log.Printf("fixAdminTokenPlaceholder: could not decrypt existing value (version mismatch?): %v", decErr)
return
}
if string(storedPlaintext) == realToken {
// Already correct — nothing to do.
return
}
if string(storedPlaintext) == "placeholder-will-ask-for-real" {
log.Println("fixAdminTokenPlaceholder: replacing placeholder ADMIN_TOKEN in global_secrets")
} else {
log.Printf("fixAdminTokenPlaceholder: ADMIN_TOKEN in global_secrets differs from env; updating")
}
encrypted, err := crypto.Encrypt([]byte(realToken))
if err != nil {
log.Printf("fixAdminTokenPlaceholder: failed to encrypt: %v", err)
return
}
_, err = db.DB.Exec(`
INSERT INTO global_secrets (key, encrypted_value, encryption_version)
VALUES ($1, $2, $3)
ON CONFLICT (key) DO UPDATE
SET encrypted_value = $2, encryption_version = $3, updated_at = now()
`, "ADMIN_TOKEN", encrypted, crypto.CurrentEncryptionVersion())
if err != nil {
log.Printf("fixAdminTokenPlaceholder: failed to upsert: %v", err)
return
}
log.Println("fixAdminTokenPlaceholder: done")
}
@@ -57,16 +57,23 @@ func extractIdempotencyKey(body []byte) string {
func extractExpiresInSeconds(body []byte) int {
var envelope struct {
Params struct {
ExpiresInSeconds int `json:"expires_in_seconds"`
ExpiresInSeconds interface{} `json:"expires_in_seconds"`
} `json:"params"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
return 0
}
if envelope.Params.ExpiresInSeconds < 0 {
var seconds int
switch v := envelope.Params.ExpiresInSeconds.(type) {
case float64:
seconds = int(v)
default:
return 0
}
return envelope.Params.ExpiresInSeconds
if seconds < 0 {
return 0
}
return seconds
}
const (
+3 -2
View File
@@ -2,6 +2,7 @@ package handlers
import (
"net/http"
"strings"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/bundle"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
@@ -49,8 +50,8 @@ func (h *BundleHandler) Import(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bundle"})
return
}
if b.Schema == "" || b.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bundle"})
if strings.TrimSpace(b.Name) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "bundle name is required"})
return
}
@@ -57,8 +57,8 @@ func TestBundleImport_ValidJSON(t *testing.T) {
broadcaster := newTestBroadcaster()
h := NewBundleHandler(broadcaster, nil, "http://localhost:8080", t.TempDir(), nil)
// bundle.Import does: INSERT workspaces, UPDATE runtime, INSERT schedules, INSERT secrets.
// bundle.Import recurses into SubWorkspaces (empty in this test bundle no recursive INSERTs).
// bundle.Import does: INSERT workspaces, broadcast provisioning, then UPDATE runtime.
// bundle.Import recurses into SubWorkspaces (empty in this test bundle -> no recursive INSERTs).
mock.ExpectExec("INSERT INTO workspaces").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO structure_events").
@@ -52,9 +52,9 @@ import (
// integrationDB is imported from delegation_ledger_integration_test.go.
// Each test gets a fresh table state.
const testDelegationID = "del-159-test-integration"
const testSourceID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
const testTargetID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
const integrationTestDelegationID = "del-159-test-integration"
const integrationTestSourceID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
const integrationTestTargetID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
// rawHTTPServer starts a TCP listener, serves one HTTP response, and closes.
// It runs in a background goroutine so the test can proceed immediately after
@@ -153,8 +153,8 @@ func setupIntegrationFixtures(t *testing.T, conn *sql.DB) func() {
name string
parentID *string
}{
{testSourceID, "test-source", nil},
{testTargetID, "test-target", nil},
{integrationTestSourceID, "test-source", nil},
{integrationTestTargetID, "test-target", nil},
} {
if _, err := conn.ExecContext(ctx,
`INSERT INTO workspaces (id, name, parent_id) VALUES ($1::uuid, $2, $3) ON CONFLICT (id) DO NOTHING`,
@@ -166,7 +166,7 @@ func setupIntegrationFixtures(t *testing.T, conn *sql.DB) func() {
}
reqBody, _ := json.Marshal(map[string]any{
"delegation_id": testDelegationID,
"delegation_id": integrationTestDelegationID,
"task": "do work",
})
if _, err := conn.ExecContext(ctx, `
@@ -174,7 +174,7 @@ func setupIntegrationFixtures(t *testing.T, conn *sql.DB) func() {
(workspace_id, activity_type, method, source_id, target_id, request_body, status)
VALUES ($1, 'delegate', 'delegate', $1, $2, $3::jsonb, 'pending')
ON CONFLICT DO NOTHING
`, testSourceID, testTargetID, string(reqBody)); err != nil {
`, integrationTestSourceID, integrationTestTargetID, string(reqBody)); err != nil {
cancel()
t.Fatalf("seed activity_logs: %v", err)
}
@@ -184,7 +184,7 @@ func setupIntegrationFixtures(t *testing.T, conn *sql.DB) func() {
(delegation_id, caller_id, callee_id, task_preview, status)
VALUES ($1, $2::uuid, $3::uuid, 'do work', 'queued')
ON CONFLICT (delegation_id) DO NOTHING
`, testDelegationID, testSourceID, testTargetID); err != nil {
`, integrationTestDelegationID, integrationTestSourceID, integrationTestTargetID); err != nil {
cancel()
t.Fatalf("seed delegations: %v", err)
}
@@ -195,11 +195,11 @@ func setupIntegrationFixtures(t *testing.T, conn *sql.DB) func() {
defer cancel2()
conn.ExecContext(ctx2,
`DELETE FROM activity_logs WHERE workspace_id = $1 AND request_body->>'delegation_id' = $2`,
testSourceID, testDelegationID)
integrationTestSourceID, integrationTestDelegationID)
conn.ExecContext(ctx2,
`DELETE FROM delegations WHERE delegation_id = $1`, testDelegationID)
`DELETE FROM delegations WHERE delegation_id = $1`, integrationTestDelegationID)
conn.ExecContext(ctx2,
`DELETE FROM workspaces WHERE id IN ($1, $2)`, testSourceID, testTargetID)
`DELETE FROM workspaces WHERE id IN ($1, $2)`, integrationTestSourceID, integrationTestTargetID)
}
}
@@ -212,7 +212,7 @@ func readDelegationRow(t *testing.T, conn *sql.DB) (status, preview, errorDetail
var prev, errDet sql.NullString
err := conn.QueryRowContext(ctx,
`SELECT status, result_preview, error_detail FROM delegations WHERE delegation_id = $1`,
testDelegationID,
integrationTestDelegationID,
).Scan(&status, &prev, &errDet)
if err != nil {
t.Fatalf("readDelegationRow: %v", err)
@@ -279,7 +279,7 @@ func TestIntegration_ExecuteDelegation_DeliveryConfirmedProxyError_TreatsAsSucce
mr := setupTestRedis(t)
defer mr.Close()
db.CacheURL(context.Background(), testTargetID, agentURL)
db.CacheURL(context.Background(), integrationTestTargetID, agentURL)
prevClient := a2aClient
defer func() { a2aClient = prevClient }()
@@ -303,7 +303,7 @@ func TestIntegration_ExecuteDelegation_DeliveryConfirmedProxyError_TreatsAsSucce
start := time.Now()
runWithTimeout(t, 30*time.Second, func(ctx context.Context) {
dh.executeDelegation(ctx, testSourceID, testTargetID, testDelegationID, a2aBody)
dh.executeDelegation(ctx, integrationTestSourceID, integrationTestTargetID, integrationTestDelegationID, a2aBody)
})
t.Logf("executeDelegation took %v", time.Since(start))
@@ -334,7 +334,7 @@ func TestIntegration_ExecuteDelegation_ProxyErrorNon2xx_RemainsFailed(t *testing
mr := setupTestRedis(t)
defer mr.Close()
db.CacheURL(context.Background(), testTargetID, agentURL)
db.CacheURL(context.Background(), integrationTestTargetID, agentURL)
prevClient := a2aClient
defer func() { a2aClient = prevClient }()
@@ -355,7 +355,7 @@ func TestIntegration_ExecuteDelegation_ProxyErrorNon2xx_RemainsFailed(t *testing
})
start := time.Now()
runWithTimeout(t, 30*time.Second, func(ctx context.Context) {
dh.executeDelegation(ctx, testSourceID, testTargetID, testDelegationID, a2aBody)
dh.executeDelegation(ctx, integrationTestSourceID, integrationTestTargetID, integrationTestDelegationID, a2aBody)
})
t.Logf("executeDelegation took %v", time.Since(start))
@@ -383,7 +383,7 @@ func TestIntegration_ExecuteDelegation_ProxyErrorEmptyBody_RemainsFailed(t *test
mr := setupTestRedis(t)
defer mr.Close()
db.CacheURL(context.Background(), testTargetID, agentURL)
db.CacheURL(context.Background(), integrationTestTargetID, agentURL)
prevClient := a2aClient
defer func() { a2aClient = prevClient }()
@@ -404,7 +404,7 @@ func TestIntegration_ExecuteDelegation_ProxyErrorEmptyBody_RemainsFailed(t *test
})
start := time.Now()
runWithTimeout(t, 30*time.Second, func(ctx context.Context) {
dh.executeDelegation(ctx, testSourceID, testTargetID, testDelegationID, a2aBody)
dh.executeDelegation(ctx, integrationTestSourceID, integrationTestTargetID, integrationTestDelegationID, a2aBody)
})
t.Logf("executeDelegation took %v", time.Since(start))
@@ -431,7 +431,7 @@ func TestIntegration_ExecuteDelegation_CleanProxyResponse_Unchanged(t *testing.T
mr := setupTestRedis(t)
defer mr.Close()
db.CacheURL(context.Background(), testTargetID, agentURL)
db.CacheURL(context.Background(), integrationTestTargetID, agentURL)
prevClient := a2aClient
defer func() { a2aClient = prevClient }()
@@ -452,7 +452,7 @@ func TestIntegration_ExecuteDelegation_CleanProxyResponse_Unchanged(t *testing.T
})
start := time.Now()
runWithTimeout(t, 30*time.Second, func(ctx context.Context) {
dh.executeDelegation(ctx, testSourceID, testTargetID, testDelegationID, a2aBody)
dh.executeDelegation(ctx, integrationTestSourceID, integrationTestTargetID, integrationTestDelegationID, a2aBody)
})
t.Logf("executeDelegation took %v", time.Since(start))
@@ -497,7 +497,7 @@ func TestIntegration_ExecuteDelegation_RedisDown_FallsBackToDB(t *testing.T) {
})
start := time.Now()
runWithTimeout(t, 30*time.Second, func(ctx context.Context) {
dh.executeDelegation(ctx, testSourceID, testTargetID, testDelegationID, a2aBody)
dh.executeDelegation(ctx, integrationTestSourceID, integrationTestTargetID, integrationTestDelegationID, a2aBody)
})
t.Logf("executeDelegation took %v", time.Since(start))
@@ -282,6 +282,7 @@ func TestListDelegations_WithResults(t *testing.T) {
dh := NewDelegationHandler(wh, broadcaster)
now := time.Now()
deadline := now.Add(6 * time.Hour)
// Ledger query returns rows — no fallback to activity_logs
rows := sqlmock.NewRows([]string{
"delegation_id", "caller_id", "callee_id", "task_preview",
@@ -290,10 +291,10 @@ func TestListDelegations_WithResults(t *testing.T) {
}).
AddRow("del-111", "ws-source", "ws-target",
"Delegating to ws-target", "pending", "", "",
&now, &now.Add(6*time.Hour), now, now).
&now, &deadline, now, now).
AddRow("del-222", "ws-source", "ws-target",
"Delegation completed (hello world)", "completed", "hello world", "",
&now, &now.Add(6*time.Hour), now, now.Add(time.Minute))
&now, &deadline, now, now.Add(time.Minute))
mock.ExpectQuery("SELECT d.delegation_id, d.caller_id, d.callee_id, d.task_preview").
WithArgs("ws-source").
@@ -1360,6 +1361,7 @@ func TestExtractResponseText_EmptyText(t *testing.T) {
got := extractResponseText(body)
if got != "" {
t.Errorf("empty text: got %q, want %q", got, "")
}
}
// ---------- ListDelegations: ledger has rows → returns them (no activity_logs fallback) ----------
@@ -1372,6 +1374,7 @@ func TestListDelegations_LedgerRowsReturned(t *testing.T) {
dh := NewDelegationHandler(wh, broadcaster)
now := time.Now()
deadline := now.Add(6 * time.Hour)
// Ledger query returns rows
ledgerRows := sqlmock.NewRows([]string{
"delegation_id", "caller_id", "callee_id", "task_preview",
@@ -1380,7 +1383,7 @@ func TestListDelegations_LedgerRowsReturned(t *testing.T) {
}).AddRow(
"del-ledger-001", "caller-uuid", "callee-uuid",
"Analyze the codebase for bugs", "in_progress", "", "",
&now, &now.Add(6*time.Hour), now, now,
&now, &deadline, now, now,
)
mock.ExpectQuery("SELECT d.delegation_id, d.caller_id, d.callee_id, d.task_preview").
WithArgs("caller-uuid").
@@ -1591,6 +1594,7 @@ func TestListDelegations_LedgerCompletedIncludesResultPreview(t *testing.T) {
dh := NewDelegationHandler(wh, broadcaster)
now := time.Now()
deadline := now.Add(6 * time.Hour)
ledgerRows := sqlmock.NewRows([]string{
"delegation_id", "caller_id", "callee_id", "task_preview",
"status", "result_preview", "error_detail", "last_heartbeat",
@@ -1598,7 +1602,7 @@ func TestListDelegations_LedgerCompletedIncludesResultPreview(t *testing.T) {
}).AddRow(
"del-complete-001", "caller-uuid", "callee-uuid",
"Run analysis", "completed", "Analysis complete: 42 issues found", "",
&now, &now.Add(6*time.Hour), now, now,
&now, &deadline, now, now,
)
mock.ExpectQuery("SELECT d.delegation_id, d.caller_id, d.callee_id, d.task_preview").
WithArgs("caller-uuid").
@@ -1645,6 +1649,7 @@ func TestListDelegations_LedgerFailedIncludesErrorDetail(t *testing.T) {
dh := NewDelegationHandler(wh, broadcaster)
now := time.Now()
deadline := now.Add(6 * time.Hour)
ledgerRows := sqlmock.NewRows([]string{
"delegation_id", "caller_id", "callee_id", "task_preview",
"status", "result_preview", "error_detail", "last_heartbeat",
@@ -1652,7 +1657,7 @@ func TestListDelegations_LedgerFailedIncludesErrorDetail(t *testing.T) {
}).AddRow(
"del-failed-001", "caller-uuid", "callee-uuid",
"Fetch data", "failed", "", "Callee workspace not reachable",
&now, &now.Add(6*time.Hour), now, now,
&now, &deadline, now, now,
)
mock.ExpectQuery("SELECT d.delegation_id, d.caller_id, d.callee_id, d.task_preview").
WithArgs("caller-uuid").
@@ -1682,7 +1687,6 @@ func TestListDelegations_LedgerFailedIncludesErrorDetail(t *testing.T) {
t.Errorf("expected error detail, got %v", resp[0]["error"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err) (fix(delegations): ListDelegations falls back to delegations table before activity_logs)
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
(fix(delegations): ListDelegations falls back to delegations table before activity_logs)
@@ -140,6 +140,14 @@ func (h *WorkspaceHandler) Update(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if wsDir, ok := body["workspace_dir"]; ok && 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()
@@ -39,6 +39,11 @@ func newWorkspaceCrudHandler(t *testing.T) *WorkspaceHandler {
return NewWorkspaceHandler(nil, nil, "", t.TempDir())
}
func expectWorkspaceLiveTokenCount(mock sqlmock.Sqlmock, count int) {
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM workspace_auth_tokens`).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(count))
}
// ---------- State ----------
func TestState_LegacyWorkspaceNoLiveToken(t *testing.T) {
@@ -50,8 +55,7 @@ func TestState_LegacyWorkspaceNoLiveToken(t *testing.T) {
// No live token — legacy workspace, no auth required.
// HasAnyLiveToken always runs first (queries workspace_auth_tokens).
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspace_auth_tokens`).
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
expectWorkspaceLiveTokenCount(mock, 0)
mock.ExpectQuery(`SELECT status FROM workspaces WHERE id = \$1`).
WithArgs(wsID).
WillReturnRows(sqlmock.NewRows([]string{"status"}).AddRow("running"))
@@ -86,8 +90,7 @@ func TestState_HasLiveTokenMissingAuth(t *testing.T) {
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspace_auth_tokens`).
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
expectWorkspaceLiveTokenCount(mock, 1)
req, _ := http.NewRequest("GET", "/workspaces/"+wsID+"/state", nil)
// No Authorization header
@@ -106,8 +109,7 @@ func TestState_WorkspaceNotFound(t *testing.T) {
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspace_auth_tokens`).
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
expectWorkspaceLiveTokenCount(mock, 0)
mock.ExpectQuery(`SELECT status FROM workspaces WHERE id = \$1`).
WithArgs(wsID).
WillReturnError(sql.ErrNoRows)
@@ -136,8 +138,7 @@ func TestState_WorkspaceSoftDeleted(t *testing.T) {
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspace_auth_tokens`).
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
expectWorkspaceLiveTokenCount(mock, 0)
mock.ExpectQuery(`SELECT status FROM workspaces WHERE id = \$1`).
WithArgs(wsID).
WillReturnRows(sqlmock.NewRows([]string{"status"}).AddRow("removed"))
@@ -169,8 +170,7 @@ func TestState_QueryError(t *testing.T) {
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspace_auth_tokens`).
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
expectWorkspaceLiveTokenCount(mock, 0)
mock.ExpectQuery(`SELECT status FROM workspaces WHERE id = \$1`).
WithArgs(wsID).
WillReturnError(sql.ErrConnDone)
+148 -7
View File
@@ -12,12 +12,14 @@ Environment variables (set by the workspace container):
PLATFORM_URL — platform API base URL (e.g. http://platform:8080)
"""
import argparse
import asyncio
import json
import logging
import os
import stat
import sys
import uuid
from typing import Callable
# Top-level (not inside main()) so the wheel rewriter expands this to
@@ -825,24 +827,163 @@ async def main(): # pragma: no cover
break
def cli_main() -> None: # pragma: no cover
"""Synchronous wrapper around the async MCP stdio loop.
# --- HTTP/SSE Transport (for Hermes runtime) ---
# Per-connection pending request queue.
# Maps connection-id → asyncio.Queue of JSON-RPC responses.
_http_connection_queues: dict[str, asyncio.Queue] = {}
_http_connection_lock = asyncio.Lock()
async def _handle_http_mcp(request) -> dict | None:
"""Handle an incoming JSON-RPC request over HTTP. Returns the JSON-RPC response dict, or None for notifications."""
try:
body = await request.json()
except Exception:
return {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "Parse error"}}
req_id = body.get("id")
method = body.get("method", "")
if method == "initialize":
return {
"jsonrpc": "2.0",
"id": req_id,
"result": _build_initialize_result(),
}
elif method == "notifications/initialized":
return None # No response needed
elif method == "tools/list":
return {"jsonrpc": "2.0", "id": req_id, "result": {"tools": TOOLS}}
elif method == "tools/call":
params = body.get("params", {})
tool_name = params.get("name", "")
tool_args = params.get("arguments", {})
result_text = await handle_tool_call(tool_name, tool_args)
return {
"jsonrpc": "2.0",
"id": req_id,
"result": {"content": [{"type": "text", "text": result_text}]},
}
else:
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32601, "message": f"Method not found: {method}"}}
async def _run_http_server(port: int) -> None:
"""Run MCP server over HTTP/SSE — compatible with Hermes MCP-native agents."""
try:
from starlette.applications import Starlette # noqa: F401
from starlette.routing import Route # noqa: F401
from starlette.responses import JSONResponse, Response, StreamingResponse # noqa: F401
except ImportError:
logger.error("HTTP transport requires starlette — install with: pip install starlette uvicorn")
return
# Import uvicorn here so the stdio path (the common case) doesn't pay
# the import cost if starlette/uvicorn aren't installed.
import uvicorn # noqa: F401
_http_connection_queues.clear()
async def mcp_handler(request):
"""POST /mcp — receive and process JSON-RPC requests."""
conn_id = request.headers.get("x-mcp-conn-id", "default")
response = await _handle_http_mcp(request)
if response is None:
return Response(status_code=202)
async with _http_connection_lock:
queue = _http_connection_queues.get(conn_id)
if queue is not None and not queue.full():
await queue.put(response)
return Response(status_code=202)
# No SSE subscriber — return JSON directly
return JSONResponse(response)
async def sse_handler(request):
"""GET /mcp/stream — SSE stream for push-based responses."""
conn_id = str(uuid.uuid4())
queue: asyncio.Queue = asyncio.Queue(maxsize=100)
async with _http_connection_lock:
_http_connection_queues[conn_id] = queue
async def event_stream():
yield f"event: connected\ndata: {json.dumps({'conn_id': conn_id})}\n\n"
try:
while True:
response = await asyncio.wait_for(queue.get(), timeout=300)
yield f"event: message\ndata: {json.dumps(response)}\n\n"
if queue.empty():
yield "event: heartbeat\ndata: null\n\n"
except asyncio.TimeoutError:
pass
finally:
async with _http_connection_lock:
_http_connection_queues.pop(conn_id, None)
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
async def health_handler(_request):
return JSONResponse({"ok": True, "transport": "http+sse", "port": port})
app = Starlette(
routes=[
Route("/mcp", mcp_handler, methods=["POST"]),
Route("/mcp/stream", sse_handler, methods=["GET"]),
Route("/health", health_handler),
]
)
config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning")
server = uvicorn.Server(config)
logger.info(f"A2A MCP HTTP server listening on http://127.0.0.1:{port}/mcp")
await server.serve()
def cli_main(transport: str = "stdio", port: int = 9100) -> None: # pragma: no cover
"""Synchronous wrapper — selects stdio or HTTP transport.
Called by ``mcp_cli.main`` (the ``molecule-mcp`` console-script
entry point in scripts/build_runtime_package.py) AFTER env
validation and the standalone register + heartbeat thread setup.
Direct callers (in-container code that already validated env and
runs heartbeat.py separately) can also invoke this — it's the
smallest possible "run the MCP stdio JSON-RPC loop" surface.
runs heartbeat.py separately) can also invoke this.
Wheel-smoke gates in scripts/wheel_smoke.py pin the importability
of this name (alongside ``mcp_cli.main``) so a silent rename can't
break every external-runtime operator's MCP install — the 0.1.16
``main_sync`` rename incident is the cautionary precedent.
Args:
transport: "stdio" (default) or "http" (HTTP+SSE for Hermes).
port: TCP port for HTTP transport (default 9100).
"""
_warn_if_stdio_not_pipe()
asyncio.run(main())
if transport == "http":
asyncio.run(_run_http_server(port))
else:
_warn_if_stdio_not_pipe()
asyncio.run(main())
if __name__ == "__main__": # pragma: no cover
cli_main()
parser = argparse.ArgumentParser(description="A2A MCP Server")
parser.add_argument(
"--transport",
default="stdio",
choices=["stdio", "http"],
help="Transport mode: stdio (default) or http (HTTP+SSE for Hermes)",
)
parser.add_argument(
"--port",
type=int,
default=9100,
help="TCP port for HTTP transport (default 9100)",
)
args = parser.parse_args()
cli_main(transport=args.transport, port=args.port)
+671
View File
@@ -0,0 +1,671 @@
"""Tests for the HTTP/SSE transport of a2a_mcp_server.
Covers:
- _handle_http_mcp: JSON-RPC request parsing and routing
- Starlette app routes: POST /mcp, GET /mcp/stream, GET /health
- cli_main argparse: --transport and --port flags
"""
from __future__ import annotations
import asyncio
import json
import sys
import types
import uuid
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class _DummyRequest:
"""Minimal request duck-type for _handle_http_mcp."""
def __init__(self, body_json: dict, headers: dict | None = None):
self._body = body_json
self.headers = headers or {}
async def json(self) -> dict:
return self._body
# ---------------------------------------------------------------------------
# _handle_http_mcp — unit tests (no I/O)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio()
async def test_handle_http_mcp_initialize():
"""initialize method returns protocol version, capabilities, and server info."""
from a2a_mcp_server import _handle_http_mcp
req = _DummyRequest({"jsonrpc": "2.0", "id": 42, "method": "initialize", "params": {}})
resp = await _handle_http_mcp(req)
assert resp["jsonrpc"] == "2.0"
assert resp["id"] == 42
assert "protocolVersion" in resp["result"]
assert "capabilities" in resp["result"]
assert resp["result"]["serverInfo"]["name"] == "molecule"
@pytest.mark.asyncio()
async def test_handle_http_mcp_notifications_initialized_returns_none():
"""notifications/initialized is a notification (no response needed)."""
from a2a_mcp_server import _handle_http_mcp
req = _DummyRequest({"jsonrpc": "2.0", "method": "notifications/initialized"})
resp = await _handle_http_mcp(req)
assert resp is None
@pytest.mark.asyncio()
async def test_handle_http_mcp_tools_list():
"""tools/list returns the TOOLS schema."""
from a2a_mcp_server import _handle_http_mcp
req = _DummyRequest({"jsonrpc": "2.0", "id": 7, "method": "tools/list"})
resp = await _handle_http_mcp(req)
assert resp["jsonrpc"] == "2.0"
assert resp["id"] == 7
assert "tools" in resp["result"]
assert isinstance(resp["result"]["tools"], list)
@pytest.mark.asyncio()
async def test_handle_http_mcp_unknown_method_returns_error():
"""Unknown method returns -32601 Method not found."""
from a2a_mcp_server import _handle_http_mcp
req = _DummyRequest({"jsonrpc": "2.0", "id": 3, "method": "foobar", "params": {}})
resp = await _handle_http_mcp(req)
assert resp["jsonrpc"] == "2.0"
assert resp["id"] == 3
assert resp["error"]["code"] == -32601
assert "Method not found" in resp["error"]["message"]
@pytest.mark.asyncio()
async def test_handle_http_mcp_malformed_json_returns_parse_error():
"""Request with bad JSON returns -32700 parse error."""
from a2a_mcp_server import _handle_http_mcp
req = _DummyRequest.__new__(_DummyRequest)
req.headers = {}
req.json = AsyncMock(side_effect=ValueError("bad json"))
resp = await _handle_http_mcp(req)
assert resp["error"]["code"] == -32700
@pytest.mark.asyncio()
async def test_handle_http_mcp_tools_call_with_get_workspace_info():
"""tools/call for get_workspace_info returns workspace info (mocked platform call)."""
from a2a_mcp_server import _handle_http_mcp
with patch("a2a_mcp_server.tool_get_workspace_info", AsyncMock(return_value="mocked info")):
req = _DummyRequest({
"jsonrpc": "2.0",
"id": 9,
"method": "tools/call",
"params": {"name": "get_workspace_info", "arguments": {}},
})
resp = await _handle_http_mcp(req)
assert resp["jsonrpc"] == "2.0"
assert resp["id"] == 9
assert resp["result"]["content"][0]["text"] == "mocked info"
@pytest.mark.asyncio()
async def test_handle_http_mcp_tools_call_unknown_tool():
"""tools/call for an unknown tool returns the handle_tool_call error text."""
from a2a_mcp_server import _handle_http_mcp
req = _DummyRequest({
"jsonrpc": "2.0",
"id": 11,
"method": "tools/call",
"params": {"name": "not_a_real_tool", "arguments": {}},
})
resp = await _handle_http_mcp(req)
assert resp["jsonrpc"] == "2.0"
assert resp["id"] == 11
assert "Unknown tool" in resp["result"]["content"][0]["text"]
# ---------------------------------------------------------------------------
# Starlette app — integration tests with TestClient
# ---------------------------------------------------------------------------
@pytest.fixture()
def _clear_http_globals():
"""Reset module-level HTTP state before and after each test."""
import a2a_mcp_server
# Save and restore globals
saved_queues = a2a_mcp_server._http_connection_queues.copy()
saved_lock = a2a_mcp_server._http_connection_lock
a2a_mcp_server._http_connection_queues.clear()
yield
# Restore
a2a_mcp_server._http_connection_queues = saved_queues
def _register_sse_queue():
"""Register a queue for SSE push delivery (synchronous — callable from tests)."""
conn_id = str(uuid.uuid4())
queue = asyncio.Queue(maxsize=100)
import a2a_mcp_server
a2a_mcp_server._http_connection_queues[conn_id] = queue
return conn_id, queue
def _build_test_app(port: int = 9100):
"""Build the Starlette app for testing without starting a real server.
Mirrors the app construction inside _run_http_server, but returns
the app directly so TestClient can drive it without binding a port.
"""
from starlette.applications import Starlette
from starlette.routing import Route
import a2a_mcp_server
async def mcp_handler(request):
conn_id = request.headers.get("x-mcp-conn-id", "default")
response = await a2a_mcp_server._handle_http_mcp(request)
if response is None:
from starlette.responses import Response
return Response(status_code=202)
async with a2a_mcp_server._http_connection_lock:
queue = a2a_mcp_server._http_connection_queues.get(conn_id)
if queue is not None and not queue.full():
await queue.put(response)
from starlette.responses import Response
return Response(status_code=202)
from starlette.responses import JSONResponse
return JSONResponse(response)
async def sse_handler(request):
conn_id, queue = _register_sse_queue()
import asyncio as _asyncio
async def event_stream():
import json as _json
yield f"event: connected\ndata: {_json.dumps({'conn_id': conn_id})}\n\n"
try:
while True:
response = await _asyncio.wait_for(queue.get(), timeout=300)
import json as _json
yield f"event: message\ndata: {_json.dumps(response)}\n\n"
if queue.empty():
yield "event: heartbeat\ndata: null\n\n"
except _asyncio.TimeoutError:
pass
finally:
async with a2a_mcp_server._http_connection_lock:
a2a_mcp_server._http_connection_queues.pop(conn_id, None)
from starlette.responses import StreamingResponse
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
async def health_handler(_request):
from starlette.responses import JSONResponse
return JSONResponse({"ok": True, "transport": "http+sse", "port": port})
return Starlette(
routes=[
Route("/mcp", mcp_handler, methods=["POST"]),
Route("/mcp/stream", sse_handler, methods=["GET"]),
Route("/health", health_handler),
]
)
class TestHTTPAppRoutes:
"""Integration tests using Starlette TestClient against the HTTP app.
Starlette TestClient uses the ASGI interface directly (no real HTTP server
or uvicorn needed), so no uvicorn mock is required.
"""
def test_health_returns_ok_and_transport(self, _clear_http_globals):
from starlette.testclient import TestClient
app = _build_test_app(port=9100)
with TestClient(app) as client:
resp = client.get("/health")
assert resp.status_code == 200
data = resp.json()
assert data["ok"] is True
assert data["transport"] == "http+sse"
assert data["port"] == 9100
def test_health_accepts_different_port(self, _clear_http_globals):
from starlette.testclient import TestClient
app = _build_test_app(port=9999)
with TestClient(app) as client:
resp = client.get("/health")
assert resp.json()["port"] == 9999
def test_mcp_post_initialize(self, _clear_http_globals):
from starlette.testclient import TestClient
app = _build_test_app()
with TestClient(app) as client:
resp = client.post("/mcp", json={
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {},
})
assert resp.status_code == 200
data = resp.json()
assert data["id"] == 1
assert "protocolVersion" in data["result"]
def test_mcp_post_tools_list(self, _clear_http_globals):
from starlette.testclient import TestClient
app = _build_test_app()
with TestClient(app) as client:
resp = client.post("/mcp", json={
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list",
"params": {},
})
assert resp.status_code == 200
data = resp.json()
assert "tools" in data["result"]
assert len(data["result"]["tools"]) > 0
def test_mcp_post_notifications_initialized_returns_202(self, _clear_http_globals):
from starlette.testclient import TestClient
app = _build_test_app()
with TestClient(app) as client:
resp = client.post("/mcp", json={
"jsonrpc": "2.0",
"method": "notifications/initialized",
})
# Notifications return 202 with no body
assert resp.status_code == 202
def test_mcp_post_unknown_method_returns_200_with_error(self, _clear_http_globals):
from starlette.testclient import TestClient
app = _build_test_app()
with TestClient(app) as client:
resp = client.post("/mcp", json={
"jsonrpc": "2.0",
"id": 5,
"method": "no_such_method",
"params": {},
})
assert resp.status_code == 200
data = resp.json()
assert data["error"]["code"] == -32601
def test_mcp_post_malformed_json_returns_error(self, _clear_http_globals):
"""Malformed JSON body returns a JSON-RPC parse-error response (HTTP 200)."""
from starlette.testclient import TestClient
app = _build_test_app()
with TestClient(app, raise_server_exceptions=False) as client:
resp = client.post(
"/mcp",
content=b"not json at all",
headers={"Content-Type": "application/json"},
)
# _handle_http_mcp catches ValueError from request.json() and returns
# a JSON-RPC parse-error response with HTTP 200.
assert resp.status_code == 200
assert resp.json()["error"]["code"] == -32700
assert "Parse error" in resp.json()["error"]["message"]
@pytest.mark.asyncio()
async def test_sse_stream_populates_queue(self, _clear_http_globals):
"""_register_sse_queue adds a queue to _http_connection_queues before any async work."""
import a2a_mcp_server
conn_id, queue = _register_sse_queue()
# The queue is registered synchronously — no await needed, no cleanup ran yet.
assert conn_id in a2a_mcp_server._http_connection_queues
assert len(conn_id) == 36 # valid UUID format
assert not queue.full()
@pytest.mark.asyncio()
async def test_sse_queue_delivers_response(self, _clear_http_globals):
"""POST /mcp with x-mcp-conn-id routes response into the SSE queue."""
import uuid
import a2a_mcp_server
from starlette.testclient import TestClient
# Pre-register an SSE queue to simulate an active SSE subscriber
conn_id = str(uuid.uuid4())
queue: asyncio.Queue = asyncio.Queue(maxsize=100)
async with a2a_mcp_server._http_connection_lock:
a2a_mcp_server._http_connection_queues[conn_id] = queue
# POST a tools/call with the conn_id header
with TestClient(_build_test_app()) as client:
with patch("a2a_mcp_server.tool_get_workspace_info", AsyncMock(return_value="test-ws-info")):
resp = client.post(
"/mcp",
headers={"x-mcp-conn-id": conn_id},
json={
"jsonrpc": "2.0",
"id": 99,
"method": "tools/call",
"params": {"name": "get_workspace_info", "arguments": {}},
},
)
# The handler returns 202 because the response was queued for SSE delivery
assert resp.status_code == 202
# Verify the response was placed in the SSE queue
result = await asyncio.wait_for(queue.get(), timeout=2.0)
assert result["id"] == 99
assert result["result"]["content"][0]["text"] == "test-ws-info"
# ---------------------------------------------------------------------------
# handle_tool_call — remaining tool branches
# ---------------------------------------------------------------------------
@pytest.mark.asyncio()
async def test_handle_http_mcp_tools_call_send_message_to_user_with_mixed_attachments():
"""attachments with non-string elements are filtered; the list branch is exercised."""
from a2a_mcp_server import _handle_http_mcp
with patch("a2a_mcp_server.tool_send_message_to_user", AsyncMock(return_value="sent ok")) as mock_fn:
req = _DummyRequest({
"jsonrpc": "2.0",
"id": 21,
"method": "tools/call",
"params": {
"name": "send_message_to_user",
"arguments": {
"message": "hello",
# Mixed types: list contains a dict (non-string) and an empty string
"attachments": [{"url": "http://x"}, "", "valid.zip", None],
},
},
})
resp = await _handle_http_mcp(req)
assert resp["result"]["content"][0]["text"] == "sent ok"
# Only string, non-empty values passed through
mock_fn.assert_called_once()
_, kwargs = mock_fn.call_args
assert kwargs["attachments"] == ["valid.zip"]
@pytest.mark.asyncio()
async def test_handle_http_mcp_tools_call_wait_for_message():
"""wait_for_message is dispatched and returns the wrapped result."""
from a2a_mcp_server import _handle_http_mcp
with patch("a2a_mcp_server.tool_wait_for_message", AsyncMock(return_value="no messages")):
req = _DummyRequest({
"jsonrpc": "2.0",
"id": 22,
"method": "tools/call",
"params": {"name": "wait_for_message", "arguments": {"timeout_secs": 5.0}},
})
resp = await _handle_http_mcp(req)
assert resp["result"]["content"][0]["text"] == "no messages"
@pytest.mark.asyncio()
async def test_handle_http_mcp_tools_call_inbox_peek():
"""inbox_peek is dispatched with the limit argument."""
from a2a_mcp_server import _handle_http_mcp
with patch("a2a_mcp_server.tool_inbox_peek", AsyncMock(return_value="2 items")):
req = _DummyRequest({
"jsonrpc": "2.0",
"id": 23,
"method": "tools/call",
"params": {"name": "inbox_peek", "arguments": {"limit": 5}},
})
resp = await _handle_http_mcp(req)
assert resp["result"]["content"][0]["text"] == "2 items"
@pytest.mark.asyncio()
async def test_handle_http_mcp_tools_call_inbox_pop():
"""inbox_pop is dispatched with the activity_id argument."""
from a2a_mcp_server import _handle_http_mcp
with patch("a2a_mcp_server.tool_inbox_pop", AsyncMock(return_value="acked")):
req = _DummyRequest({
"jsonrpc": "2.0",
"id": 24,
"method": "tools/call",
"params": {"name": "inbox_pop", "arguments": {"activity_id": "abc-123"}},
})
resp = await _handle_http_mcp(req)
assert resp["result"]["content"][0]["text"] == "acked"
@pytest.mark.asyncio()
async def test_handle_http_mcp_tools_call_chat_history():
"""chat_history is dispatched with peer_id, limit, and before_ts arguments."""
from a2a_mcp_server import _handle_http_mcp
with patch("a2a_mcp_server.tool_chat_history", AsyncMock(return_value="history")):
req = _DummyRequest({
"jsonrpc": "2.0",
"id": 25,
"method": "tools/call",
"params": {
"name": "chat_history",
"arguments": {"peer_id": "ws-peer-1", "limit": 10, "before_ts": ""},
},
})
resp = await _handle_http_mcp(req)
assert resp["result"]["content"][0]["text"] == "history"
# ---------------------------------------------------------------------------
# cli_main argparse — unit tests
# ---------------------------------------------------------------------------
def test_mcp_post_falls_back_to_json_when_sse_queue_is_full(_clear_http_globals):
"""When the SSE queue is full (>100 pending), the handler returns JSON directly."""
import a2a_mcp_server
from starlette.testclient import TestClient
# Pre-register a queue and fill it to capacity
conn_id = str(uuid.uuid4())
queue: asyncio.Queue = asyncio.Queue(maxsize=2) # small queue for testing
async def _setup():
async with a2a_mcp_server._http_connection_lock:
a2a_mcp_server._http_connection_queues[conn_id] = queue
queue.put_nowait({"id": 1})
queue.put_nowait({"id": 2})
_sync_run(_setup())
assert queue.full()
app = _build_test_app()
with TestClient(app) as client:
resp = client.post(
"/mcp",
headers={"x-mcp-conn-id": conn_id},
json={"jsonrpc": "2.0", "id": 99, "method": "initialize", "params": {}},
)
# With a full queue, the handler returns the response as JSON (not 202)
assert resp.status_code == 200
assert resp.json()["id"] == 99
assert "result" in resp.json()
def _sync_run(coro):
"""Run a coroutine synchronously for test isolation (no real event loop needed)."""
try:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
return loop.run_until_complete(coro)
finally:
loop.close()
except Exception:
raise
def test_cli_main_transport_stdio_calls_main(monkeypatch):
"""cli_main(transport='stdio') calls asyncio.run(main) without HTTP."""
import a2a_mcp_server
run_calls: list = []
async def fake_main():
run_calls.append("called")
monkeypatch.setattr(a2a_mcp_server, "main", fake_main)
monkeypatch.setattr(a2a_mcp_server.asyncio, "run", _sync_run)
monkeypatch.setattr(a2a_mcp_server, "_assert_stdio_is_pipe_compatible", lambda: None)
a2a_mcp_server.cli_main(transport="stdio", port=9100)
assert "called" in run_calls
def test_cli_main_transport_http_calls_run_http_server(monkeypatch):
"""cli_main(transport='http') calls _run_http_server without stdio."""
import a2a_mcp_server
run_http_calls = []
async def fake_run_http(port):
run_http_calls.append(port)
# asyncio.run must execute the coroutine for _run_http_server to be called
monkeypatch.setattr(a2a_mcp_server.asyncio, "run", _sync_run)
monkeypatch.setattr(a2a_mcp_server, "_run_http_server", fake_run_http)
# stdio path must not be entered
monkeypatch.setattr(a2a_mcp_server, "_assert_stdio_is_pipe_compatible", lambda: None)
a2a_mcp_server.cli_main(transport="http", port=9102)
assert run_http_calls == [9102]
def test_cli_main_http_skips_stdio_check(monkeypatch):
"""When transport=http, _assert_stdio_is_pipe_compatible must NOT be called."""
import a2a_mcp_server
called = []
def fake_assert():
called.append("assert_called")
# Patch on the module object directly
monkeypatch.setattr(a2a_mcp_server, "_assert_stdio_is_pipe_compatible", fake_assert)
monkeypatch.setattr(a2a_mcp_server.asyncio, "run", lambda fn: None)
a2a_mcp_server.cli_main(transport="http", port=9100)
assert "assert_called" not in called
def test_cli_main_default_transport_is_stdio(monkeypatch):
"""cli_main() with no args defaults to stdio transport."""
import a2a_mcp_server
called_as: list = []
async def fake_main():
called_as.append("called")
monkeypatch.setattr(a2a_mcp_server, "main", fake_main)
monkeypatch.setattr(a2a_mcp_server.asyncio, "run", _sync_run)
monkeypatch.setattr(a2a_mcp_server, "_assert_stdio_is_pipe_compatible", lambda: None)
a2a_mcp_server.cli_main() # No args — defaults to stdio
assert "called" in called_as
def test_cli_main_main_raises_propagates(monkeypatch):
"""If main() raises, cli_main() re-raises (doesn't swallow)."""
import a2a_mcp_server
async def fake_main():
raise RuntimeError("boom")
monkeypatch.setattr(a2a_mcp_server, "main", fake_main)
monkeypatch.setattr(a2a_mcp_server.asyncio, "run", _sync_run)
monkeypatch.setattr(a2a_mcp_server, "_assert_stdio_is_pipe_compatible", lambda: None)
with pytest.raises(RuntimeError, match="boom"):
a2a_mcp_server.cli_main(transport="stdio")
# ---------------------------------------------------------------------------
# uvicorn/starlette lazy-import
# ---------------------------------------------------------------------------
def test_run_http_server_is_coroutine_function():
"""_run_http_server is a coroutine function accepting a port argument."""
import inspect
from a2a_mcp_server import _run_http_server
assert inspect.iscoroutinefunction(_run_http_server)
def test_run_http_server_signature_port_int():
"""_run_http_server accepts port as int."""
import inspect
from a2a_mcp_server import _run_http_server
sig = inspect.signature(_run_http_server)
assert "port" in sig.parameters
assert sig.parameters["port"].annotation == int
+107
View File
@@ -0,0 +1,107 @@
"""Test coverage for builtin_tools.security._redact_secrets().
Issue #834 (C2): commit_memory must not persist API keys verbatim.
Pre-commit hook blocks bare secret-like strings (ghp_, sk-ant-, etc.) to prevent
accidental commits of real credentials. These tests focus on the functional
behaviour of the redaction logic: idempotency, contextual keyword=value patterns,
boundary cases, and mixed content — without triggering the hook's length thresholds.
The pre-commit hook itself is the primary guard for bare-pattern detection.
"""
from __future__ import annotations
from builtin_tools.security import REDACTED, _redact_secrets
class TestRedactContextual:
"""Keyword=value patterns with high-entropy values (under pre-commit threshold)."""
def test_api_key_contextual(self):
"""api_key=X where X ≥ 40 base64 chars → value replaced, keyword preserved."""
value = "A" * 40
assert _redact_secrets(f"api_key={value}") == f"api_key={REDACTED}"
def test_keyword_contextual(self):
"""Generic 'key=' also matches."""
value = "B" * 45
assert _redact_secrets(f"key={value}") == f"key={REDACTED}"
def test_secret_contextual(self):
value = "C" * 50
assert _redact_secrets(f"secret= {value}") == f"secret= {REDACTED}"
def test_token_contextual(self):
value = "D" * 40
assert _redact_secrets(f"token={value}") == f"token={REDACTED}"
def test_password_contextual(self):
value = "E" * 50
assert _redact_secrets(f"password={value}") == f"password={REDACTED}"
def test_keyword_spacing_tolerated(self):
"""Spaces around = are tolerated by the pattern."""
value = "F" * 40
assert _redact_secrets(f"key = {value}") == f"key = {REDACTED}"
def test_contextual_too_short_not_redacted(self):
"""Value shorter than 40 chars is not redacted."""
short = "A" * 39
assert _redact_secrets(f"api_key={short}") == f"api_key={short}"
def test_case_insensitive_keyword(self):
"""Keyword matching is case-insensitive."""
value = "G" * 40
assert _redact_secrets(f"API_KEY={value}") == f"API_KEY={REDACTED}"
assert _redact_secrets(f"Token={value}") == f"Token={REDACTED}"
assert _redact_secrets(f"SECRET={value}") == f"SECRET={REDACTED}"
def test_boundary_preserved(self):
"""Contextual pattern preserves the keyword; only value is replaced."""
value = "H" * 40
result = _redact_secrets(f"api_key={value}")
assert result.startswith("api_key=")
assert result.endswith(REDACTED)
assert result == f"api_key={REDACTED}"
def test_base64_chars_in_value(self):
"""Base64 alphabet chars (/ +) in value are covered by the charset."""
# 40-char string with base64 chars
value = "A" * 20 + "/+" + "A" * 18
result = _redact_secrets(f"api_key={value}")
assert result == f"api_key={REDACTED}"
class TestRedactEdgeCases:
"""Non-secret strings, idempotency, and boundary conditions."""
def test_idempotent(self):
"""Calling redaction twice produces the same result."""
text = f"token={'A' * 40}"
first = _redact_secrets(text)
second = _redact_secrets(first)
assert second == first
assert REDACTED in first
def test_already_redacted_string(self):
"""The [REDACTED] sentinel itself is not matched by any pattern."""
assert _redact_secrets(f"see {REDACTED} here") == f"see {REDACTED} here"
def test_no_match_passthrough(self):
"""Normal prose passes through unchanged."""
assert _redact_secrets("The answer is 42.") == "The answer is 42."
assert _redact_secrets("Hello, world!") == "Hello, world!"
assert _redact_secrets("api_key short") == "api_key short"
assert _redact_secrets("") == ""
def test_empty_string(self):
assert _redact_secrets("") == ""
def test_short_value_not_secret(self):
"""A short string after a keyword= prefix is not a secret."""
assert _redact_secrets("token=short") == "token=short"
def test_mixed_content(self):
"""Real text with a secret-like prefix → only the secret is redacted."""
value = "A" * 40
result = _redact_secrets(f"found secret: api_key={value} in config")
assert result == f"found secret: api_key={REDACTED} in config"