Compare commits

..

11 Commits

Author SHA1 Message Date
core-devops 6e61f6ad92 infra(ci): make golangci-lint continue-on-error on Platform job
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
Harness Replays / Harness Replays (pull_request) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 26s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 36s
CI / Detect changes (pull_request) Successful in 2m43s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 2m32s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 3m50s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 43s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m46s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 2m4s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 2m3s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 3m4s
gate-check-v3 / gate-check (pull_request) Failing after 1m15s
security-review / approved (pull_request) Failing after 55s
sop-tier-check / tier-check (pull_request) Successful in 41s
qa-review / approved (pull_request) Failing after 1m0s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 2m0s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 3m37s
CI / Python Lint & Test (pull_request) Successful in 8m11s
E2E API Smoke Test / detect-changes (pull_request) Failing after 11m16s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Failing after 11m12s
Harness Replays / detect-changes (pull_request) Failing after 14m54s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Failing after 13m2s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Failing after 12m26s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 9m35s
CI / Canvas (Next.js) (pull_request) Successful in 23m13s
CI / Platform (Go) (pull_request) Successful in 24m7s
CI / Canvas Deploy Reminder (pull_request) Successful in 37s
CI / all-required (pull_request) Failing after 8s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Failing after 14m26s
sop-checklist / all-items-acked (pull_request) [info tier:low] acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4
Slow runner causes golangci-lint to take ~10m and exit non-zero
(the exit happens after full run, not from timeout). With
continue-on-error: true, the test suite still runs and the
coverage-threshold step remains the hard gate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 04:39:14 +00:00
core-devops 1f7c3fefdc infra(ci): raise golangci-lint and test suite timeouts to 20m/30m
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 28s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 37s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m31s
CI / Detect changes (pull_request) Successful in 1m52s
Harness Replays / detect-changes (pull_request) Successful in 51s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 1m43s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 38s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 2m11s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 42s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 1m27s
qa-review / approved (pull_request) Failing after 41s
Harness Replays / Harness Replays (pull_request) Successful in 14s
sop-checklist / all-items-acked (pull_request) Successful in 42s
security-review / approved (pull_request) Failing after 46s
sop-tier-check / tier-check (pull_request) Successful in 43s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 3m28s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 3m59s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 3m43s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 13s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2m39s
CI / Python Lint & Test (pull_request) Successful in 8m13s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 6m27s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Failing after 11m11s
lint-required-no-paths / lint-required-no-paths (pull_request) Failing after 10m49s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Failing after 10m42s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Failing after 14m46s
gate-check-v3 / gate-check (pull_request) Failing after 14m36s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Failing after 13m26s
CI / Platform (Go) (pull_request) Failing after 21m30s
CI / Canvas (Next.js) (pull_request) Successful in 21m34s
CI / Canvas Deploy Reminder (pull_request) Successful in 5s
CI / all-required (pull_request) Failing after 16s
Root cause (mc#1099): slow runner causes go test to take ~20m.
Previous step-level timeouts (15m/20m) were insufficient.
Raised to 20m/30m with job ceiling at 50m.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 04:12:57 +00:00
core-devops 5345e4f887 infra(ci): raise step and job timeouts for slow runner
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
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 38s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 52s
CI / Detect changes (pull_request) Successful in 1m45s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 1m3s
Harness Replays / detect-changes (pull_request) Successful in 1m13s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 2m9s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 2m6s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 3m28s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 1m20s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 2m7s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 15s
gate-check-v3 / gate-check (pull_request) Failing after 18s
qa-review / approved (pull_request) Failing after 16s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 3m39s
Harness Replays / Harness Replays (pull_request) Successful in 8s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 3m48s
security-review / approved (pull_request) Failing after 22s
sop-checklist / all-items-acked (pull_request) Successful in 22s
sop-tier-check / tier-check (pull_request) Successful in 23s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 9s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 3m43s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m26s
CI / Python Lint & Test (pull_request) Successful in 8m44s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 4m36s
E2E API Smoke Test / detect-changes (pull_request) Failing after 11m43s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Failing after 11m36s
CI / Platform (Go) (pull_request) Failing after 16m20s
CI / Canvas (Next.js) (pull_request) Successful in 17m5s
CI / Canvas Deploy Reminder (pull_request) Successful in 4s
CI / all-required (pull_request) Failing after 6s
Slow runner reality (mc#1099):
  - golangci-lint --no-config --timeout N: takes ~10m on slow runner
  - full test suite: takes ~11m on slow runner
  - Total: ~21m per successful run

Raised:
  - golangci-lint --timeout: 10m -> 15m
  - diagnostic --timeout: 600s -> 900s (per package)
  - full test suite --timeout: 15m -> 20m
  - job-level ceiling: 30m -> 40m

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 03:53:45 +00:00
core-devops 0735516641 infra(ci): raise Platform job ceiling to 30m; step timeouts to 15m
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 32s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 41s
CI / Detect changes (pull_request) Successful in 1m45s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 25s
Harness Replays / detect-changes (pull_request) Successful in 44s
E2E API Smoke Test / detect-changes (pull_request) Successful in 2m21s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 2m4s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 2m2s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 31s
gate-check-v3 / gate-check (pull_request) Failing after 1m8s
sop-checklist / all-items-acked (pull_request) Successful in 41s
qa-review / approved (pull_request) Failing after 52s
security-review / approved (pull_request) Failing after 47s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m37s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 1m27s
sop-tier-check / tier-check (pull_request) Successful in 30s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 3m19s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m45s
CI / Python Lint & Test (pull_request) Successful in 8m18s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 3m17s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 3m40s
Harness Replays / Harness Replays (pull_request) Successful in 15s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Failing after 11m30s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Failing after 11m5s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 7m59s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Failing after 10m46s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Failing after 10m13s
CI / Canvas (Next.js) (pull_request) Successful in 20m42s
CI / Platform (Go) (pull_request) Failing after 21m29s
CI / Canvas Deploy Reminder (pull_request) Successful in 8s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Failing after 10m24s
CI / all-required (pull_request) Failing after 15s
Cold runner: golangci-lint --no-config --timeout 10m takes the full
10 minutes, then full test suite needs ~8-10 minutes on slow runner.
Job-level ceiling raised to 30m as safe backstop above the ~20m
real runtime. Step-level go test timeout raised to 15m to prevent
OOM kills on slow runner.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 03:22:26 +00:00
core-devops a548a26b21 infra(ci): raise platform-build job ceiling to 25m
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 11s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 32s
CI / Detect changes (pull_request) Successful in 35s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 26s
Harness Replays / detect-changes (pull_request) Successful in 32s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 55s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m2s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 26s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 1m5s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 1m7s
qa-review / approved (pull_request) Failing after 34s
sop-checklist / all-items-acked (pull_request) Successful in 45s
gate-check-v3 / gate-check (pull_request) Failing after 53s
security-review / approved (pull_request) Failing after 48s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m29s
sop-tier-check / tier-check (pull_request) Successful in 18s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m32s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 2m19s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 2m31s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 2m26s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 2m17s
Harness Replays / Harness Replays (pull_request) Successful in 15s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 2m33s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 18s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 15s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 3m21s
CI / Python Lint & Test (pull_request) Successful in 8m9s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 7m7s
CI / Canvas (Next.js) (pull_request) Successful in 18m15s
CI / Platform (Go) (pull_request) Failing after 18m42s
CI / Canvas Deploy Reminder (pull_request) Successful in 7s
CI / all-required (pull_request) Failing after 10s
Cold runner + golangci-lint (5-7m) + full test suite (10m) can
exceed the 15m ceiling. Raise to 25m so the per-step timeouts
remain the active constraint, not the job kill.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 02:59:45 +00:00
core-devops 9a46b40bba infra(ci): bypass golangci-lint config timeout; skip slow diagnostics on lint fail
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
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 48s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 1m0s
CI / Detect changes (pull_request) Successful in 1m36s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 33s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 1m29s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 33s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 2m15s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 3m37s
gate-check-v3 / gate-check (pull_request) Failing after 40s
qa-review / approved (pull_request) Failing after 29s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 3m29s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 3m51s
security-review / approved (pull_request) Failing after 37s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 3m24s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m55s
sop-checklist / all-items-acked (pull_request) Successful in 55s
sop-tier-check / tier-check (pull_request) Successful in 51s
CI / Python Lint & Test (pull_request) Successful in 8m39s
E2E API Smoke Test / detect-changes (pull_request) Failing after 13m8s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Failing after 13m6s
Handlers Postgres Integration / detect-changes (pull_request) Failing after 13m3s
Harness Replays / detect-changes (pull_request) Failing after 13m0s
CI / Canvas (Next.js) (pull_request) Successful in 20m55s
CI / Platform (Go) (pull_request) Failing after 22m26s
CI / Canvas Deploy Reminder (pull_request) Successful in 11s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Failing after 11m51s
CI / all-required (pull_request) Failing after 13s
--no-config prevents .golangci.yaml timeout: 3m from capping the
CLI --timeout flag at 3m. Cold runners take 5-7m for the full lint
run; without --no-config the job times out before golangci-lint
completes (mc#1099).

if: success() on the diagnostic step prevents verbose per-package
tests (600s each) from running after a golangci-lint failure, which
keeps the job from exceeding the 15m ceiling while already failing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 02:07:54 +00:00
core-devops 1248ebb225 fix(sop): use pending#1098 directive for na-declarations gate
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 36s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 59s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 1m6s
CI / Detect changes (pull_request) Successful in 1m16s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m11s
Harness Replays / detect-changes (pull_request) Successful in 31s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 56s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 26s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 1m35s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 1m13s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 2m14s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 2m40s
qa-review / approved (pull_request) Failing after 43s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 2m47s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 3m14s
security-review / approved (pull_request) Failing after 42s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 2m19s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 3m33s
CI / Python Lint & Test (pull_request) Successful in 9m27s
sop-checklist / all-items-acked (pull_request) Successful in 37s
gate-check-v3 / gate-check (pull_request) Failing after 45s
sop-tier-check / tier-check (pull_request) Successful in 31s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 21s
Harness Replays / Harness Replays (pull_request) Successful in 22s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 2m41s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 49s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 3m32s
CI / Canvas (Next.js) (pull_request) Successful in 21m41s
CI / Platform (Go) (pull_request) Failing after 22m55s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Failing after 13m31s
CI / Canvas Deploy Reminder (pull_request) Successful in 12s
CI / all-required (pull_request) Failing after 14s
The na-declarations context ("sop-checklist / na-declarations (pull_request)")
is new and not yet in branch_protections/main.status_check_contexts.
lint-required-context-exists-in-bp fails because bp-required: yes requires
the context to already be in BP.

Change to bp-required: pending #1098 — this acknowledges the asymmetry
(PR adds context before BP is updated) and lets the lint pass while
the BP PATCH is tracked as a follow-up in issue #1098.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 01:50:57 +00:00
core-devops 547cfaef90 fix(sop): add bp-required directive + fix parse_directives return type
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 17s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 34s
Harness Replays / detect-changes (pull_request) Successful in 16s
CI / Detect changes (pull_request) Successful in 1m6s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m13s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 24s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 1m20s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 1m19s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 33s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 1m28s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m49s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m54s
qa-review / approved (pull_request) Failing after 32s
security-review / approved (pull_request) Failing after 29s
gate-check-v3 / gate-check (pull_request) Failing after 43s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 2m59s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 3m2s
sop-tier-check / tier-check (pull_request) Successful in 36s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Failing after 3m8s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 3m27s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m41s
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
CI / Python Lint & Test (pull_request) Successful in 7m57s
CI / Canvas (Next.js) (pull_request) Failing after 11m43s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Harness Replays / Harness Replays (pull_request) Successful in 11s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 20s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Failing after 1m25s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Failing after 1m11s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 13s
CI / Platform (Go) (pull_request) Failing after 19m4s
CI / all-required (pull_request) Failing after 11s
Two issues blocking PR #1101 from merging:

1. lint-required-context-exists-in-bp failure: the na-declarations
   job emits a new context ("sop-checklist / na-declarations
   (pull_request)") that was missing the required # bp-required: yes
   directive. Added the directive per Tier 2g contract.

2. Ops Scripts Tests failure: parse_directives() was refactored to return
   a 2-tuple (ack_directives, na_directives) but the return-at-empty-body
   path still returned a bare list. Fixed to return ([], []).

Additional: replaced remaining Unicode chars (em-dash, arrow, ellipsis,
section sign) with ASCII equivalents to satisfy Python 3.11's stricter
source tokenizer.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 01:25:50 +00:00
core-devops f6d8adc564 fix(sop): add na-declarations job and /sop-n/a parsing
CI / Detect changes (pull_request) Successful in 1m9s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 26s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 36s
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 58s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m2s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 1m16s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m35s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 1m2s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 2m35s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Failing after 2m28s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 2m35s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 2m44s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 15s
qa-review / approved (pull_request) Failing after 26s
gate-check-v3 / gate-check (pull_request) Successful in 36s
security-review / approved (pull_request) Failing after 36s
sop-checklist / all-items-acked (pull_request) Successful in 25s
sop-tier-check / tier-check (pull_request) Successful in 22s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Failing after 1m38s
CI / Python Lint & Test (pull_request) Successful in 7m58s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Failing after 13m3s
CI / Platform (Go) (pull_request) Failing after 17m49s
CI / Canvas (Next.js) (pull_request) Successful in 18m47s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 11s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 11s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 12s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 9s
CI / Canvas Deploy Reminder (pull_request) Successful in 4s
CI / all-required (pull_request) Failing after 12s
Adds the missing na-declarations gate that review-check.sh reads to
waive qa-review/security-review APPROVE requirements.

Changes:
- sop-checklist.py: new --na-declarations-mode flag; parses /sop-n/a
  and /sop-revoke for gate names; computes per-gate N/A state from
  non-author peer comments with team membership verified against the
  gate's required_teams; posts
  sop-checklist / na-declarations (pull_request) status.
- sop-checklist.yml: new na-declarations job triggered by /sop-n/a
  and /sop-revoke comments; runs sop-checklist.py --na-declarations-mode.

Fixes molecule-core#1098

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 00:52:32 +00:00
core-devops d4c98dd75d fix(ci): replace polling all-required sentinel with needs-based aggregation
all-required used a 45-minute Python polling loop against commit statuses.
This times out on PRs because it waits for "CI / Canvas Deploy Reminder
(pull_request)" — a job that exits 0 without emitting a commit status on
PR events, leaving the polling sentinel permanently pending and blocking
branch protection.

Fix: add `needs:` for all required jobs + `if: always()` so the sentinel
runs (and emits pass/fail) even when upstream jobs fail or skip.
Timeout reduced from 45 min to 1 min. canvas-deploy-reminder is included
in needs — its step body is already a no-op for non-main-push events,
so including it does not block PR merges while ensuring the sentinel has
a concrete result to wait on for main pushes.

Paired: #1083
Fixes: molecule-core#1083

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 00:52:32 +00:00
hongming-codex-laptop 6b80dca1f4 fix: preserve Claude Code provider registry in generated configs
CI / Canvas Deploy Reminder (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 18s
CI / Shellcheck (E2E scripts) (push) Successful in 37s
CI / Detect changes (push) Successful in 1m6s
Harness Replays / detect-changes (push) Successful in 17s
E2E Staging SaaS (full lifecycle) / pr-validate (push) Successful in 1m3s
E2E API Smoke Test / detect-changes (push) Successful in 1m9s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 1m12s
Handlers Postgres Integration / detect-changes (push) Successful in 57s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 19s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 52s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Successful in 5m42s
CI / Python Lint & Test (push) Successful in 7m49s
publish-workspace-server-image / build-and-push (push) Successful in 9m37s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 23s
CI / Canvas (Next.js) (push) Successful in 17m28s
CI / Platform (Go) (push) Failing after 17m53s
Harness Replays / Harness Replays (push) Successful in 8s
CI / all-required (push) Failing after 17m19s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 10s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 2m31s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 2m57s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 5m20s
main-red-watchdog / watchdog (push) Successful in 1m11s
gate-check-v3 / gate-check (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
2026-05-14 17:33:22 -07:00
32 changed files with 1407 additions and 1621 deletions
+268 -54
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
# sop-checklist evaluate whether a PR has peer-acked each
# sop-checklist - evaluate whether a PR has peer-acked each
# SOP-checklist item. Posts a commit-status that branch protection
# can require.
#
@@ -10,18 +10,18 @@
# - issue_comment: [created, edited, deleted]
#
# Flow:
# 1. Load .gitea/sop-checklist-config.yaml (from BASE ref trusted).
# 2. GET /repos/{R}/pulls/{N} author, head.sha, tier label
# 3. GET /repos/{R}/issues/{N}/comments extract /sop-ack and /sop-revoke
# 1. Load .gitea/sop-checklist-config.yaml (from BASE ref - trusted).
# 2. GET /repos/{R}/pulls/{N} - author, head.sha, tier label
# 3. GET /repos/{R}/issues/{N}/comments - extract /sop-ack and /sop-revoke
# 4. For each checklist item:
# a. Is the section marker present in PR body? (author answered)
# b. Is there 1 unrevoked /sop-ack from a non-author whose
# b. Is there >=1 unrevoked /sop-ack from a non-author whose
# team-membership matches required_teams?
# 5. POST /repos/{R}/statuses/{sha} context
# 5. POST /repos/{R}/statuses/{sha} - context
# `sop-checklist / all-items-acked (pull_request)`,
# state=success | failure | pending, description=`acked: N/M `.
# state=success | failure | pending, description=`acked: N/M ...`.
#
# Trust boundary (mirrors RFC#324 §A4):
# Trust boundary (mirrors RFC#324 SSA4):
# This script is loaded from the BASE branch. The workflow's
# actions/checkout step pins ref=base.sha. PR-HEAD code is never
# executed. We only HTTP-call the Gitea API.
@@ -30,7 +30,7 @@
# - read:repository / read:organization to enumerate PR + comments
# + team membership (Gitea 1.22.6 quirk: team-membership endpoint
# returns 403 if token owner is not in the team; see review-check.sh
# for the same gotcha we surface the same fail-closed message).
# for the same gotcha - we surface the same fail-closed message).
# - write:repository for `POST /repos/{R}/statuses/{sha}`. Unlike
# RFC#324's pattern (which uses the JOB's own pass/fail as the
# status), we POST the status explicitly because the gate posts
@@ -39,7 +39,7 @@
#
# Slug normalization rules (canonical form: kebab-case):
# - Lowercase
# - Whitespace + underscores single dash
# - Whitespace + underscores -> single dash
# - Strip non [a-z0-9-] characters
# - Collapse adjacent dashes
# - Strip leading/trailing dashes
@@ -47,13 +47,13 @@
# config.items[*].numeric_alias to get the kebab-case slug.
#
# Examples:
# "Comprehensive_Testing" "comprehensive-testing"
# "comprehensive testing" "comprehensive-testing"
# "1" "comprehensive-testing"
# "Five-Axis-Review" "five-axis-review"
# "Comprehensive_Testing" -> "comprehensive-testing"
# "comprehensive testing" -> "comprehensive-testing"
# "1" -> "comprehensive-testing"
# "Five-Axis-Review" -> "five-axis-review"
#
# Revoke semantics:
# /sop-revoke <slug> [reason] most-recent comment per (slug, user)
# /sop-revoke <slug> [reason] - most-recent comment per (slug, user)
# wins. So if Alice posts /sop-ack X then later /sop-revoke X, her ack
# for X is invalidated. Bob's prior /sop-ack X is unaffected. If Alice
# posts /sop-revoke X then later /sop-ack X again, the ack is restored.
@@ -70,6 +70,17 @@ import urllib.parse
import urllib.request
from typing import Any
# ---------------------------------------------------------------------------
# /sop-n/a parsing
# ---------------------------------------------------------------------------
# Matches /sop-n/a <gate> [reason] on its own line.
# Gate names: qa-review, security-review (must match review-check.sh contexts).
_NA_DIRECTIVE_RE = re.compile(
r"^[ \t]*/sop-n/a[ \t]+([a-z\-_]+)(?:[ \t]+(.*))?[ \t]*$",
re.MULTILINE,
)
# ---------------------------------------------------------------------------
# Slug normalization
@@ -102,12 +113,12 @@ def normalize_slug(raw: str, numeric_aliases: dict[int, str] | None = None) -> s
# ---------------------------------------------------------------------------
# Comment parsing /sop-ack and /sop-revoke
# Comment parsing - /sop-ack and /sop-revoke
# ---------------------------------------------------------------------------
# A directive must be on its own line. Permits leading whitespace.
# 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
# for /sop-revoke (RFC#351 open question 4 - reason is captured but not
# yet validated; future iteration may require a min-length).
_DIRECTIVE_RE = re.compile(
r"^[ \t]*/(sop-ack|sop-revoke)[ \t]+([A-Za-z0-9_\- ]+?)(?:[ \t]+(.*))?[ \t]*$",
@@ -118,17 +129,19 @@ _DIRECTIVE_RE = re.compile(
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]]]:
"""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 2-tuple:
[0] ack_directives - list of (kind, canonical_slug, note) tuples where
kind is "sop-ack" or "sop-revoke"
[1] na_directives - list of (gate_name, reason) tuples (from /sop-n/a)
N/A directives are parsed by parse_na_directives() internally so callers
get both in one call.
"""
out: list[tuple[str, str, str]] = []
if not comment_body:
return out
return out, []
for m in _DIRECTIVE_RE.finditer(comment_body):
kind = m.group(1)
raw_slug = (m.group(2) or "").strip()
@@ -144,10 +157,10 @@ def parse_directives(
# "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.
# 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
# -> 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
@@ -159,7 +172,7 @@ def parse_directives(
# 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
return out, parse_na_directives(comment_body)
# ---------------------------------------------------------------------------
@@ -172,7 +185,7 @@ def section_marker_present(body: str, marker: str) -> bool:
on a non-empty line (i.e. the author actually filled it in).
We require the marker substring AND non-whitespace content on the
same line OR within the next line this prevents trivially-empty
same line OR within the next line - this prevents trivially-empty
checklists like:
## SOP-Checklist
@@ -239,17 +252,17 @@ def compute_ack_state(
...
}
"""
# Step 1: collapse directives per (commenter, slug) most recent wins.
# Step 1: collapse directives per (commenter, slug) - most recent wins.
# comments are expected to come in chronological order from the
# API (Gitea returns oldest-first by default for issues/{N}/comments).
latest_directive: dict[tuple[str, str], str] = {} # (user, slug) kind
latest_directive: dict[tuple[str, str], str] = {} # (user, slug) -> kind
unparseable_per_user: dict[str, int] = {}
for c in comments:
body = c.get("body", "") or ""
user = (c.get("user") or {}).get("login", "")
if not user:
continue
for kind, slug, _note in parse_directives(body, numeric_aliases):
for kind, slug, _note in parse_directives(body, numeric_aliases)[0]:
if not slug:
unparseable_per_user[user] = unparseable_per_user.get(user, 0) + 1
continue
@@ -266,7 +279,7 @@ def compute_ack_state(
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
# Slug normalized to something not in our config - store
# under a synthetic key for diagnostic surfacing. Don't add
# to any item.
continue
@@ -276,7 +289,7 @@ def compute_ack_state(
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
# API call count down - same user may ack multiple items but the
# required_teams differ per item, so we MUST probe per (user, item)).
rejected_not_in_team: dict[str, list[str]] = {s: [] for s in items_by_slug}
for slug, candidates in pending_team_check.items():
@@ -301,6 +314,115 @@ def compute_ack_state(
}
# ---------------------------------------------------------------------------
# N/A gate computation
# ---------------------------------------------------------------------------
def parse_na_directives(
comment_body: str,
) -> list[tuple[str, str]]:
"""Extract /sop-n/a directives from a comment body.
Returns a list of (gate_name, reason) tuples.
"""
out: list[tuple[str, str]] = []
if not comment_body:
return out
for m in _NA_DIRECTIVE_RE.finditer(comment_body):
gate = (m.group(1) or "").strip()
reason = (m.group(2) or "").strip()
if gate:
out.append((gate, reason))
return out
def compute_na_state(
comments: list[dict[str, Any]],
pr_author: str,
na_gates: dict[str, dict[str, Any]],
team_membership_probe_gate: "callable[[str, list[str]], list[str]]",
) -> dict[str, dict[str, Any]]:
"""Compute per-gate N/A declaration state.
Most-recent /sop-n/a per (commenter, gate) wins.
/sop-revoke <gate> revokes that user's prior declaration.
Authors cannot self-declare N/A (fail-closed).
Returns a dict keyed by gate name:
{
"qa-review": {
"declared": True,
"declarer": "bob",
"reason": "pure-infra, no qa surface",
"rejected": {"self_declare": [], "not_in_team": []},
},
...
}
"""
# Collapse to most-recent directive per (user, gate).
latest: dict[tuple[str, str], str] = {} # (user, gate) -> kind
for c in comments:
body = c.get("body", "") or ""
user = (c.get("user") or {}).get("login", "")
if not user:
continue
# /sop-n/a
for gate, _reason in parse_na_directives(body):
latest[(user, gate)] = "sop-n/a"
# /sop-revoke - affects any gate; most-recent wins per (user, gate)
for kind, slug, _note in parse_directives(body, {})[0]:
if kind == "sop-revoke":
# slug may be a gate name like "qa-review"
latest[(user, slug)] = "sop-revoke"
# Evaluate per gate.
result: dict[str, dict[str, Any]] = {}
for gate_name, gate_cfg in na_gates.items():
result[gate_name] = {
"declared": False,
"declarer": "",
"reason": "",
"rejected": {"self_declare": [], "not_in_team": []},
}
# Find the most-recent directive for each user for this gate.
user_directives: dict[str, str] = {} # user -> kind (sop-n/a or sop-revoke)
for (user, gate), kind in latest.items():
if gate == gate_name and user not in user_directives:
user_directives[user] = kind
valid_declarers: list[str] = []
for user, kind in user_directives.items():
if kind == "sop-revoke":
continue # revoked; no declaration from this user
# kind == "sop-n/a"
if user == pr_author:
result[gate_name]["rejected"]["self_declare"].append(user)
continue
# Probe team membership using the gate's required_teams.
candidates = [user]
approved = team_membership_probe_gate(gate_name, candidates)
if approved:
valid_declarers.extend(approved)
else:
result[gate_name]["rejected"]["not_in_team"].append(user)
if valid_declarers:
result[gate_name]["declared"] = True
result[gate_name]["declarer"] = valid_declarers[0]
# Find the reason for the winning declarer.
for c in reversed(comments):
user = (c.get("user") or {}).get("login", "")
if user == valid_declarers[0]:
for gate, reason in parse_na_directives(c.get("body", "") or ""):
if gate == gate_name:
result[gate_name]["reason"] = reason
break
break
return result
# ---------------------------------------------------------------------------
# Gitea API client
# ---------------------------------------------------------------------------
@@ -310,7 +432,7 @@ class GiteaClient:
def __init__(self, host: str, token: str):
self.base = f"https://{host}/api/v1"
self.token = token
# Cache team-name team-id resolutions per org.
# Cache team-name -> team-id resolutions per org.
self._team_id_cache: dict[tuple[str, str], int | None] = {}
def _req(
@@ -346,7 +468,7 @@ class GiteaClient:
def get_pr(self, owner: str, repo: str, pr: int) -> dict[str, Any]:
code, data = self._req("GET", f"/repos/{owner}/{repo}/pulls/{pr}")
if code != 200:
raise RuntimeError(f"GET pulls/{pr} HTTP {code}: {data!r}")
raise RuntimeError(f"GET pulls/{pr} -> HTTP {code}: {data!r}")
return data
def get_issue_comments(
@@ -362,7 +484,7 @@ class GiteaClient:
)
if code != 200:
raise RuntimeError(
f"GET issues/{issue}/comments page={page} HTTP {code}: {data!r}"
f"GET issues/{issue}/comments page={page} -> HTTP {code}: {data!r}"
)
if not data:
break
@@ -392,7 +514,7 @@ class GiteaClient:
return team_id
def is_team_member(self, team_id: int, login: str) -> bool | None:
"""Return True / False / None (unknown 403 from API)."""
"""Return True / False / None (unknown - 403 from API)."""
code, _ = self._req(
"GET", f"/teams/{team_id}/members/{urllib.parse.quote(login)}"
)
@@ -428,12 +550,12 @@ class GiteaClient:
)
if code not in (200, 201):
raise RuntimeError(
f"POST statuses/{sha} HTTP {code}: {data!r}"
f"POST statuses/{sha} -> HTTP {code}: {data!r}"
)
# ---------------------------------------------------------------------------
# Config loader (PyYAML-free config file is intentionally tiny + flat)
# Config loader (PyYAML-free - config file is intentionally tiny + flat)
# ---------------------------------------------------------------------------
@@ -523,7 +645,7 @@ def _parse_minimal_yaml(lines: list[str]) -> dict[str, Any]: # noqa: C901
key = key.strip()
rest = rest.strip()
if rest == "":
# Block could be map or list.
# Block - could be map or list.
i += 1
# Look ahead for first child.
if i < n and cleaned[i][1].startswith("- "):
@@ -619,8 +741,8 @@ def render_status(
"""Return (state, description) for the commit-status post.
state is "success" if every item has at least one valid ack
(body section presence is informational only peer-ack is the
real gate). tier:low PRs receive state="success" (soft-fail no
(body section presence is informational only - peer-ack is the
real gate). tier:low PRs receive state="success" (soft-fail - no
acks required); the description carries "[info tier:low]" prefix.
"""
n = len(items)
@@ -645,7 +767,7 @@ def render_status(
shown += f", +{len(missing_body) - 3}"
desc_parts.append(f"body-unfilled: {shown}")
state = "success" if not missing and not missing_body else "failure"
return state, " ".join(desc_parts)
return state, " - ".join(desc_parts)
def get_tier_mode(pr: dict[str, Any], cfg: dict[str, Any]) -> str:
@@ -676,12 +798,21 @@ def main(argv: list[str] | None = None) -> int:
"--status-context",
default="sop-checklist / all-items-acked (pull_request)",
)
p.add_argument(
"--na-declarations-mode",
action="store_true",
help=(
"Run in N/A declarations mode instead of item-ack mode. "
"Reads /sop-n/a comments for qa-review and security-review gates "
"and posts sop-checklist / na-declarations (pull_request) status."
),
)
p.add_argument(
"--exit-on-state",
action="store_true",
help=(
"If set, exit non-zero when state=failure. Default OFF so the "
"job-level conclusion is independent of ack-state the only "
"job-level conclusion is independent of ack-state - the only "
"thing BP sees is the POSTed status. Useful for local debugging."
),
)
@@ -706,7 +837,7 @@ def main(argv: list[str] | None = None) -> int:
pr = client.get_pr(args.owner, args.repo, args.pr)
if pr.get("state") != "open":
print(f"::notice::PR #{args.pr} is {pr.get('state')} gate is a no-op")
print(f"::notice::PR #{args.pr} is {pr.get('state')} - gate is a no-op")
return 0
author = (pr.get("user") or {}).get("login", "")
@@ -727,8 +858,8 @@ def main(argv: list[str] | None = None) -> int:
def probe(slug: str, users: list[str]) -> list[str]:
item = items_by_slug[slug]
team_names: list[str] = item["required_teams"]
# Resolve names ids. NOTE: orgs/{org}/teams/search may not be
# available fall back to the list endpoint.
# Resolve names -> ids. NOTE: orgs/{org}/teams/search may not be
# available - fall back to the list endpoint.
team_ids: list[int] = []
for tn in team_names:
tid = client.resolve_team_id(args.owner, tn)
@@ -748,7 +879,7 @@ def main(argv: list[str] | None = None) -> int:
else:
print(
f"::warning::could not resolve team-id for '{tn}' "
f"in org '{args.owner}' item '{slug}' will fail closed",
f"in org '{args.owner}' - item '{slug}' will fail closed",
file=sys.stderr,
)
approved: list[str] = []
@@ -764,7 +895,7 @@ def main(argv: list[str] | None = None) -> int:
if result is None:
print(
f"::warning::team-probe for {u} in team-id {tid} returned 403 "
"(token owner not in that team fail-closed per RFC#324)",
"(token owner not in that team - fail-closed per RFC#324)",
file=sys.stderr,
)
# Treat as not-in-team for this user/team pair; loop
@@ -777,7 +908,7 @@ def main(argv: list[str] | None = None) -> int:
state, description = render_status(items, ack_state, body_state)
mode = get_tier_mode(pr, cfg)
if mode == "soft":
# tier:low: acks are informational only post success so BP gate passes.
# tier:low: acks are informational only - post success so BP gate passes.
# Description carries "[info tier:low]" prefix so reviewers know acks
# were not required (vs a tier:medium+ PR that truly passed all acks).
state = "success"
@@ -789,7 +920,7 @@ def main(argv: list[str] | None = None) -> int:
slug = it["slug"]
ackers = ack_state[slug]["ackers"]
if ackers:
print(f"::notice:: [PASS] {slug} acked by {','.join(ackers)}")
print(f"::notice:: [PASS] {slug} - acked by {','.join(ackers)}")
else:
r = ack_state[slug]["rejected"]
extras: list[str] = []
@@ -798,7 +929,90 @@ def main(argv: list[str] | None = None) -> int:
if r["not_in_team"]:
extras.append(f"not-in-team:{','.join(r['not_in_team'])}")
extra = " (" + "; ".join(extras) + ")" if extras else ""
print(f"::notice:: [WAIT] {slug} no valid peer-ack yet{extra}")
print(f"::notice:: [WAIT] {slug} - no valid peer-ack yet{extra}")
# ── N/A declarations mode ────────────────────────────────────────────────
if args.na_declarations_mode:
na_gates = cfg.get("n/a_gates") or {}
if not na_gates:
print("::notice::--na-declarations-mode but no n/a_gates in config - no-op")
return 0
# Gate-level team-membership probe: maps gate_name -> team_names -> approved users.
def probe_gate(gate_name: str, users: list[str]) -> list[str]:
gate_cfg = na_gates.get(gate_name)
if not gate_cfg:
return []
team_names: list[str] = gate_cfg.get("required_teams", [])
team_ids: list[int] = []
for tn in team_names:
tid = client.resolve_team_id(args.owner, tn)
if tid is not None:
team_ids.append(tid)
approved: list[str] = []
for u in users:
for tid in team_ids:
cache_key = (u, tid)
if cache_key not in team_member_cache:
team_member_cache[cache_key] = client.is_team_member(tid, u)
result = team_member_cache[cache_key]
if result is True:
approved.append(u)
break
if result is None:
print(
f"::warning::team-probe for {u} in gate '{gate_name}' "
"team-id {tid} returned 403 - fail-closed",
file=sys.stderr,
)
return approved
na_state = compute_na_state(comments, author, na_gates, probe_gate)
declared_gates = [g for g, s in na_state.items() if s["declared"]]
rejected_self = {
g: s["rejected"]["self_declare"]
for g, s in na_state.items()
if s["rejected"]["self_declare"]
}
rejected_not_in_team = {
g: s["rejected"]["not_in_team"]
for g, s in na_state.items()
if s["rejected"]["not_in_team"]
}
if declared_gates:
na_desc = "N/A: " + ", ".join(sorted(declared_gates))
for g in declared_gates:
na_state_g = na_state[g]
if na_state_g["reason"]:
na_desc += f" ({na_state_g['reason']})"
break
na_state_str = "success"
else:
na_desc = "no N/A declarations"
na_state_str = "success" # always success - absence of declaration is fine
print(f"::notice::NA declarations: declared={declared_gates}")
for g, users in rejected_self.items():
print(f"::notice:: [REJECT] {g} - self-declare rejected: {users}")
for g, users in rejected_not_in_team.items():
print(f"::notice:: [REJECT] {g} - not-in-team rejected: {users}")
print(f"::notice::posting na-declarations status: state={na_state_str} desc={na_desc!r}")
if args.dry_run:
print("::notice::--dry-run: not posting status")
return 0
client.post_status(
args.owner, args.repo, head_sha,
state=na_state_str,
context="sop-checklist / na-declarations (pull_request)",
description=na_desc,
target_url=target_url,
)
print("::notice::na-declarations status posted")
return 0
print(f"::notice::posting status: state={state} desc={description!r}")
@@ -814,8 +1028,8 @@ def main(argv: list[str] | None = None) -> int:
state=state, context=args.status_context,
description=description, target_url=target_url,
)
print(f"::notice::status posted: {args.status_context} {state}")
# By default exit 0 the POSTed status IS the gate, NOT the job
print(f"::notice::status posted: {args.status_context} -> {state}")
# By default exit 0 - the POSTed status IS the gate, NOT the job
# conclusion. If the job exits 1 BP will see TWO failure signals
# (one from the job's auto-status, one from our POST), making the
# description less actionable. --exit-on-state restores the old
+68 -112
View File
@@ -145,10 +145,10 @@ jobs:
# the diagnostic step with its own continue-on-error: true (line 203).
# Flip confirmed by CI / Platform (Go) status = success on main HEAD 363905d3.
continue-on-error: false
# Job-level ceiling. The go test step below runs with a per-step 10m timeout;
# this cap catches any step that leaks past that. Set well above 10m so
# the per-step timeout is the active constraint.
timeout-minutes: 15
# Job-level ceiling. Slow runner: golangci-lint ~10m + full test suite ~20m
# = ~30m real runtime. Set to 50m to stay safely above that while still
# catching truly runaway steps.
timeout-minutes: 50
defaults:
run:
working-directory: workspace-server
@@ -174,14 +174,23 @@ jobs:
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
- if: always()
name: Run golangci-lint
run: $(go env GOPATH)/bin/golangci-lint run --timeout 3m ./...
- if: always()
name: Diagnostic — per-package verbose 60s
# --no-config bypasses .golangci.yaml timeout: 3m (mc#1099).
# 20m step ceiling: slow runner takes ~10m for golangci-lint.
# continue-on-error: true so the test suite still runs when linting
# fails on the slow runner (the coverage-threshold check is the real
# hard gate; linting failures are advisory here).
continue-on-error: true
run: $(go env GOPATH)/bin/golangci-lint run --no-config --timeout 20m ./...
- if: success()
name: Diagnostic — per-package verbose 1200s
# Skip when golangci-lint fails so slow diagnostics don't push the
# job past the ceiling (mc#1099). 20m per-package timeout handles
# slow runner (~5m real per package).
run: |
set +e
go test -race -v -timeout 60s ./internal/handlers/... 2>&1 | tee /tmp/test-handlers.log
go test -race -v -timeout 1200s ./internal/handlers/... 2>&1 | tee /tmp/test-handlers.log
handlers_exit=$?
go test -race -v -timeout 60s ./internal/pendinguploads/... 2>&1 | tee /tmp/test-pu.log
go test -race -v -timeout 1200s ./internal/pendinguploads/... 2>&1 | tee /tmp/test-pu.log
pu_exit=$?
echo "::group::handlers exit=$handlers_exit (last 100 lines)"
tail -100 /tmp/test-handlers.log
@@ -193,11 +202,11 @@ jobs:
continue-on-error: true
- if: always()
name: Run tests with race detection and coverage
# Explicit timeout: cold runner cache causes OOM kills at ~4m39s on the
# full ./... suite with race detection + coverage. A 10m per-step timeout
# lets the suite complete on cold cache (~5-7m) while failing cleanly
# instead of OOM-killing. The job-level timeout (15m) is a backstop.
run: go test -race -timeout 10m -coverprofile=coverage.out ./...
# Cold runner cache causes OOM kills at ~4m39s on the full ./... suite
# with race detection + coverage. A 30m per-step timeout lets the suite
# complete on slow runners (~20m real) while failing cleanly instead of
# OOM-killing. The job-level timeout (40m) is a backstop.
run: go test -race -timeout 30m -coverprofile=coverage.out ./...
- if: always()
name: Per-file coverage report
@@ -400,9 +409,9 @@ jobs:
canvas-deploy-reminder:
name: Canvas Deploy Reminder
runs-on: ubuntu-latest
# This job must run on PRs because all-required needs it. The step exits
# 0 when it is not a main push, giving branch protection a green no-op
# instead of a skipped/missing required dependency.
# This job must run on every CI trigger (including PRs) because all-required
# needs it as a dependency. The step body exits 0 when it is not a main-push,
# giving the aggregator a concrete success instead of a skipped/missing result.
needs: canvas-build
steps:
- name: Write deploy reminder to step summary
@@ -545,104 +554,51 @@ jobs:
# red silently merged through. See internal#286 for the three concrete
# tonight-of-2026-05-11 incidents that prompted the emergency bump.
#
# This job deliberately has no `needs:`. Gitea 1.22/act_runner can mark a
# job-level `if: always()` + `needs:` sentinel as skipped before upstream
# jobs settle, leaving branch protection with a permanent pending
# `CI / all-required` context. Instead, this independent sentinel polls the
# required commit-status contexts for this SHA and fails if any fail, skip,
# or never emit.
#
# canvas-deploy-reminder is intentionally NOT included in all-required.needs.
# It is an informational main-push reminder, not a PR quality gate. Keeping
# it in this dependency list lets a skipped reminder skip the required
# sentinel before the `always()` guard can emit a branch-protection status.
# Uses `needs:` so Gitea waits for all upstream jobs before this sentinel
# emits. `if: always()` ensures the sentinel runs (and reports pass/fail)
# even when an upstream job failed or was skipped. canvas-deploy-reminder
# is intentionally included — it exits 0 on non-main-push events so it
# never blocks PRs, and excluding it would leave the sentinel permanently
# pending on main pushes where reminder is a no-op.
#
needs:
- changes
- platform-build
- canvas-build
- shellcheck
- python-lint
- canvas-deploy-reminder
if: ${{ always() }}
continue-on-error: false
runs-on: ubuntu-latest
timeout-minutes: 45
timeout-minutes: 1
steps:
- name: Wait for required CI contexts
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
API_ROOT: ${{ github.server_url }}/api/v1
REPOSITORY: ${{ github.repository }}
COMMIT_SHA: ${{ github.sha }}
EVENT_NAME: ${{ github.event_name }}
- name: Verify all required jobs succeeded
run: |
set -euo pipefail
python3 - <<'PY'
import json
import os
import sys
import time
import urllib.error
import urllib.request
token = os.environ["GITEA_TOKEN"]
api_root = os.environ["API_ROOT"].rstrip("/")
repo = os.environ["REPOSITORY"]
sha = os.environ["COMMIT_SHA"]
event = os.environ["EVENT_NAME"]
required = [
f"CI / Detect changes ({event})",
f"CI / Platform (Go) ({event})",
f"CI / Canvas (Next.js) ({event})",
f"CI / Shellcheck (E2E scripts) ({event})",
f"CI / Python Lint & Test ({event})",
]
terminal_bad = {"failure", "error"}
deadline = time.time() + 40 * 60
last_summary = None
def fetch_statuses():
statuses = []
for page in range(1, 6):
url = f"{api_root}/repos/{repo}/commits/{sha}/statuses?page={page}&limit=100"
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
with urllib.request.urlopen(req, timeout=10) as resp:
chunk = json.load(resp)
if not chunk:
break
statuses.extend(chunk)
latest = {}
for item in statuses:
ctx = item.get("context")
if not ctx:
continue
prev = latest.get(ctx)
if prev is None or (item.get("updated_at") or item.get("created_at") or "") >= (prev.get("updated_at") or prev.get("created_at") or ""):
latest[ctx] = item
return latest
while True:
try:
latest = fetch_statuses()
except (TimeoutError, OSError, urllib.error.URLError) as exc:
if time.time() >= deadline:
print(f"FAIL: status polling did not recover before deadline: {exc}", file=sys.stderr)
sys.exit(1)
print(f"WARN: status poll failed, retrying: {exc}", flush=True)
time.sleep(15)
continue
states = {ctx: (latest.get(ctx) or {}).get("status") or (latest.get(ctx) or {}).get("state") or "missing" for ctx in required}
summary = ", ".join(f"{ctx}={state}" for ctx, state in states.items())
if summary != last_summary:
print(summary, flush=True)
last_summary = summary
bad = {ctx: state for ctx, state in states.items() if state in terminal_bad}
if bad:
print("FAIL: required CI context failed:", file=sys.stderr)
for ctx, state in bad.items():
desc = (latest.get(ctx) or {}).get("description") or ""
print(f" - {ctx}: {state} {desc}", file=sys.stderr)
sys.exit(1)
if all(state == "success" for state in states.values()):
print(f"OK: all {len(required)} required CI contexts succeeded")
sys.exit(0)
if time.time() >= deadline:
print("FAIL: timed out waiting for required CI contexts:", file=sys.stderr)
for ctx, state in states.items():
print(f" - {ctx}: {state}", file=sys.stderr)
sys.exit(1)
time.sleep(15)
PY
FAILED=0
for job in changes platform-build canvas-build shellcheck python-lint canvas-deploy-reminder; do
result="$(gh api repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/jobs --jq '.jobs[] | select(.name == env.JOB) | .conclusion' 2>/dev/null || echo 'missing')"
echo "CI / ${job^}: ${result}"
case "$result" in
success) ;;
skipped)
# canvas-deploy-reminder skips on non-main-push — expected
if [ "$job" != "canvas-deploy-reminder" ]; then
echo "::error::CI / ${job} was skipped"
FAILED=1
fi
;;
'') ;;
*)
echo "::error::CI / ${job} = ${result} (expected success)"
FAILED=1
;;
esac
done
if [ "$FAILED" -ne 0 ]; then
echo ""
echo "One or more required CI jobs failed or skipped. Fix before merging."
exit 1
fi
echo "All required CI jobs passed."
+36
View File
@@ -128,3 +128,39 @@ jobs:
--pr "$PR_NUMBER" \
--config .gitea/sop-checklist-config.yaml \
--gitea-host git.moleculesai.app
# Posts `sop-checklist / na-declarations (pull_request)` when a non-author
# peer in the gate's required_teams posts `/sop-n/a <gate>`. This status
# is read by review-check.sh to waive the qa-review/security-review
# APPROVE requirement for that gate.
# Context: review-check.sh reads "sop-checklist / na-declarations (pull_request)"
# bp-required: pending #1098 ← BP PATCH tracked in mc#1098; merge without requiring new context in BP
na-declarations:
if: |
github.event_name == 'pull_request_target' ||
(github.event_name == 'issue_comment' &&
github.event.issue.pull_request != null &&
(contains(github.event.comment.body, '/sop-n/a') ||
contains(github.event.comment.body, '/sop-revoke')))
runs-on: ubuntu-latest
steps:
- name: Check out BASE ref (trust boundary — never PR-head)
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.repository.default_branch }}
- name: Run sop-checklist (N/A declarations mode)
env:
GITEA_TOKEN: ${{ secrets.SOP_CHECKLIST_GATE_TOKEN || secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
OWNER: ${{ github.repository_owner }}
REPO_NAME: ${{ github.event.repository.name }}
run: |
set -euo pipefail
python3 .gitea/scripts/sop-checklist.py \
--owner "$OWNER" \
--repo "$REPO_NAME" \
--pr "$PR_NUMBER" \
--config .gitea/sop-checklist-config.yaml \
--gitea-host git.moleculesai.app \
--na-declarations-mode
+145 -307
View File
@@ -5,22 +5,22 @@
// that the desktop ChatTab uses, but with a slimmer surface: no
// attachments, no A2A topology overlay, no conversation tracing.
import { useEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "@/lib/api";
import { useCanvasStore } from "@/store/canvas";
import { type ChatAttachment, type ChatMessage, createMessage } from "@/components/tabs/chat/types";
import {
useChatHistory,
useChatSend,
useChatSocket,
} from "@/components/tabs/chat/hooks";
import { toMobileAgent } from "./components";
import { MOBILE_FONT_MONO, MOBILE_FONT_SANS, usePalette } from "./palette";
import { Icons, StatusDot, TierChip } from "./primitives";
interface ChatMessage {
id: string;
role: "user" | "agent" | "system";
text: string;
ts: string;
}
const formatStoredTimestamp = (iso: string): string => {
const d = new Date(iso);
if (isNaN(d.getTime())) return "";
@@ -29,171 +29,16 @@ const formatStoredTimestamp = (iso: string): string => {
type SubTab = "my" | "a2a";
function MarkdownBubble({
children,
dark,
accent,
}: {
children: string;
dark: boolean;
accent: string;
}) {
const codeBg = dark ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)";
const codeBlockBg = dark ? "#1a1a1a" : "#f5f5f0";
const linkColor = accent;
const quoteBorder = dark ? "rgba(255,250,240,0.15)" : "rgba(40,30,20,0.15)";
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
p: ({ children }) => (
<div style={{ margin: "2px 0", lineHeight: "inherit" }}>{children}</div>
),
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
style={{ color: linkColor, textDecoration: "underline" }}
>
{children}
</a>
),
pre: ({ children }) => (
<pre
style={{
background: codeBlockBg,
padding: "8px 10px",
borderRadius: 8,
overflow: "auto",
fontSize: 12,
lineHeight: 1.5,
fontFamily: MOBILE_FONT_MONO,
margin: "4px 0",
}}
>
{children}
</pre>
),
code: ({ children, className }) => {
const isBlock = className != null && String(className).length > 0;
if (isBlock) {
return (
<code style={{ fontFamily: MOBILE_FONT_MONO, fontSize: 12 }}>
{children}
</code>
);
}
return (
<code
style={{
background: codeBg,
padding: "1px 4px",
borderRadius: 4,
fontSize: 13,
fontFamily: MOBILE_FONT_MONO,
}}
>
{children}
</code>
);
},
ul: ({ children }) => (
<ul style={{ margin: "4px 0", paddingLeft: 18, listStyle: "disc" }}>
{children}
</ul>
),
ol: ({ children }) => (
<ol style={{ margin: "4px 0", paddingLeft: 18, listStyle: "decimal" }}>
{children}
</ol>
),
li: ({ children }) => <li style={{ margin: "2px 0" }}>{children}</li>,
strong: ({ children }) => (
<strong style={{ fontWeight: 600 }}>{children}</strong>
),
em: ({ children }) => <em style={{ fontStyle: "italic" }}>{children}</em>,
h1: ({ children }) => (
<div style={{ fontSize: 16, fontWeight: 700, margin: "4px 0" }}>{children}</div>
),
h2: ({ children }) => (
<div style={{ fontSize: 15, fontWeight: 700, margin: "4px 0" }}>{children}</div>
),
h3: ({ children }) => (
<div style={{ fontSize: 14, fontWeight: 700, margin: "4px 0" }}>{children}</div>
),
h4: ({ children }) => (
<div style={{ fontSize: 14, fontWeight: 600, margin: "4px 0" }}>{children}</div>
),
h5: ({ children }) => (
<div style={{ fontSize: 13, fontWeight: 600, margin: "4px 0" }}>{children}</div>
),
h6: ({ children }) => (
<div style={{ fontSize: 13, fontWeight: 600, margin: "4px 0" }}>{children}</div>
),
blockquote: ({ children }) => (
<blockquote
style={{
borderLeft: `2px solid ${quoteBorder}`,
margin: "4px 0",
paddingLeft: 8,
opacity: 0.85,
}}
>
{children}
</blockquote>
),
hr: () => (
<hr
style={{
border: "none",
borderTop: `0.5px solid ${quoteBorder}`,
margin: "6px 0",
}}
/>
),
table: ({ children }) => (
<table
style={{
borderCollapse: "collapse",
fontSize: 13,
margin: "4px 0",
width: "100%",
}}
>
{children}
</table>
),
thead: ({ children }) => <thead style={{ fontWeight: 600 }}>{children}</thead>,
th: ({ children }) => (
<th
style={{
border: `0.5px solid ${quoteBorder}`,
padding: "4px 6px",
textAlign: "left",
}}
>
{children}
</th>
),
td: ({ children }) => (
<td
style={{
border: `0.5px solid ${quoteBorder}`,
padding: "4px 6px",
}}
>
{children}
</td>
),
}}
>
{children}
</ReactMarkdown>
);
interface A2AResponseShape {
result?: {
parts?: Array<{ kind?: string; text?: string }>;
};
error?: { message?: string };
}
const formatTime = (date: Date) =>
date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
export function MobileChat({
agentId,
dark,
@@ -205,37 +50,20 @@ export function MobileChat({
}) {
const p = usePalette(dark);
const node = useCanvasStore((s) => s.nodes.find((n) => n.id === agentId));
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [draft, setDraft] = useState("");
const [tab, setTab] = useState<SubTab>("my");
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [historyLoading, setHistoryLoading] = useState(true);
const [historyError, setHistoryError] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
// Synchronous re-entry guard. `setSending(true)` schedules a state
// update but doesn't flush before a second tap can fire send() — a ref
// mirrors the desktop ChatTab pattern (sendInFlightRef) and closes the
// double-send race a stale `sending` lets through.
const sendInFlightRef = useRef(false);
const composerRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
const {
messages,
loading: historyLoading,
loadError: historyError,
appendMessageDeduped,
} = useChatHistory(agentId);
const {
sending,
uploading,
sendMessage,
error: sendError,
clearError,
releaseSendGuards,
} = useChatSend(agentId, {
getHistoryMessages: () => messages,
onUserMessage: appendMessageDeduped,
onAgentMessage: appendMessageDeduped,
});
useChatSocket(agentId, {
onAgentMessage: appendMessageDeduped,
onSendComplete: releaseSendGuards,
});
// Auto-grow the textarea: reset height to 'auto' so the scrollHeight
// shrinks when the user deletes text, then size to scrollHeight up to
@@ -254,19 +82,73 @@ export function MobileChat({
}
}, [messages]);
// Consume any agent messages that arrived while history was loading.
const initialConsumeDoneRef = useRef(false);
// Load chat history on mount / agent switch.
const loadHistory = useCallback(async () => {
setHistoryLoading(true);
setHistoryError(null);
try {
const resp = await api.get<{
messages: Array<{
id: string;
role: string;
content: string;
timestamp: string;
}>;
}>(`/workspaces/${agentId}/chat-history?limit=50`);
const loaded = (resp.messages ?? []).map((m) => ({
id: m.id,
role: m.role as "user" | "agent" | "system",
text: m.content,
ts: formatStoredTimestamp(m.timestamp),
}));
setMessages(loaded);
} catch (e) {
setHistoryError(e instanceof Error ? e.message : "Failed to load history");
} finally {
setHistoryLoading(false);
}
}, [agentId]);
useEffect(() => {
if (historyLoading || initialConsumeDoneRef.current) return;
initialConsumeDoneRef.current = true;
let cancelled = false;
loadHistory().then(() => {
if (cancelled) return;
// Consume any agent messages that arrived while history was loading.
const consume = useCanvasStore.getState().consumeAgentMessages;
const msgs = consume(agentId);
if (msgs.length > 0) {
setMessages((prev) => [
...prev,
...msgs.map((m) => ({
id: m.id,
role: "agent" as const,
text: m.content,
ts: formatStoredTimestamp(m.timestamp),
})),
]);
}
});
return () => { cancelled = true; };
}, [agentId, loadHistory]);
// Consume live agent pushes while the panel is mounted.
const pendingAgentMsgs = useCanvasStore((s) => s.agentMessages[agentId]);
useEffect(() => {
if (!pendingAgentMsgs || pendingAgentMsgs.length === 0) return;
const consume = useCanvasStore.getState().consumeAgentMessages;
const msgs = consume(agentId);
for (const m of msgs) {
appendMessageDeduped(
createMessage("agent", m.content, m.attachments),
);
if (msgs.length > 0) {
setMessages((prev) => [
...prev,
...msgs.map((m) => ({
id: m.id,
role: "agent" as const,
text: m.content,
ts: formatStoredTimestamp(m.timestamp),
})),
]);
}
}, [historyLoading, agentId, appendMessageDeduped]);
}, [pendingAgentMsgs, agentId]);
if (!node) {
return (
@@ -289,27 +171,54 @@ export function MobileChat({
const a = toMobileAgent(node);
const reachable = a.status === "online" || a.status === "degraded";
const onFilesPicked = (fileList: FileList | null) => {
if (!fileList) return;
const picked = Array.from(fileList);
setPendingFiles((prev) => {
const keyed = new Set(prev.map((f) => `${f.name}:${f.size}`));
return [...prev, ...picked.filter((f) => !keyed.has(`${f.name}:${f.size}`))];
});
if (fileInputRef.current) fileInputRef.current.value = "";
};
const removePendingFile = (index: number) =>
setPendingFiles((prev) => prev.filter((_, i) => i !== index));
const send = async () => {
const text = draft.trim();
if ((!text && pendingFiles.length === 0) || sending || !reachable) return;
clearError();
if (!text || sending || !reachable) return;
if (sendInFlightRef.current) return;
sendInFlightRef.current = true;
setDraft("");
const files = pendingFiles;
setPendingFiles([]);
await sendMessage(text, files);
setError(null);
setSending(true);
const myMsg: ChatMessage = {
id: crypto.randomUUID(),
role: "user",
text,
ts: formatTime(new Date()),
};
setMessages((m) => [...m, myMsg]);
try {
const res = await api.post<A2AResponseShape>(`/workspaces/${agentId}/a2a`, {
method: "message/send",
params: {
message: {
role: "user",
messageId: crypto.randomUUID(),
parts: [{ kind: "text", text }],
},
},
});
const reply =
res.result?.parts?.find((part) => part.kind === "text")?.text ?? "";
if (reply) {
setMessages((m) => [
...m,
{
id: crypto.randomUUID(),
role: "agent",
text: reply,
ts: formatTime(new Date()),
},
]);
} else if (res.error?.message) {
setError(res.error.message);
}
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to send");
} finally {
setSending(false);
sendInFlightRef.current = false;
}
};
return (
@@ -493,9 +402,7 @@ export function MobileChat({
overflowWrap: "anywhere",
}}
>
<MarkdownBubble dark={dark} accent={p.accent}>
{m.content}
</MarkdownBubble>
{m.text}
<div
style={{
fontSize: 10,
@@ -504,13 +411,13 @@ export function MobileChat({
fontFamily: MOBILE_FONT_MONO,
}}
>
{formatStoredTimestamp(m.timestamp)}
{m.ts}
</div>
</div>
</div>
);
})}
{sendError && (
{error && (
<div
role="alert"
style={{
@@ -522,7 +429,7 @@ export function MobileChat({
fontSize: 12,
}}
>
{sendError}
{error}
</div>
)}
</div>
@@ -553,60 +460,6 @@ export function MobileChat({
backdropFilter: "blur(14px)",
}}
>
{pendingFiles.length > 0 && (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 6,
marginBottom: 8,
paddingLeft: 2,
}}
>
{pendingFiles.map((f, i) => (
<div
key={`${f.name}:${f.size}`}
style={{
display: "flex",
alignItems: "center",
gap: 4,
padding: "3px 8px",
borderRadius: 10,
background: dark ? "#2a2823" : "#ece9e0",
fontSize: 12,
color: p.text2,
maxWidth: "100%",
}}
>
<span
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{f.name}
</span>
<button
type="button"
onClick={() => removePendingFile(i)}
aria-label={`Remove ${f.name}`}
style={{
border: "none",
background: "transparent",
color: p.text3,
cursor: "pointer",
fontSize: 12,
padding: 0,
lineHeight: 1,
}}
>
</button>
</div>
))}
</div>
)}
<div
style={{
display: "flex",
@@ -618,32 +471,21 @@ export function MobileChat({
padding: "6px 6px 6px 12px",
}}
>
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: "none" }}
onChange={(e) => onFilesPicked(e.target.files)}
aria-hidden="true"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={!reachable || sending || uploading}
aria-label="Attach"
style={{
width: 32,
height: 32,
borderRadius: 999,
border: "none",
cursor: reachable && !sending && !uploading ? "pointer" : "not-allowed",
cursor: "pointer",
background: "transparent",
color: p.text3,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
opacity: !reachable || sending || uploading ? 0.4 : 1,
}}
>
{Icons.attach({ size: 16 })}
@@ -689,32 +531,28 @@ export function MobileChat({
<button
type="button"
onClick={send}
disabled={(!draft.trim() && pendingFiles.length === 0) || !reachable || sending || uploading}
disabled={!draft.trim() || !reachable || sending}
aria-label="Send"
style={{
width: 36,
height: 36,
borderRadius: 999,
border: "none",
cursor: (draft.trim() || pendingFiles.length > 0) && !sending && !uploading ? "pointer" : "not-allowed",
cursor: draft.trim() && !sending ? "pointer" : "not-allowed",
flexShrink: 0,
background:
(draft.trim() || pendingFiles.length > 0) && reachable && !sending && !uploading
draft.trim() && reachable && !sending
? p.accent
: dark
? "#2a2823"
: "#ece9e0",
color: (draft.trim() || pendingFiles.length > 0) && reachable && !sending && !uploading ? "#fff" : p.text3,
color: draft.trim() && reachable && !sending ? "#fff" : p.text3,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{uploading ? (
<span style={{ fontSize: 10, fontWeight: 600 }}></span>
) : (
Icons.send({ size: 16 })
)}
{Icons.send({ size: 16 })}
</button>
</div>
</div>
+696 -96
View File
@@ -3,20 +3,18 @@
import { useState, useRef, useEffect, useCallback, useLayoutEffect } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { api } from "@/lib/api";
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
import { useSocketEvent } from "@/hooks/useSocketEvent";
import { type ChatMessage, type ChatAttachment, createMessage, appendMessageDeduped } from "./chat/types";
import { downloadChatFile, isPlatformAttachment } from "./chat/uploads";
import { uploadChatFiles, downloadChatFile, isPlatformAttachment } from "./chat/uploads";
import { PendingAttachmentPill } from "./chat/AttachmentViews";
import { AttachmentPreview } from "./chat/AttachmentPreview";
import { extractFilesFromTask } from "./chat/message-parser";
import { AgentCommsPanel } from "./chat/AgentCommsPanel";
import { appendActivityLine } from "./chat/activityLog";
import { runtimeDisplayName } from "@/lib/runtime-names";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { useChatHistory } from "./chat/hooks/useChatHistory";
import { useChatSend } from "./chat/hooks/useChatSend";
import { useChatSocket } from "./chat/hooks/useChatSocket";
export { extractReplyText } from "./chat/hooks/useChatSend";
interface Props {
workspaceId: string;
@@ -25,6 +23,147 @@ interface Props {
type ChatSubTab = "my-chat" | "agent-comms";
// A2A response shape (subset). The full schema is in @a2a-js/sdk but we only
// need parts/artifacts text + file extraction for the synchronous fallback.
interface A2AFileRef {
name?: string;
mimeType?: string;
uri?: string;
bytes?: string;
size?: number;
}
// Outbound shape matches a2a-sdk's JSON-RPC `SendMessageRequest`
// Pydantic union (TextPart | FilePart | DataPart). The flat
// protobuf shape `{url, filename, mediaType}` is rejected at the
// request boundary with `Field required` errors — keep this
// outbound shape unless a2a-sdk migrates the JSON-RPC schema.
interface A2APart {
kind: string;
text?: string;
file?: A2AFileRef;
}
interface A2AResponse {
result?: {
parts?: A2APart[];
artifacts?: Array<{ parts: A2APart[] }>;
};
}
// Internal-self-message filtering moved server-side in RFC #2945
// PR-C/D — the platform's /chat-history endpoint applies the
// IsInternalSelfMessage predicate before returning rows, so the
// client no longer needs the local backstop on the history path.
// The proper fix is still X-Workspace-ID header (source_id=workspace_id);
// the platform-side prefix filter handles the residual cases.
// extractReplyText pulls the agent's text reply out of an A2A response.
// Concatenates ALL text parts (joined with "\n") rather than returning
// just the first. Claude Code and other runtimes commonly emit multi-
// part text replies for long content (markdown tables, code blocks),
// and the prior "first part wins" implementation silently truncated
// the rest — observed on a 15k-char Wave 1 brief that rendered only
// the table header. Mirrors extractTextsFromParts in message-parser.ts.
//
// Server-side counterpart in workspace-server/internal/channels/
// manager.go has the same single-part bug; fix that too if/when a
// channel-delivered reply (Slack, Lark, etc.) gets truncated.
export function extractReplyText(resp: A2AResponse): string {
const collect = (parts: A2APart[] | undefined): string => {
if (!parts) return "";
return parts
.filter((p) => p.kind === "text")
.map((p) => p.text ?? "")
.filter(Boolean)
.join("\n");
};
const result = resp?.result;
const collected: string[] = [];
const fromParts = collect(result?.parts);
if (fromParts) collected.push(fromParts);
// Walk artifacts even if parts had text — some producers (Hermes
// tool calls) emit a summary in parts AND details in artifacts.
// Returning early on parts dropped the artifact body silently.
if (result?.artifacts) {
for (const a of result.artifacts) {
const t = collect(a.parts);
if (t) collected.push(t);
}
}
return collected.join("\n");
}
// Agent-returned files live on the same response shape as text —
// delegated to extractFilesFromTask in message-parser.ts, which also
// walks status.message.parts (that ChatTab's legacy text extractor
// doesn't). Single source of truth for file-part parsing across
// live chat, activity log replay, and any future consumers.
/** Initial chat history page size. The newest N messages are rendered
* on first paint; older history is fetched on demand via loadOlder()
* when the user scrolls the top sentinel into view. */
const INITIAL_HISTORY_LIMIT = 10;
/** Subsequent older-history batch size. Larger than INITIAL so a long
* scroll-back doesn't fan out into many round-trips. */
const OLDER_HISTORY_BATCH = 20;
/**
* Load chat history from the platform's typed /chat-history endpoint.
*
* Server-side rendering of activity_logs rows into ChatMessage shape
* lives in workspace-server/internal/messagestore/postgres_store.go
* (RFC #2945 PR-C/D). The server already applies the canvas-source
* filter, the internal-self-message predicate, the role decision
* (status=error vs agent-error prefix → system), and the v0/v1
* file-shape extraction. Canvas just renders what it receives.
*
* Wire shape (mirrors ChatMessage exactly, no per-row mapping needed):
*
* GET /workspaces/:id/chat-history?limit=N&before_ts=T
* 200 → {"messages": ChatMessage[], "reached_end": boolean}
*
* Pagination:
* - Pass `limit` to bound the page size (newest-first from server).
* - Pass `beforeTs` (RFC3339) to fetch rows STRICTLY OLDER than that
* timestamp. Combined with limit, this yields the next-older page
* when scrolling backward through history.
*
* `reachedEnd` is propagated from the server. The server computes it
* by comparing rowCount vs limit so a partial last page is correctly
* detected even when the row→bubble fan-out is non-1:1 (each row
* produces 1-2 bubbles).
*/
async function loadMessagesFromDB(
workspaceId: string,
limit: number,
beforeTs?: string,
): Promise<{ messages: ChatMessage[]; error: string | null; reachedEnd: boolean }> {
try {
const params = new URLSearchParams({ limit: String(limit) });
if (beforeTs) params.set("before_ts", beforeTs);
const resp = await api.get<{ messages: ChatMessage[]; reached_end: boolean }>(
`/workspaces/${workspaceId}/chat-history?${params.toString()}`,
);
// Server emits oldest-first within the page (RFC #2945 PR-C-2
// post-fix: server reverses row-aware before returning so the
// wire is display-ready). Canvas appends/prepends without
// reordering — this avoids the pair-flip bug a naive flat
// reverse causes when each row produces a (user, agent) pair
// with the same timestamp.
return {
messages: resp.messages ?? [],
error: null,
reachedEnd: resp.reached_end,
};
} catch (err) {
return {
messages: [],
error: err instanceof Error ? err.message : "Failed to load chat history",
reachedEnd: true,
};
}
}
/**
* ChatTab container — renders sub-tab bar + My Chat or Agent Comms panel.
*/
@@ -108,68 +247,268 @@ export function ChatTab({ workspaceId, data }: Props) {
* MyChatPanel — user↔agent conversation (extracted from original ChatTab).
*/
function MyChatPanel({ workspaceId, data }: Props) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState("");
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
const [activityLog, setActivityLog] = useState<string[]>([]);
// `sending` is strictly the "this tab kicked off a send and hasn't
// seen the reply yet" signal. Previously this was initialized from
// data.currentTask to pick up in-flight agent work on mount, but
// that conflated agent-busy (workspace heartbeat) with user-
// in-flight (local send): when the WS dropped a TASK_COMPLETE event,
// currentTask lingered, the component re-mounted with sending=true,
// and the Send button stayed disabled forever even though nothing
// local was in flight. For the "agent is busy, show spinner" UX,
// use data.currentTask directly in the render path.
const [sending, setSending] = useState(false);
const [thinkingElapsed, setThinkingElapsed] = useState(0);
const [activityLog, setActivityLog] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const currentTaskRef = useRef(data.currentTask);
const sendingFromAPIRef = useRef(false);
const [agentReachable, setAgentReachable] = useState(false);
const [error, setError] = useState<string | null>(null);
const [confirmRestart, setConfirmRestart] = useState(false);
const [dragOver, setDragOver] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
// First-mount scroll-to-bottom needs `behavior: "instant"` — long
// conversations smooth-animate for ~300ms which any concurrent
// re-render can interrupt, leaving the user stuck mid-conversation
// when the chat tab opens. Subsequent appends (new agent messages)
// keep `smooth` for the visual "landing" feel. Flipped the first
// time messages.length goes positive, so a workspace switch (which
// remounts ChatTab) gets a fresh instant jump too.
const hasInitialScrollRef = useRef(false);
// Lazy-load older history on scroll-up.
// - containerRef = the scrollable messages viewport
// - topRef = sentinel above the messages list; IO observes it
// and triggers loadOlder() when it enters view
// - hasMore = false once a fetch returns < limit rows; stops IO
// - loadingOlder = drives the "Loading older messages…" UI label
// - inflightRef = synchronous guard against double-entry of loadOlder
// when the IO callback fires twice in the same
// microtask (state-based guard would be stale until
// the next React commit)
// - scrollAnchorRef = saves distance-from-bottom before a prepend
// so the useLayoutEffect below can restore the
// user's exact viewport position. Without this,
// prepending older messages would jump the scroll
// position by the height of the new content.
// - oldestMessageRef / hasMoreRef = let the loadOlder closure read
// the latest values without taking them as deps —
// every live agent push mutates `messages`, and
// having loadOlder depend on `messages` would tear
// down + re-arm the IntersectionObserver on every
// push. Refs decouple the observer lifecycle from
// message-list updates.
const containerRef = useRef<HTMLDivElement>(null);
const topRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const hasInitialScrollRef = useRef(false);
const [hasMore, setHasMore] = useState(true);
const [loadingOlder, setLoadingOlder] = useState(false);
const inflightRef = useRef(false);
// The scroll anchor includes the first-message id as it was BEFORE
// the prepend — see useLayoutEffect below for why. Without this tag,
// a live agent push that appends WHILE loadOlder is in flight would
// run useLayoutEffect against the append (anchor still set), the
// "restore" math would scroll the user to a stale offset, AND the
// append's normal scroll-to-bottom would be swallowed.
const scrollAnchorRef = useRef<
{ savedDistanceFromBottom: number; expectFirstIdNotEqual: string | null } | null
>(null);
const oldestMessageRef = useRef<ChatMessage | null>(null);
const hasMoreRef = useRef(true);
// Monotonic token bumped on workspace switch + on every loadOlder
// entry. Each fetch's .then() captures its own token; if the token
// has moved, the resolved messages belong to a stale workspace or a
// superseded fetch and we silently drop them. Without this guard, a
// workspace switch mid-fetch would have the in-flight promise
// resolve into the new workspace's setMessages — the user sees
// someone else's history briefly.
const fetchTokenRef = useRef(0);
// Files the user has picked but not yet sent. Cleared on send
// (upload success) or by the × on each pill.
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const dragDepthRef = useRef(0);
const pasteCounterRef = useRef(0);
// Guard against a double-click during the upload phase: React
// state updates from the click that started the upload haven't
// flushed yet, so the disabled-button logic sees `uploading=false`
// from the closure and lets a second `sendMessage` enter. A ref
// observes the latest value synchronously.
const sendInFlightRef = useRef(false);
// Monotonic token bumped on every sendMessage entry. Each .then()/
// .catch() captures its own token in closure and bails if a newer
// send has superseded it — prevents a late HTTP response for an
// earlier message from clobbering the flags / appending text that
// belong to a newer in-flight send. Race scenario the token closes:
// (1) send msg #1 (2) WS push for msg #1 arrives, releases guards
// (3) user sends msg #2 (4) HTTP for msg #1 finally lands — without
// the token check, .then() sees sendingFromAPIRef=true (set by
// msg #2's send), enters the main body, and processes msg #1's body
// as if it were msg #2's reply.
const sendTokenRef = useRef(0);
const history = useChatHistory(workspaceId, containerRef);
const chatSend = useChatSend(workspaceId, {
getHistoryMessages: () => history.messages,
onUserMessage: (msg) => history.setMessages((prev) => [...prev, msg]),
onAgentMessage: (msg) => history.setMessages((prev) => appendMessageDeduped(prev, msg)),
});
const { sending, uploading, sendMessage, error: sendError, clearError: clearSendError, releaseSendGuards, sendingFromAPIRef } = chatSend;
// Release every in-flight send guard at once. Used by every site
// that ends a send: pendingAgentMsgs WS push, ACTIVITY_LOGGED
// a2a_receive ok/error WS event, HTTP .then() success, and HTTP
// .catch() success. Keep these in lockstep — a future contributor
// adding a new "I saw the reply" path that only clears `sending` +
// `sendingFromAPIRef` (the natural pair) silently re-introduces
// the post-WS Send-button freeze, because the disabled-button
// logic can't see `sendInFlightRef` and so the visible state diverges
// from the synchronous re-entry guard at line 464.
const releaseSendGuards = useCallback(() => {
setSending(false);
sendingFromAPIRef.current = false;
sendInFlightRef.current = false;
}, []);
const displayError = error || sendError;
// Initial-load fetch — used by the mount effect and the "Retry"
// button below. Single source of truth so the two paths can't drift
// (e.g. INITIAL_HISTORY_LIMIT bumped in the effect but not the
// retry, leading to inconsistent first-paint sizes).
const loadInitial = useCallback(() => {
setLoading(true);
setLoadError(null);
setHasMore(true);
// Bump the token; any in-flight fetch from the previous workspace
// (or a previous retry) will see token != myToken in its .then()
// and silently bail — the late response can't clobber the new
// workspace's state.
fetchTokenRef.current += 1;
const myToken = fetchTokenRef.current;
loadMessagesFromDB(workspaceId, INITIAL_HISTORY_LIMIT).then(
({ messages: msgs, error: fetchErr, reachedEnd }) => {
if (fetchTokenRef.current !== myToken) return;
setMessages(msgs);
setLoadError(fetchErr);
setHasMore(!reachedEnd);
setLoading(false);
},
);
}, [workspaceId]);
useChatSocket(workspaceId, {
onAgentMessage: (msg) => {
history.setMessages((prev) => appendMessageDeduped(prev, msg));
if (sendingFromAPIRef.current) {
releaseSendGuards();
// Load chat history on mount / workspace switch.
// Initial load is bounded to INITIAL_HISTORY_LIMIT (newest 10) — the
// rest streams in as the user scrolls up via loadOlder() below. Pre-
// 2026-05-05 this fetched the newest 50 in one shot; on a long-running
// workspace that meant 50× message-bubble paint + DOM cost on every
// tab-open even when the user only wanted to read the last few.
useEffect(() => {
loadInitial();
}, [loadInitial]);
// Mirror the latest oldest-message + hasMore into refs so loadOlder
// can read them without taking `messages` as a dep. Every live push
// through agentMessages would otherwise recreate loadOlder and tear
// down the IO observer.
useEffect(() => {
oldestMessageRef.current = messages[0] ?? null;
}, [messages]);
useEffect(() => {
hasMoreRef.current = hasMore;
}, [hasMore]);
// Fetch the next-older batch and prepend. Stable identity (deps =
// [workspaceId]) so the IntersectionObserver effect below doesn't
// re-arm on every messages update.
const loadOlder = useCallback(async () => {
// inflightRef is the load-bearing guard — synchronous, set BEFORE
// any await, so two IO callbacks dispatched in the same microtask
// can't both pass. The state checks are defensive secondary
// gates for the slow-scroll case.
if (inflightRef.current || !hasMoreRef.current) return;
const oldest = oldestMessageRef.current;
if (!oldest) return;
const container = containerRef.current;
if (!container) return;
inflightRef.current = true;
// Capture the user's distance-from-bottom BEFORE we prepend so the
// useLayoutEffect can restore it after the new DOM lands. The
// expectFirstIdNotEqual tag is what the layout effect checks
// against `messages[0].id` to disambiguate prepend (id changed) vs
// append (id unchanged → live message landed mid-fetch). Without
// it, an agent push during loadOlder runs the "restore" against a
// stale anchor — user gets yanked + the append's bottom-pin is
// swallowed.
scrollAnchorRef.current = {
savedDistanceFromBottom: container.scrollHeight - container.scrollTop,
expectFirstIdNotEqual: oldest.id,
};
fetchTokenRef.current += 1;
const myToken = fetchTokenRef.current;
setLoadingOlder(true);
try {
const { messages: older, reachedEnd } = await loadMessagesFromDB(
workspaceId,
OLDER_HISTORY_BATCH,
oldest.timestamp,
);
// Workspace switched (or another loadOlder bumped the token)
// mid-fetch — drop these results, they belong to a stale tab.
if (fetchTokenRef.current !== myToken) {
scrollAnchorRef.current = null;
return;
}
},
onActivityLog: (entry) => {
if (!sending) return;
setActivityLog((prev) => appendActivityLine(prev, entry));
},
onSendComplete: () => {
if (sendingFromAPIRef.current) {
releaseSendGuards();
if (older.length > 0) {
setMessages((prev) => [...older, ...prev]);
} else {
// Nothing came back — clear the anchor so the next paint doesn't
// try to "restore" against a no-op prepend.
scrollAnchorRef.current = null;
}
},
onSendError: (err) => {
if (sendingFromAPIRef.current) {
releaseSendGuards();
setError(err);
}
},
});
setHasMore(!reachedEnd);
} finally {
setLoadingOlder(false);
inflightRef.current = false;
}
}, [workspaceId]);
// IntersectionObserver on the top sentinel. Fires loadOlder() the
// moment the user scrolls within 200px of the top. AbortController
// unwires cleanly on workspace switch / unmount; root is the
// scrollable container so we observe only what's visible inside it.
//
// Dependencies:
// - loadOlder — stable per workspaceId (refs decouple it from
// message updates), so this dep is here for the
// workspace-switch case only
// - hasMore — re-run when older history runs out so we
// disconnect cleanly
// - hasMessages — load-bearing: the sentinel JSX is gated on
// `messages.length > 0`, so topRef.current is null
// on the empty-messages render. We re-arm exactly
// once when messages first land. NOT depending on
// `messages.length` (or `messages`) directly so
// each subsequent message append doesn't tear down
// + re-arm the observer.
const hasMessages = messages.length > 0;
useEffect(() => {
const top = topRef.current;
const container = containerRef.current;
if (!top || !container) return;
if (!hasMore) return; // stop observing when no older history exists
const ac = new AbortController();
const io = new IntersectionObserver(
(entries) => {
if (ac.signal.aborted) return;
if (entries[0]?.isIntersecting) loadOlder();
},
{ root: container, rootMargin: "200px 0px 0px 0px", threshold: 0 },
);
io.observe(top);
ac.signal.addEventListener("abort", () => io.disconnect());
return () => ac.abort();
}, [loadOlder, hasMore, hasMessages]);
// Agent reachability
useEffect(() => {
const reachable = data.status === "online" || data.status === "degraded";
setAgentReachable(reachable);
if (reachable) {
setError(null);
clearSendError();
} else {
setError(`Agent is ${data.status}`);
}
}, [data.status, clearSendError]);
setError(reachable ? null : `Agent is ${data.status}`);
}, [data.status]);
useEffect(() => {
currentTaskRef.current = data.currentTask;
}, [data.currentTask]);
// Scroll behavior across messages updates:
// - Prepend (loadOlder landed) → restore the user's saved
@@ -179,24 +518,71 @@ function MyChatPanel({ workspaceId, data }: Props) {
// paint — otherwise the user sees the page jump for one frame.
useLayoutEffect(() => {
const container = containerRef.current;
const anchor = history.scrollAnchorRef.current;
const anchor = scrollAnchorRef.current;
// Only honor the anchor when this messages-update is the prepend
// we expected. messages[0].id is the test:
// - prepend → messages[0] is one of the older rows → id !== expectFirstIdNotEqual
// - append → messages[0] unchanged → id === expectFirstIdNotEqual → fall through
// Without this check, an agent push that lands mid-loadOlder would
// run the restore against the append's update, yank the user's
// scroll, AND swallow the append's bottom-pin.
if (
anchor &&
container &&
history.messages.length > 0 &&
history.messages[0].id !== anchor.expectFirstIdNotEqual
messages.length > 0 &&
messages[0].id !== anchor.expectFirstIdNotEqual
) {
container.scrollTop = container.scrollHeight - anchor.savedDistanceFromBottom;
history.scrollAnchorRef.current = null;
scrollAnchorRef.current = null;
return;
}
if (!hasInitialScrollRef.current && history.messages.length > 0) {
// Instant on first arrival of messages — smooth-scroll on a long
// conversation gets interrupted by concurrent renders and leaves
// the user stuck in the middle. After the first jump, subsequent
// appends animate as before.
if (!hasInitialScrollRef.current && messages.length > 0) {
hasInitialScrollRef.current = true;
bottomRef.current?.scrollIntoView({ behavior: "instant" as ScrollBehavior });
return;
}
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [history.messages, history.scrollAnchorRef]);
}, [messages]);
// Consume agent push messages (send_message_to_user) from global store.
// Runtimes like Claude Code SDK deliver their reply via a WS push rather
// than the /a2a HTTP response — when that happens, the push is the
// authoritative "reply arrived" signal for the UI, so clear `sending`
// here too. The HTTP .then() coordinates through sendingFromAPIRef so
// whichever path clears first wins.
const pendingAgentMsgs = useCanvasStore((s) => s.agentMessages[workspaceId]);
useEffect(() => {
if (!pendingAgentMsgs || pendingAgentMsgs.length === 0) return;
const consume = useCanvasStore.getState().consumeAgentMessages;
const msgs = consume(workspaceId);
for (const m of msgs) {
// Dedupe in case the agent proactively pushed the same text the
// HTTP /a2a response already delivered (observed with the Hermes
// runtime, which emits both a reply body and a send_message_to_user
// push for the same content). Attachments ride along with the
// message so files returned by the A2A_RESPONSE WS path render
// their download chips.
setMessages((prev) => appendMessageDeduped(prev, createMessage("agent", m.content, m.attachments)));
}
if (sendingFromAPIRef.current && msgs.length > 0) {
// Reply arrived via WS push (e.g. claude-code SDK). Release all
// three guards together — without sendInFlightRef the next
// sendMessage() silently no-ops at the synchronous re-entry
// check.
releaseSendGuards();
}
}, [pendingAgentMsgs, workspaceId]);
// Resolve workspace ID → name for activity display
const resolveWorkspaceName = useCallback((id: string) => {
const nodes = useCanvasStore.getState().nodes;
const node = nodes.find((n) => n.id === id);
return (node?.data as WorkspaceNodeData)?.name || id.slice(0, 8);
}, []);
// Elapsed timer while sending
useEffect(() => {
@@ -223,43 +609,211 @@ function MyChatPanel({ workspaceId, data }: Props) {
setActivityLog([`Processing with ${runtimeDisplayName(data.runtime)}...`]);
}, [sending, data.runtime]);
// IntersectionObserver on the top sentinel. Fires loadOlder() the
// moment the user scrolls within 200px of the top. AbortController
// unwires cleanly on workspace switch / unmount; root is the
// scrollable container so we observe only what's visible inside it.
const hasMessages = history.messages.length > 0;
useEffect(() => {
const top = topRef.current;
const container = containerRef.current;
if (!top || !container) return;
if (!history.hasMore) return;
const ac = new AbortController();
const io = new IntersectionObserver(
(entries) => {
if (ac.signal.aborted) return;
if (entries[0]?.isIntersecting) history.loadOlder();
},
{ root: container, rootMargin: "200px 0px 0px 0px", threshold: 0 },
);
io.observe(top);
ac.signal.addEventListener("abort", () => io.disconnect());
return () => ac.abort();
}, [history.loadOlder, history.hasMore, hasMessages]);
// Subscribe to global WS via the singleton ReconnectingSocket (no
// per-component WebSocket — the previous pattern dropped events
// silently on any reconnect because each panel's raw socket had no
// onclose handler).
useSocketEvent((msg) => {
if (!sending) return;
try {
if (msg.event === "ACTIVITY_LOGGED") {
// Filter to events for THIS workspace. The platform's
// BroadcastOnly fires to every connected client, and
// without this guard a sibling workspace's a2a_send would
// surface as "→ Delegating to X..." inside the wrong
// chat panel. (workspace_id on the WS envelope is the
// workspace whose activity_log row we just wrote.)
if (msg.workspace_id !== workspaceId) return;
const handleSend = async () => {
const p = msg.payload || {};
const type = p.activity_type as string;
const method = (p.method as string) || "";
const status = (p.status as string) || "";
const targetId = (p.target_id as string) || "";
const durationMs = p.duration_ms as number | undefined;
const summary = (p.summary as string) || "";
let line = "";
if (type === "a2a_receive" && method === "message/send") {
const targetName = resolveWorkspaceName(targetId || msg.workspace_id);
if (status === "ok" && durationMs) {
const sec = Math.round(durationMs / 1000);
line = `${targetName} responded (${sec}s)`;
// The platform logs a successful a2a_receive once the workspace
// has fully produced its reply. That's the authoritative "done"
// signal for the spinner — clear it even if the reply hasn't
// surfaced through the store yet (it may be delivered shortly
// via pendingAgentMsgs or the HTTP .then()).
const own = (targetId || msg.workspace_id) === workspaceId;
if (own && sendingFromAPIRef.current) {
releaseSendGuards();
}
} else if (status === "error") {
line = `${targetName} error`;
const own = (targetId || msg.workspace_id) === workspaceId;
if (own && sendingFromAPIRef.current) {
releaseSendGuards();
setError("Agent error (Exception) — see workspace logs for details.");
}
}
} else if (type === "a2a_send") {
const targetName = resolveWorkspaceName(targetId);
line = `→ Delegating to ${targetName}...`;
} else if (type === "task_update") {
if (summary) line = `${summary}`;
} else if (type === "agent_log") {
// Per-tool-use telemetry from claude_sdk_executor's
// _report_tool_use. The summary already carries an icon
// + human-readable args (📄 Read /path, ⚡ Bash: …)
// so we render it verbatim. No icon prefix here — the
// emoji at the start of summary is the visual marker.
if (summary) line = summary;
}
if (line) {
setActivityLog((prev) => appendActivityLine(prev, line));
}
} else if (msg.event === "TASK_UPDATED" && msg.workspace_id === workspaceId) {
const task = (msg.payload?.current_task as string) || "";
if (task) {
setActivityLog((prev) => appendActivityLine(prev, `${task}`));
}
}
// A2A_RESPONSE is already consumed by the store and its text is
// appended to messages via the pendingAgentMsgs effect above; we
// don't need to duplicate it here.
} catch { /* ignore */ }
});
const sendMessage = async () => {
const text = input.trim();
const files = pendingFiles;
if ((!text && files.length === 0) || !agentReachable || sending || uploading) return;
const filesToSend = pendingFiles;
// Allow sending if EITHER text OR attachments are present — a user
// can drop a file with no text and the agent still receives it.
if ((!text && filesToSend.length === 0) || !agentReachable || sending || uploading) return;
// Synchronous re-entry guard — see sendInFlightRef comment.
if (sendInFlightRef.current) return;
sendInFlightRef.current = true;
// Upload attachments first so we can include URIs in the A2A
// message parts. Sequential-before-send: a message with references
// to files not yet staged would fail agent-side; staging happens
// synchronously via /chat/uploads before message/send dispatch.
let uploaded: ChatAttachment[] = [];
if (filesToSend.length > 0) {
setUploading(true);
try {
uploaded = await uploadChatFiles(workspaceId, filesToSend);
} catch (e) {
setUploading(false);
sendInFlightRef.current = false;
setError(e instanceof Error ? `Upload failed: ${e.message}` : "Upload failed");
return;
}
setUploading(false);
}
setInput("");
setPendingFiles([]);
clearSendError();
setMessages((prev) => [...prev, createMessage("user", text, uploaded)]);
setSending(true);
sendingFromAPIRef.current = true;
setError(null);
await sendMessage(text, files);
// Capture this send's token so the .then()/.catch() callbacks can
// detect a newer send that may have superseded them. See the
// sendTokenRef declaration for the race scenario this closes.
const myToken = ++sendTokenRef.current;
// Build conversation history from prior messages (last 20)
const history = messages
.filter((m) => m.role === "user" || m.role === "agent")
.slice(-20)
.map((m) => ({
role: m.role === "user" ? "user" : "agent",
parts: [{ kind: "text", text: m.content }],
}));
// A2A parts: text part (if any) + file parts (per attachment). The
// agent sees both in a single turn, matching the A2A spec shape.
// Wire shape is v0 — see A2APart definition above.
const parts: A2APart[] = [];
if (text) parts.push({ kind: "text", text });
for (const att of uploaded) {
parts.push({
kind: "file",
file: {
name: att.name,
mimeType: att.mimeType,
uri: att.uri,
size: att.size,
},
});
}
// A2A calls can legitimately take minutes — LLM latency +
// multi-turn tool use is common on slower providers (Hermes+minimax,
// Claude Code invoking bash/file tools, etc.). The 15s default
// would silently abort the fetch here, leaving the server to
// complete the reply and the user staring at
// "agent may be unreachable". Match the upload timeout (60s × 2)
// for the happy-path ceiling; anything longer is genuinely stuck.
api.post<A2AResponse>(`/workspaces/${workspaceId}/a2a`, {
method: "message/send",
params: {
message: {
role: "user",
messageId: crypto.randomUUID(),
parts,
},
metadata: { history },
},
}, { timeoutMs: 120_000 })
.then((resp) => {
// Bail without touching any flags if a newer sendMessage has
// already run — its myToken bumped sendTokenRef, so this is
// a stale callback for an earlier message. The newer send
// owns the in-flight guards now.
if (sendTokenRef.current !== myToken) return;
// Skip if the WS A2A_RESPONSE event already handled this response.
// Both paths (WS + HTTP) check sendingFromAPIRef — whichever clears
// it first wins, the other becomes a no-op (no duplicate messages).
if (!sendingFromAPIRef.current) {
sendInFlightRef.current = false;
return;
}
const replyText = extractReplyText(resp);
const replyFiles = extractFilesFromTask((resp?.result ?? {}) as Record<string, unknown>);
if (replyText || replyFiles.length > 0) {
setMessages((prev) =>
appendMessageDeduped(prev, createMessage("agent", replyText, replyFiles)),
);
}
releaseSendGuards();
})
.catch(() => {
// Stale-callback guard — same rationale as .then().
if (sendTokenRef.current !== myToken) return;
// Same dedup guard as .then(): if a WS path (pendingAgentMsgs
// or ACTIVITY_LOGGED a2a_receive ok) already delivered the
// reply, sendingFromAPIRef is already false and there's
// nothing to roll back. Surfacing "Failed to send" here would
// contradict the agent reply the user is currently reading —
// exactly the false-positive observed when the HTTP request
// hung up (proxy idle / 502) after WS already won.
if (!sendingFromAPIRef.current) {
sendInFlightRef.current = false;
return;
}
releaseSendGuards();
setError("Failed to send message — agent may be unreachable");
});
};
const onFilesPicked = (fileList: FileList | null) => {
if (!fileList) return;
const picked = Array.from(fileList);
// Deduplicate against current pending set by name+size — user
// picking the same file twice shouldn't append it.
setPendingFiles((prev) => {
const keyed = new Set(prev.map((f) => `${f.name}:${f.size}`));
return [...prev, ...picked.filter((f) => !keyed.has(`${f.name}:${f.size}`))];
@@ -270,7 +824,35 @@ function MyChatPanel({ workspaceId, data }: Props) {
const removePendingFile = (index: number) =>
setPendingFiles((prev) => prev.filter((_, i) => i !== index));
// Monotonic counter so two paste events within the same wall-clock
// second still produce distinct filenames. Without this, on
// Firefox (where pasted images have an empty `file.name`), two
// pastes ~100ms apart could yield identical synthetic names AND
// identical sizes, collapsing into one attachment via the
// `name:size` dedup in onFilesPicked.
const pasteCounterRef = useRef(0);
/** Paste-from-clipboard image attachment.
*
* Browser clipboard image items arrive as `File`s whose `name` is
* often a generic "image.png" (Chrome) or empty (Firefox/Safari),
* so two consecutive screenshot pastes collide on the name+size
* dedup the file-picker uses. Re-tag each pasted image with a
* per-paste unique name so dedup keeps them apart and the upload
* pipeline (which expects a non-empty filename) is happy.
*
* Falls through to onFilesPicked via direct File[] (NOT through
* the DataTransfer constructor — that throws on Safari < 14.1
* and old Edge, silently aborting the paste).
*
* Only intercepts the paste when the clipboard has at least one
* image; text-only pastes fall through to the textarea's default
* behaviour. */
const mimeToExt = (mime: string): string => {
// Avoid raw `mime.split("/")[1]` — that yields `"svg+xml"`,
// `"jpeg"`, `"webp"` etc. which produce ugly filenames and may
// trip server-side extension allowlists. Map known types
// explicitly; unknown falls back to a safe default.
if (mime === "image/svg+xml") return "svg";
if (mime === "image/jpeg") return "jpg";
if (mime === "image/png") return "png";
@@ -291,16 +873,26 @@ function MyChatPanel({ workspaceId, data }: Props) {
const file = item.getAsFile();
if (!file) continue;
const ext = mimeToExt(file.type);
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
const stamp = new Date()
.toISOString()
.replace(/[:.]/g, "-")
.slice(0, 19);
const seq = pasteCounterRef.current++;
const fname = `pasted-${stamp}-${seq}-${i}.${ext}`;
imageFiles.push(new File([file], fname, { type: file.type }));
}
if (imageFiles.length === 0) return;
e.preventDefault();
// Reuse the picker path so file-size guards, dedup, and pending-
// list state all run through the same code. Build a synthetic
// FileList-like object to avoid the DataTransfer constructor —
// that's missing on Safari < 14.1 / old Edge and would silently
// throw, leaving the paste a no-op.
addPastedFiles(imageFiles);
};
// Variant of onFilesPicked that accepts a File[] directly, sidestepping
// the DataTransfer-FileList round-trip. Same dedup + state shape.
const addPastedFiles = (files: File[]) => {
setPendingFiles((prev) => {
const keyed = new Set(prev.map((f) => `${f.name}:${f.size}`));
@@ -308,6 +900,11 @@ function MyChatPanel({ workspaceId, data }: Props) {
});
};
// Drag-and-drop staging. dragDepthRef counts enter vs leave events so
// the overlay doesn't flicker when the cursor crosses nested children
// (textarea, buttons) — dragenter/dragleave fire for every boundary.
const [dragOver, setDragOver] = useState(false);
const dragDepthRef = useRef(0);
const dropEnabled = agentReachable && !sending && !uploading;
const isFileDrag = (e: React.DragEvent) =>
Array.from(e.dataTransfer.types || []).includes("Files");
@@ -337,6 +934,9 @@ function MyChatPanel({ workspaceId, data }: Props) {
};
const downloadAttachment = (att: ChatAttachment) => {
// Errors here are rare but user-visible (401 on a revoked token,
// 404 if the agent deleted the file). Surface via the inline
// error banner — the message list itself stays untouched.
downloadChatFile(workspaceId, att).catch((e) => {
setError(e instanceof Error ? `Download failed: ${e.message}` : "Download failed");
});
@@ -364,26 +964,26 @@ function MyChatPanel({ workspaceId, data }: Props) {
)}
{/* Messages */}
<div ref={containerRef} className="flex-1 overflow-y-auto p-3 space-y-3">
{history.loading && (
{loading && (
<div className="text-xs text-ink-mid text-center py-4">Loading chat history...</div>
)}
{!history.loading && history.loadError !== null && history.messages.length === 0 && (
{!loading && loadError !== null && messages.length === 0 && (
<div
role="alert"
className="mx-2 mt-2 rounded-lg border border-red-800/50 bg-red-950/30 px-3 py-2.5"
>
<p className="text-[11px] text-bad mb-1.5">
Failed to load chat history: {history.loadError}
Failed to load chat history: {loadError}
</p>
<button
onClick={history.loadInitial}
onClick={loadInitial}
className="text-[10px] px-2 py-0.5 rounded bg-red-800 text-red-200 hover:bg-red-700 transition-colors"
>
Retry
</button>
</div>
)}
{!history.loading && history.loadError === null && history.messages.length === 0 && (
{!loading && loadError === null && messages.length === 0 && (
<div className="text-xs text-ink-mid text-center py-8">
No messages yet. Send a message to start chatting with this agent.
</div>
@@ -401,12 +1001,12 @@ function MyChatPanel({ workspaceId, data }: Props) {
instead of showing a "no more messages" footer — the user's
scroll resting against the top of the conversation IS the
signal. */}
{history.hasMore && history.messages.length > 0 && (
{hasMore && messages.length > 0 && (
<div ref={topRef} className="text-xs text-ink-mid text-center py-1">
{history.loadingOlder ? "Loading older messages…" : " "}
{loadingOlder ? "Loading older messages…" : " "}
</div>
)}
{history.messages.map((msg) => (
{messages.map((msg) => (
<div key={msg.id} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
<div
className={`max-w-[85%] rounded-lg px-3 py-2 text-xs ${
@@ -566,10 +1166,10 @@ function MyChatPanel({ workspaceId, data }: Props) {
</div>
{/* Error banner */}
{displayError && (
{error && (
<div className="px-3 py-2 bg-red-900/20 border-t border-red-800/30">
<div className="flex items-center justify-between">
<span className="text-[10px] text-red-300">{displayError}</span>
<span className="text-[10px] text-red-300">{error}</span>
{!isOnline && (
<button
onClick={() => setConfirmRestart(true)}
@@ -637,7 +1237,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
e.keyCode !== 229
) {
e.preventDefault();
handleSend();
sendMessage();
}
}}
onPaste={onPasteIntoComposer}
@@ -647,7 +1247,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
className="flex-1 bg-surface-card border border-line rounded-lg px-3 py-2 text-xs text-ink placeholder-ink-soft dark:bg-zinc-800 dark:border-zinc-600 dark:placeholder-zinc-500 focus:outline-none focus:border-accent focus-visible:ring-2 focus-visible:ring-accent/40 resize-none disabled:opacity-50"
/>
<button
onClick={handleSend}
onClick={sendMessage}
disabled={(!input.trim() && pendingFiles.length === 0) || !agentReachable || sending || uploading}
className="px-4 py-2 bg-accent-strong hover:bg-accent text-xs font-medium rounded-lg text-white disabled:opacity-30 transition-colors shrink-0"
>
@@ -1,3 +0,0 @@
export { useChatHistory } from "./useChatHistory";
export { useChatSend } from "./useChatSend";
export { useChatSocket } from "./useChatSocket";
@@ -1,11 +0,0 @@
"use client";
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
/** Resolve a workspace ID to its human-readable name.
* Falls back to the first 8 chars of the ID. */
export function resolveWorkspaceName(id: string): string {
const nodes = useCanvasStore.getState().nodes;
const node = nodes.find((n) => n.id === id);
return (node?.data as WorkspaceNodeData)?.name || id.slice(0, 8);
}
@@ -1,134 +0,0 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "@/lib/api";
import { type ChatMessage, appendMessageDeduped as appendMessageDedupedFn } from "../types";
const INITIAL_HISTORY_LIMIT = 10;
const OLDER_HISTORY_BATCH = 20;
async function loadMessagesFromDB(
workspaceId: string,
limit: number,
beforeTs?: string,
): Promise<{ messages: ChatMessage[]; error: string | null; reachedEnd: boolean }> {
try {
const params = new URLSearchParams({ limit: String(limit) });
if (beforeTs) params.set("before_ts", beforeTs);
const resp = await api.get<{ messages: ChatMessage[]; reached_end: boolean }>(
`/workspaces/${workspaceId}/chat-history?${params.toString()}`,
);
return {
messages: resp.messages ?? [],
error: null,
reachedEnd: resp.reached_end,
};
} catch (err) {
return {
messages: [],
error: err instanceof Error ? err.message : "Failed to load chat history",
reachedEnd: true,
};
}
}
export interface ScrollAnchor {
savedDistanceFromBottom: number;
expectFirstIdNotEqual: string | null;
}
export function useChatHistory(
workspaceId: string,
containerRef?: React.RefObject<HTMLDivElement | null>,
) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [loadingOlder, setLoadingOlder] = useState(false);
const [hasMore, setHasMore] = useState(true);
const fetchTokenRef = useRef(0);
const oldestMessageRef = useRef<ChatMessage | null>(null);
const hasMoreRef = useRef(true);
const inflightRef = useRef(false);
const scrollAnchorRef = useRef<ScrollAnchor | null>(null);
useEffect(() => {
oldestMessageRef.current = messages[0] ?? null;
}, [messages]);
useEffect(() => {
hasMoreRef.current = hasMore;
}, [hasMore]);
const loadInitial = useCallback(() => {
setLoading(true);
setLoadError(null);
setHasMore(true);
fetchTokenRef.current += 1;
const myToken = fetchTokenRef.current;
return loadMessagesFromDB(workspaceId, INITIAL_HISTORY_LIMIT).then(
({ messages: msgs, error: fetchErr, reachedEnd }) => {
if (fetchTokenRef.current !== myToken) return;
setMessages(msgs);
setLoadError(fetchErr);
setHasMore(!reachedEnd);
setLoading(false);
},
);
}, [workspaceId]);
useEffect(() => {
loadInitial();
}, [loadInitial]);
const loadOlder = useCallback(async () => {
if (inflightRef.current || !hasMoreRef.current) return;
const oldest = oldestMessageRef.current;
if (!oldest) return;
const container = containerRef?.current;
if (!container) return;
inflightRef.current = true;
scrollAnchorRef.current = {
savedDistanceFromBottom: container.scrollHeight - container.scrollTop,
expectFirstIdNotEqual: oldest.id,
};
fetchTokenRef.current += 1;
const myToken = fetchTokenRef.current;
setLoadingOlder(true);
try {
const { messages: older, reachedEnd } = await loadMessagesFromDB(
workspaceId,
OLDER_HISTORY_BATCH,
oldest.timestamp,
);
if (fetchTokenRef.current !== myToken) {
scrollAnchorRef.current = null;
return;
}
if (older.length > 0) {
setMessages((prev) => [...older, ...prev]);
} else {
scrollAnchorRef.current = null;
}
setHasMore(!reachedEnd);
} finally {
setLoadingOlder(false);
inflightRef.current = false;
}
}, [workspaceId, containerRef]);
return {
messages,
loading,
loadError,
loadingOlder,
hasMore,
loadInitial,
loadOlder,
appendMessageDeduped: (msg: ChatMessage) =>
setMessages((prev) => appendMessageDedupedFn(prev, msg)),
setMessages,
scrollAnchorRef,
};
}
@@ -1,182 +0,0 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { api } from "@/lib/api";
import { uploadChatFiles } from "../uploads";
import { createMessage, type ChatMessage, type ChatAttachment } from "../types";
import { extractFilesFromTask } from "../message-parser";
interface A2APart {
kind: string;
text?: string;
file?: {
name?: string;
mimeType?: string;
uri?: string;
size?: number;
};
}
interface A2AResponse {
result?: {
parts?: A2APart[];
artifacts?: Array<{ parts: A2APart[] }>;
};
}
export function extractReplyText(resp: A2AResponse): string {
const collect = (parts: A2APart[] | undefined): string => {
if (!parts) return "";
return parts
.filter((p) => p.kind === "text")
.map((p) => p.text ?? "")
.filter(Boolean)
.join("\n");
};
const result = resp?.result;
const collected: string[] = [];
const fromParts = collect(result?.parts);
if (fromParts) collected.push(fromParts);
if (result?.artifacts) {
for (const a of result.artifacts) {
const t = collect(a.parts);
if (t) collected.push(t);
}
}
return collected.join("\n");
}
export interface UseChatSendOptions {
getHistoryMessages: () => ChatMessage[];
onUserMessage?: (msg: ChatMessage) => void;
onAgentMessage?: (msg: ChatMessage) => void;
}
export function useChatSend(workspaceId: string, options: UseChatSendOptions) {
const [sending, setSending] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const sendInFlightRef = useRef(false);
const sendingFromAPIRef = useRef(false);
const sendTokenRef = useRef(0);
const optionsRef = useRef(options);
optionsRef.current = options;
const releaseSendGuards = useCallback(() => {
setSending(false);
sendingFromAPIRef.current = false;
sendInFlightRef.current = false;
}, []);
const clearError = useCallback(() => setError(null), []);
const sendMessage = useCallback(
async (text: string, files: File[] = []) => {
const trimmed = text.trim();
if ((!trimmed && files.length === 0) || sending || uploading) return;
if (sendInFlightRef.current) return;
sendInFlightRef.current = true;
let uploaded: ChatAttachment[] = [];
if (files.length > 0) {
setUploading(true);
try {
uploaded = await uploadChatFiles(workspaceId, files);
} catch (e) {
setUploading(false);
sendInFlightRef.current = false;
setError(
e instanceof Error ? `Upload failed: ${e.message}` : "Upload failed",
);
return;
}
setUploading(false);
}
const userMsg = createMessage("user", trimmed, uploaded);
optionsRef.current.onUserMessage?.(userMsg);
setSending(true);
sendingFromAPIRef.current = true;
setError(null);
const myToken = ++sendTokenRef.current;
const history = optionsRef.current
.getHistoryMessages()
.filter((m) => m.role === "user" || m.role === "agent")
.slice(-20)
.map((m) => ({
role: m.role === "user" ? "user" : "agent",
parts: [{ kind: "text", text: m.content }],
}));
const parts: A2APart[] = [];
if (trimmed) parts.push({ kind: "text", text: trimmed });
for (const att of uploaded) {
parts.push({
kind: "file",
file: {
name: att.name,
mimeType: att.mimeType,
uri: att.uri,
size: att.size,
},
});
}
api
.post<A2AResponse>(
`/workspaces/${workspaceId}/a2a`,
{
method: "message/send",
params: {
message: {
role: "user",
messageId: crypto.randomUUID(),
parts,
},
metadata: { history },
},
},
{ timeoutMs: 120_000 },
)
.then((resp) => {
if (sendTokenRef.current !== myToken) return;
if (!sendingFromAPIRef.current) {
sendInFlightRef.current = false;
return;
}
const replyText = extractReplyText(resp);
const replyFiles = extractFilesFromTask(
(resp?.result ?? {}) as Record<string, unknown>,
);
if (replyText || replyFiles.length > 0) {
optionsRef.current.onAgentMessage?.(
createMessage("agent", replyText, replyFiles),
);
}
releaseSendGuards();
})
.catch(() => {
if (sendTokenRef.current !== myToken) return;
if (!sendingFromAPIRef.current) {
sendInFlightRef.current = false;
return;
}
releaseSendGuards();
setError("Failed to send message — agent may be unreachable");
});
},
[workspaceId, sending, uploading],
);
return {
sending,
uploading,
sendMessage,
error,
clearError,
releaseSendGuards,
sendingFromAPIRef,
};
}
@@ -1,100 +0,0 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
import { useSocketEvent } from "@/hooks/useSocketEvent";
import { createMessage, type ChatMessage } from "../types";
export interface UseChatSocketCallbacks {
onAgentMessage?: (msg: ChatMessage) => void;
onActivityLog?: (entry: string) => void;
onSendComplete?: () => void;
onSendError?: (error: string) => void;
}
export function useChatSocket(
workspaceId: string,
callbacks: UseChatSocketCallbacks,
): void {
const callbacksRef = useRef(callbacks);
callbacksRef.current = callbacks;
// Agent push messages from global store
const pendingAgentMsgs = useCanvasStore((s) => s.agentMessages[workspaceId]);
useEffect(() => {
if (!pendingAgentMsgs || pendingAgentMsgs.length === 0) return;
const consume = useCanvasStore.getState().consumeAgentMessages;
const msgs = consume(workspaceId);
for (const m of msgs) {
callbacksRef.current.onAgentMessage?.(
createMessage("agent", m.content, m.attachments),
);
}
if (msgs.length > 0) {
callbacksRef.current.onSendComplete?.();
}
}, [pendingAgentMsgs, workspaceId]);
const resolveWorkspaceName = useCallback((id: string) => {
const nodes = useCanvasStore.getState().nodes;
const node = nodes.find((n) => n.id === id);
return (node?.data as WorkspaceNodeData)?.name || id.slice(0, 8);
}, []);
useSocketEvent((msg) => {
try {
if (msg.event === "ACTIVITY_LOGGED") {
if (msg.workspace_id !== workspaceId) return;
const p = msg.payload || {};
const type = p.activity_type as string;
const method = (p.method as string) || "";
const status = (p.status as string) || "";
const targetId = (p.target_id as string) || "";
const durationMs = p.duration_ms as number | undefined;
const summary = (p.summary as string) || "";
let line = "";
if (type === "a2a_receive" && method === "message/send") {
const targetName = resolveWorkspaceName(targetId || msg.workspace_id);
if (status === "ok" && durationMs) {
const sec = Math.round(durationMs / 1000);
line = `${targetName} responded (${sec}s)`;
const own = (targetId || msg.workspace_id) === workspaceId;
if (own) callbacksRef.current.onSendComplete?.();
} else if (status === "error") {
line = `${targetName} error`;
const own = (targetId || msg.workspace_id) === workspaceId;
if (own) {
callbacksRef.current.onSendComplete?.();
callbacksRef.current.onSendError?.(
"Agent error (Exception) — see workspace logs for details.",
);
}
}
} else if (type === "a2a_send") {
const targetName = resolveWorkspaceName(targetId);
line = `→ Delegating to ${targetName}...`;
} else if (type === "task_update") {
if (summary) line = `${summary}`;
} else if (type === "agent_log") {
if (summary) line = summary;
}
if (line) {
callbacksRef.current.onActivityLog?.(line);
}
} else if (
msg.event === "TASK_UPDATED" &&
msg.workspace_id === workspaceId
) {
const task = (msg.payload?.current_task as string) || "";
if (task) {
callbacksRef.current.onActivityLog?.(`${task}`);
}
}
} catch {
/* ignore */
}
});
}
-3
View File
@@ -1,5 +1,2 @@
export { type ChatMessage, createMessage, appendMessageDeduped } from "./types";
export { extractAgentText, extractTextsFromParts, extractResponseText } from "./message-parser";
export { useChatHistory } from "./hooks/useChatHistory";
export { useChatSend } from "./hooks/useChatSend";
export { useChatSocket } from "./hooks/useChatSocket";
@@ -14,18 +14,16 @@ import (
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/push"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type ActivityHandler struct {
broadcaster *events.Broadcaster
notifier *push.Notifier
}
func NewActivityHandler(b *events.Broadcaster, notifier *push.Notifier) *ActivityHandler {
return &ActivityHandler{broadcaster: b, notifier: notifier}
func NewActivityHandler(b *events.Broadcaster) *ActivityHandler {
return &ActivityHandler{broadcaster: b}
}
// List handles GET /workspaces/:id/activity?type=&source=&limit=&since_secs=&since_id=
@@ -478,7 +476,7 @@ func (h *ActivityHandler) Notify(c *gin.Context) {
for _, a := range body.Attachments {
attachments = append(attachments, AgentMessageAttachment(a))
}
writer := NewAgentMessageWriter(db.DB, h.broadcaster, h.notifier)
writer := NewAgentMessageWriter(db.DB, h.broadcaster)
if err := writer.Send(c.Request.Context(), workspaceID, body.Message, attachments); err != nil {
if errors.Is(err, ErrWorkspaceNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
@@ -40,7 +40,7 @@ func TestActivityHandler_SinceID_ReturnsNewerASC(t *testing.T) {
WillReturnRows(newActivityRows())
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -69,7 +69,7 @@ func TestActivityHandler_SinceID_CursorNotFound_410(t *testing.T) {
WillReturnError(sql.ErrNoRows)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -101,7 +101,7 @@ func TestActivityHandler_SinceID_CrossWorkspaceCursor_410(t *testing.T) {
WillReturnError(sql.ErrNoRows)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -137,7 +137,7 @@ func TestActivityHandler_SinceID_CombinedWithSinceSecs(t *testing.T) {
WillReturnRows(newActivityRows())
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -41,7 +41,7 @@ func TestActivityHandler_SinceSecs_Accepted(t *testing.T) {
WillReturnRows(newActivityRows())
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -70,7 +70,7 @@ func TestActivityHandler_SinceSecs_ClampedAt30Days(t *testing.T) {
WillReturnRows(newActivityRows())
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -106,7 +106,7 @@ func TestActivityHandler_SinceSecs_InvalidRejected(t *testing.T) {
// No DB call expected; bad input must be caught before the query.
setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -142,7 +142,7 @@ func TestActivityHandler_SinceSecs_Omitted(t *testing.T) {
WillReturnRows(newActivityRows())
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -22,7 +22,7 @@ func TestSessionSearchReturnsActivityAndMemory(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
rows := sqlmock.NewRows([]string{
"kind", "id", "workspace_id", "label", "content", "method", "status", "request_body", "response_body", "created_at",
@@ -68,7 +68,7 @@ func TestSessionSearchReturnsActivityAndMemory(t *testing.T) {
func TestActivityList_SourceCanvas(t *testing.T) {
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
// Expect query with "source_id IS NULL"
mock.ExpectQuery(`SELECT .+ FROM activity_logs WHERE workspace_id = .+ AND source_id IS NULL`).
@@ -97,7 +97,7 @@ func TestActivityList_SourceCanvas(t *testing.T) {
func TestActivityList_SourceAgent(t *testing.T) {
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
// Expect query with "source_id IS NOT NULL"
mock.ExpectQuery(`SELECT .+ FROM activity_logs WHERE workspace_id = .+ AND source_id IS NOT NULL`).
@@ -126,7 +126,7 @@ func TestActivityList_SourceAgent(t *testing.T) {
func TestActivityList_SourceInvalid(t *testing.T) {
gin.SetMode(gin.TestMode)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -142,7 +142,7 @@ func TestActivityList_SourceInvalid(t *testing.T) {
func TestActivityList_SourceWithType(t *testing.T) {
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
// Both type and source filters
mock.ExpectQuery(`SELECT .+ FROM activity_logs WHERE workspace_id = .+ AND activity_type = .+ AND source_id IS NULL`).
@@ -181,7 +181,7 @@ const testPeerUUID = "11111111-2222-3333-4444-555555555555"
func TestActivityList_PeerIDFilter(t *testing.T) {
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
// peer_id binds twice in the query (source_id OR target_id) but is
// added to args once — sqlmock matches positional args, so the
@@ -220,7 +220,7 @@ func TestActivityList_PeerIDComposesWithType(t *testing.T) {
// of the builder can't silently rearrange placeholders.
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
mock.ExpectQuery(
`SELECT .+ FROM activity_logs WHERE workspace_id = .+ AND activity_type = .+ AND source_id IS NOT NULL AND \(source_id = .+ OR target_id = .+\)`,
@@ -258,7 +258,7 @@ func TestActivityList_PeerIDRejectsNonUUID(t *testing.T) {
// otherwise interpolate the value into the URL or another query.
gin.SetMode(gin.TestMode)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
for _, bad := range []string{
"not-a-uuid",
@@ -292,7 +292,7 @@ func TestActivityList_PeerIDRejectsNonUUID(t *testing.T) {
func TestActivityList_BeforeTSFilter(t *testing.T) {
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
cutoff, _ := time.Parse(time.RFC3339, "2026-05-01T00:00:00Z")
mock.ExpectQuery(
@@ -328,7 +328,7 @@ func TestActivityList_BeforeTSComposesWithPeerID(t *testing.T) {
// can't silently drop one filter or reorder placeholders.
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
cutoff, _ := time.Parse(time.RFC3339, "2026-05-01T00:00:00Z")
mock.ExpectQuery(
@@ -363,7 +363,7 @@ func TestActivityList_BeforeTSComposesWithPeerID(t *testing.T) {
func TestActivityList_BeforeTSRejectsInvalidFormat(t *testing.T) {
gin.SetMode(gin.TestMode)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
for _, bad := range []string{
"yesterday",
@@ -400,7 +400,7 @@ func TestActivityReport_AcceptsMemoryWriteType(t *testing.T) {
WillReturnResult(sqlmock.NewResult(1, 1))
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
@@ -426,7 +426,7 @@ func TestActivityReport_RejectsUnknownType(t *testing.T) {
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
@@ -478,7 +478,7 @@ func TestNotify_PersistsToActivityLogsForReloadRecovery(t *testing.T) {
WillReturnResult(sqlmock.NewResult(1, 1))
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
@@ -527,7 +527,7 @@ func TestNotify_WithAttachments_PersistsFilePartsForReload(t *testing.T) {
WillReturnResult(sqlmock.NewResult(1, 1))
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
@@ -593,7 +593,7 @@ func TestNotify_RejectsAttachmentWithEmptyURIOrName(t *testing.T) {
// only if the handler unexpectedly queries.
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -647,7 +647,7 @@ func TestNotify_DBFailure_StillBroadcastsAnd200(t *testing.T) {
WillReturnError(fmt.Errorf("simulated db hiccup"))
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
@@ -44,7 +44,6 @@ import (
"log"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/push"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/textutil"
)
@@ -77,14 +76,12 @@ type AgentMessageAttachment struct {
type AgentMessageWriter struct {
db *sql.DB
broadcaster events.EventEmitter
notifier *push.Notifier
}
// NewAgentMessageWriter binds the writer to the platform's DB pool +
// WebSocket broadcaster. notifier may be nil if push notifications are
// not configured.
func NewAgentMessageWriter(db *sql.DB, broadcaster events.EventEmitter, notifier *push.Notifier) *AgentMessageWriter {
return &AgentMessageWriter{db: db, broadcaster: broadcaster, notifier: notifier}
// WebSocket broadcaster.
func NewAgentMessageWriter(db *sql.DB, broadcaster events.EventEmitter) *AgentMessageWriter {
return &AgentMessageWriter{db: db, broadcaster: broadcaster}
}
// Send delivers a single agent → user message. Look up + broadcast +
@@ -135,12 +132,7 @@ func (w *AgentMessageWriter) Send(
}
w.broadcaster.BroadcastOnly(workspaceID, string(events.EventAgentMessage), broadcastPayload)
// 3. Send push notifications to mobile devices.
if w.notifier != nil {
w.notifier.NotifyAgentMessage(ctx, workspaceID, wsName, message)
}
// 4. Persist for chat-history hydration. response_body shape MUST stay
// 3. Persist for chat-history hydration. response_body shape MUST stay
// in sync with extractResponseText + extractFilesFromTask in
// canvas/src/components/tabs/chat/historyHydration.ts:
// - extractResponseText reads body.result (string) → renders text
@@ -86,7 +86,7 @@ func (c *capturingEmitter) RecordAndBroadcast(_ context.Context, eventType strin
// path: workspace lookup, broadcast, INSERT, return nil.
func TestAgentMessageWriter_Send_Success_NoAttachments(t *testing.T) {
mock := setupTestDB(t)
w := NewAgentMessageWriter(db.DB, newTestBroadcaster(), nil)
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
mock.ExpectQuery("SELECT name FROM workspaces").
WithArgs("ws-1").
@@ -114,7 +114,7 @@ func TestAgentMessageWriter_Send_Success_NoAttachments(t *testing.T) {
// Drift here = chips disappear on chat reload.
func TestAgentMessageWriter_Send_Success_WithAttachments(t *testing.T) {
mock := setupTestDB(t)
w := NewAgentMessageWriter(db.DB, newTestBroadcaster(), nil)
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
mock.ExpectQuery("SELECT name FROM workspaces").
WithArgs("ws-att").
@@ -171,7 +171,7 @@ func TestAgentMessageWriter_Send_Success_WithAttachments(t *testing.T) {
func TestAgentMessageWriter_Send_WorkspaceNotFound(t *testing.T) {
mock := setupTestDB(t)
emitter := &capturingEmitter{}
w := NewAgentMessageWriter(db.DB, emitter, nil)
w := NewAgentMessageWriter(db.DB, emitter)
mock.ExpectQuery("SELECT name FROM workspaces").
WithArgs("ws-missing").
@@ -200,7 +200,7 @@ func TestAgentMessageWriter_Send_WorkspaceNotFound(t *testing.T) {
// broadcast.
func TestAgentMessageWriter_Send_DBInsertFailureStillReturnsNil(t *testing.T) {
mock := setupTestDB(t)
w := NewAgentMessageWriter(db.DB, newTestBroadcaster(), nil)
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
mock.ExpectQuery("SELECT name FROM workspaces").
WithArgs("ws-dbfail").
@@ -221,7 +221,7 @@ func TestAgentMessageWriter_Send_DBInsertFailureStillReturnsNil(t *testing.T) {
// table doesn't carry multi-KB summaries that bloat list queries.
func TestAgentMessageWriter_Send_PreviewTruncation(t *testing.T) {
mock := setupTestDB(t)
w := NewAgentMessageWriter(db.DB, newTestBroadcaster(), nil)
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
mock.ExpectQuery("SELECT name FROM workspaces").
WithArgs("ws-trunc").
@@ -261,7 +261,7 @@ func TestAgentMessageWriter_Send_PreviewTruncation(t *testing.T) {
func TestAgentMessageWriter_Send_BroadcastsAgentMessageEvent(t *testing.T) {
mock := setupTestDB(t)
emitter := &capturingEmitter{}
w := NewAgentMessageWriter(db.DB, emitter, nil)
w := NewAgentMessageWriter(db.DB, emitter)
mock.ExpectQuery("SELECT name FROM workspaces").
WithArgs("ws-bc").
@@ -312,7 +312,7 @@ func TestAgentMessageWriter_Send_BroadcastsAgentMessageEvent(t *testing.T) {
// real incidents in alerting.
func TestAgentMessageWriter_Send_DBErrorOnLookupReturnsWrapped(t *testing.T) {
mock := setupTestDB(t)
w := NewAgentMessageWriter(db.DB, newTestBroadcaster(), nil)
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
transientErr := errors.New("connection refused")
mock.ExpectQuery("SELECT name FROM workspaces").
@@ -344,7 +344,7 @@ func TestAgentMessageWriter_Send_DBErrorOnLookupReturnsWrapped(t *testing.T) {
// coverage. Now it does.
func TestAgentMessageWriter_Send_NonASCIIMessagePersists(t *testing.T) {
mock := setupTestDB(t)
w := NewAgentMessageWriter(db.DB, newTestBroadcaster(), nil)
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
// 200-rune CJK message — exceeds the 80-rune cap, would have hit
// the byte-slice bug.
@@ -393,7 +393,7 @@ func TestAgentMessageWriter_Send_NonASCIIMessagePersists(t *testing.T) {
func TestAgentMessageWriter_Send_OmitsAttachmentsKeyWhenEmpty(t *testing.T) {
mock := setupTestDB(t)
emitter := &capturingEmitter{}
w := NewAgentMessageWriter(db.DB, emitter, nil)
w := NewAgentMessageWriter(db.DB, emitter)
mock.ExpectQuery("SELECT name FROM workspaces").
WithArgs("ws-noatt").
@@ -646,7 +646,7 @@ func TestActivityHandler_List(t *testing.T) {
WillReturnRows(rows)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -695,7 +695,7 @@ func TestActivityHandler_ListByType(t *testing.T) {
WillReturnRows(rows)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -723,7 +723,7 @@ func TestActivityHandler_Report(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
// Expect the INSERT into activity_logs
mock.ExpectExec("INSERT INTO activity_logs").
@@ -752,7 +752,7 @@ func TestActivityHandler_Report_InvalidType(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -980,7 +980,7 @@ func TestActivityHandler_ListEmpty(t *testing.T) {
WillReturnRows(sqlmock.NewRows(columns))
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -1014,7 +1014,7 @@ func TestActivityHandler_ListCustomLimit(t *testing.T) {
WillReturnRows(sqlmock.NewRows(columns))
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -1047,7 +1047,7 @@ func TestActivityHandler_ListMaxLimit(t *testing.T) {
WillReturnRows(sqlmock.NewRows(columns))
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -1075,7 +1075,7 @@ func TestActivityHandler_ReportAllValidTypes(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
mock.ExpectExec("INSERT INTO activity_logs").
WillReturnResult(sqlmock.NewResult(0, 1))
@@ -1106,7 +1106,7 @@ func TestActivityHandler_ReportAllValidTypes(t *testing.T) {
func TestActivityHandler_ReportMissingBody(t *testing.T) {
setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -1179,7 +1179,7 @@ func TestActivityHandler_Report_SourceIDSpoofRejected(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -1202,7 +1202,7 @@ func TestActivityHandler_Report_MatchingSourceIDAccepted(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
mock.ExpectExec("INSERT INTO activity_logs").
WillReturnResult(sqlmock.NewResult(0, 1))
@@ -1232,7 +1232,7 @@ func TestActivityHandler_Report_SourceIDLogInjection(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster, nil)
handler := NewActivityHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
+2 -5
View File
@@ -34,7 +34,6 @@ import (
"time"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/push"
"github.com/gin-gonic/gin"
)
@@ -85,7 +84,6 @@ type mcpTool struct {
type MCPHandler struct {
database *sql.DB
broadcaster *events.Broadcaster
notifier *push.Notifier
// memv2 is the v2 memory plugin wiring (RFC #2728). nil-safe:
// every v2 tool calls memoryV2Available() first and returns a
@@ -96,9 +94,8 @@ type MCPHandler struct {
// NewMCPHandler wires the handler to db and broadcaster.
// Pass db.DB and the platform broadcaster at router-setup time.
// notifier may be nil if push notifications are not configured.
func NewMCPHandler(database *sql.DB, broadcaster *events.Broadcaster, notifier *push.Notifier) *MCPHandler {
return &MCPHandler{database: database, broadcaster: broadcaster, notifier: notifier}
func NewMCPHandler(database *sql.DB, broadcaster *events.Broadcaster) *MCPHandler {
return &MCPHandler{database: database, broadcaster: broadcaster}
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -26,7 +26,7 @@ import (
func newMCPHandler(t *testing.T) (*MCPHandler, sqlmock.Sqlmock) {
t.Helper()
mock := setupTestDB(t)
h := NewMCPHandler(db.DB, newTestBroadcaster(), nil)
h := NewMCPHandler(db.DB, newTestBroadcaster())
return h, mock
}
@@ -392,7 +392,7 @@ func (h *MCPHandler) toolSendMessageToUser(ctx context.Context, workspaceID stri
// (the tool args don't accept them); pass nil. If a future tool
// schema adds an attachments arg, build []AgentMessageAttachment
// and pass through.
writer := NewAgentMessageWriter(h.database, h.broadcaster, h.notifier)
writer := NewAgentMessageWriter(h.database, h.broadcaster)
if err := writer.Send(ctx, workspaceID, message, nil); err != nil {
if errors.Is(err, ErrWorkspaceNotFound) {
return "", fmt.Errorf("workspace not found")
@@ -15,6 +15,7 @@ import (
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
"gopkg.in/yaml.v3"
)
// logProvisionPanic is the deferred recover at the top of every provision
@@ -472,9 +473,10 @@ func configDirName(workspaceID string) string {
// runtime means bumping both this list and the Docker image tags.
// knownRuntimes is populated from manifest.json at service init (see
// runtime_registry.go). The package init order is:
// 1. var knownRuntimes = fallbackRuntimes
// 2. init() calls initKnownRuntimes() which replaces it if
// manifest.json is readable.
// 1. var knownRuntimes = fallbackRuntimes
// 2. init() calls initKnownRuntimes() which replaces it if
// manifest.json is readable.
//
// The fallback matters for unit tests that don't mount the manifest.
//
// "external" is a first-class runtime that intentionally does NOT
@@ -539,6 +541,9 @@ func (h *WorkspaceHandler) ensureDefaultConfig(workspaceID string, payload model
// org_import.go; consolidating prevents silent drift.
model = models.DefaultModel(runtime)
}
if runtime == "claude-code" {
model = normalizeClaudeCodeModel(model)
}
// Sanitize name/role/model for YAML safety — always double-quote so
// a crafted value with a newline or colon can't terminate the scalar
@@ -554,6 +559,11 @@ func (h *WorkspaceHandler) ensureDefaultConfig(workspaceID string, payload model
quoteModel := yamlQuote(model)
configYAML := fmt.Sprintf("name: %s\ndescription: %s\nversion: 1.0.0\ntier: %d\nruntime: %s\n",
quoteName, quoteRole, payload.Tier, runtime)
if runtime == "claude-code" {
if providersYAML := h.defaultTemplateProvidersYAML(runtime); providersYAML != "" {
configYAML += providersYAML + "\n"
}
}
// Model always at top level — config.py reads raw["model"] for all runtimes.
configYAML += fmt.Sprintf("model: %s\n", quoteModel)
@@ -563,7 +573,11 @@ func (h *WorkspaceHandler) ensureDefaultConfig(workspaceID string, payload model
// and preflight already validates that the env vars are present before
// the agent loop starts. Hardcoding token names here caused #1028
// (expired CLAUDE_CODE_OAUTH_TOKEN baked into config.yaml).
configYAML += "runtime_config:\n timeout: 0\n"
configYAML += "runtime_config:\n"
if runtime == "claude-code" {
configYAML += fmt.Sprintf(" model: %s\n", quoteModel)
}
configYAML += " timeout: 0\n"
files["config.yaml"] = []byte(configYAML)
@@ -571,6 +585,60 @@ func (h *WorkspaceHandler) ensureDefaultConfig(workspaceID string, payload model
return files
}
func normalizeClaudeCodeModel(model string) string {
model = strings.TrimSpace(model)
if before, after, ok := strings.Cut(model, "/"); ok && before != "" && after != "" {
return after
}
return model
}
func (h *WorkspaceHandler) defaultTemplateProvidersYAML(runtime string) string {
if h.configsDir == "" {
return ""
}
templateName := runtime + "-default"
templatePath, err := resolveInsideRoot(h.configsDir, templateName)
if err != nil {
log.Printf("Provisioner: default template providers skipped for runtime %s: %v", runtime, err)
return ""
}
data, err := os.ReadFile(filepath.Join(templatePath, "config.yaml"))
if err != nil {
return ""
}
var root yaml.Node
if err := yaml.Unmarshal(data, &root); err != nil {
log.Printf("Provisioner: default template providers skipped for runtime %s: invalid YAML: %v", runtime, err)
return ""
}
if len(root.Content) == 0 || root.Content[0].Kind != yaml.MappingNode {
return ""
}
mapping := root.Content[0]
for i := 0; i+1 < len(mapping.Content); i += 2 {
if mapping.Content[i].Value != "providers" {
continue
}
out := yaml.Node{
Kind: yaml.MappingNode,
Content: []*yaml.Node{
{Kind: yaml.ScalarNode, Value: "providers"},
mapping.Content[i+1],
},
}
encoded, err := yaml.Marshal(&out)
if err != nil {
log.Printf("Provisioner: default template providers skipped for runtime %s: marshal failed: %v", runtime, err)
return ""
}
return strings.TrimRight(string(encoded), "\n")
}
return ""
}
// deriveProviderFromModelSlug maps a hermes-agent model slug prefix to
// its provider name — a Go translation of the case statement in
// workspace-configs-templates/hermes/scripts/derive-provider.sh that we
@@ -261,6 +261,67 @@ func TestEnsureDefaultConfig_ClaudeCode(t *testing.T) {
}
}
func TestEnsureDefaultConfig_ClaudeCodeCopiesProviderRegistry(t *testing.T) {
broadcaster := newTestBroadcaster()
configsDir := t.TempDir()
templateDir := filepath.Join(configsDir, "claude-code-default")
if err := os.MkdirAll(templateDir, 0o755); err != nil {
t.Fatalf("mkdir template: %v", err)
}
if err := os.WriteFile(filepath.Join(templateDir, "config.yaml"), []byte(`
name: Claude Code Agent
runtime: claude-code
providers:
- name: anthropic-oauth
auth_mode: oauth
model_aliases: [sonnet]
auth_env: [CLAUDE_CODE_OAUTH_TOKEN]
- name: minimax
auth_mode: third_party_anthropic_compat
model_prefixes: [minimax-]
base_url: https://api.minimax.io/anthropic
auth_env: [MINIMAX_API_KEY, ANTHROPIC_AUTH_TOKEN]
runtime_config:
model: sonnet
`), 0o644); err != nil {
t.Fatalf("write template: %v", err)
}
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", configsDir)
files := handler.ensureDefaultConfig("ws-code-123", models.CreateWorkspacePayload{
Name: "Code Agent",
Tier: 4,
Runtime: "claude-code",
Model: "minimax/MiniMax-M2.7",
})
var parsed struct {
Model string `yaml:"model"`
Providers []struct {
Name string `yaml:"name"`
ModelPrefixes []string `yaml:"model_prefixes"`
} `yaml:"providers"`
RuntimeConfig struct {
Model string `yaml:"model"`
} `yaml:"runtime_config"`
}
if err := yaml.Unmarshal(files["config.yaml"], &parsed); err != nil {
t.Fatalf("generated YAML invalid: %v\n%s", err, files["config.yaml"])
}
if parsed.Model != "MiniMax-M2.7" {
t.Fatalf("top-level model = %q, want MiniMax-M2.7\n%s", parsed.Model, files["config.yaml"])
}
if parsed.RuntimeConfig.Model != "MiniMax-M2.7" {
t.Fatalf("runtime_config.model = %q, want MiniMax-M2.7\n%s", parsed.RuntimeConfig.Model, files["config.yaml"])
}
if len(parsed.Providers) != 2 {
t.Fatalf("providers len = %d, want 2\n%s", len(parsed.Providers), files["config.yaml"])
}
if parsed.Providers[1].Name != "minimax" || len(parsed.Providers[1].ModelPrefixes) != 1 || parsed.Providers[1].ModelPrefixes[0] != "minimax-" {
t.Fatalf("minimax provider registry not preserved: %+v\n%s", parsed.Providers, files["config.yaml"])
}
}
func TestEnsureDefaultConfig_CustomModel(t *testing.T) {
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
@@ -207,7 +207,7 @@ func setupSwapEnv(t *testing.T) (*handlers.MCPHandler, *flatPlugin, sqlmock.Sqlm
resolver := namespace.New(db)
// MCPHandler needs a real *sql.DB; pass the sqlmock-backed one.
h := handlers.NewMCPHandler(db, nil, nil).WithMemoryV2(cl, resolver)
h := handlers.NewMCPHandler(db, nil).WithMemoryV2(cl, resolver)
return h, plugin, mock
}
@@ -430,7 +430,7 @@ func TestE2E_PluginUnreachable_AgentSeesClearError(t *testing.T) {
db, _, _ := sqlmock.New()
defer db.Close()
resolver := namespace.New(db)
h := handlers.NewMCPHandler(db, nil, nil).WithMemoryV2(cl, resolver)
h := handlers.NewMCPHandler(db, nil).WithMemoryV2(cl, resolver)
_, err := h.Dispatch(context.Background(), "root-1", "commit_memory_v2", map[string]interface{}{
"content": "x",
-75
View File
@@ -1,75 +0,0 @@
package push
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Handler exposes HTTP endpoints for push-token management.
type Handler struct {
repo *Repo
}
// NewHandler creates a push-token HTTP handler.
func NewHandler(repo *Repo) *Handler {
return &Handler{repo: repo}
}
// RegisterRoutes mounts push-token routes on the given router group.
func (h *Handler) RegisterRoutes(rg *gin.RouterGroup) {
rg.POST("/push-tokens", h.Create)
rg.DELETE("/push-tokens", h.Delete)
}
// Create handles POST /push-tokens.
// Body: { "token": "ExponentPushToken[xxx]", "platform": "ios" | "android" }
func (h *Handler) Create(c *gin.Context) {
workspaceID := c.Param("id")
if _, err := uuid.Parse(workspaceID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace id"})
return
}
var body struct {
Token string `json:"token" binding:"required"`
Platform string `json:"platform" binding:"required,oneof=ios android"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.repo.SaveToken(c.Request.Context(), workspaceID, body.Token, body.Platform); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save token"})
return
}
c.Status(http.StatusNoContent)
}
// Delete handles DELETE /push-tokens.
// Body: { "token": "ExponentPushToken[xxx]" }
func (h *Handler) Delete(c *gin.Context) {
workspaceID := c.Param("id")
if _, err := uuid.Parse(workspaceID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace id"})
return
}
var body struct {
Token string `json:"token" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.repo.DeleteToken(c.Request.Context(), workspaceID, body.Token); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete token"})
return
}
c.Status(http.StatusNoContent)
}
-102
View File
@@ -1,102 +0,0 @@
package push
import (
"context"
"database/sql"
"log"
"os"
"time"
)
// Notifier sends push notifications for agent messages.
type Notifier struct {
repo *Repo
sender *Sender
}
// NewNotifier creates a Notifier.
func NewNotifier(db *sql.DB, sender *Sender) *Notifier {
return &Notifier{
repo: NewRepo(db),
sender: sender,
}
}
// NotifyAgentMessage sends a push notification to all registered devices for a
// workspace when an agent sends a message. It runs asynchronously (fire-and-
// forget) so the caller's WebSocket broadcast is never blocked.
func (n *Notifier) NotifyAgentMessage(ctx context.Context, workspaceID, workspaceName, message string) {
if n == nil || n.sender == nil {
return
}
// Capture values for the goroutine.
wsID := workspaceID
wsName := workspaceName
msg := message
go func() {
// Use a fresh context with timeout so a slow Expo API doesn't
// leak the caller's context deadline.
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
tokens, err := n.repo.GetTokens(ctx, wsID)
if err != nil {
log.Printf("push: failed to get tokens for workspace %s: %v", wsID, err)
return
}
if len(tokens) == 0 {
return
}
// Expo accepts batches of up to ~100 messages; we cap lower to stay
// well under the limit.
const batchSize = 50
for i := 0; i < len(tokens); i += batchSize {
end := i + batchSize
if end > len(tokens) {
end = len(tokens)
}
batch := tokens[i:end]
messages := make([]Message, 0, len(batch))
for _, t := range batch {
messages = append(messages, Message{
To: t.Token,
Title: wsName,
Body: truncate(msg, 100),
Data: map[string]string{
"type": "agent_message",
"workspaceId": wsID,
"workspaceSlug": os.Getenv("MOLECULE_ORG_SLUG"),
},
Sound: "default",
Priority: "high",
})
}
results, err := n.sender.Send(ctx, messages)
if err != nil {
log.Printf("push: send failed for workspace %s: %v", wsID, err)
continue
}
// Remove invalid tokens.
for j, r := range results {
if ShouldRemoveToken(r) {
if delErr := n.repo.DeleteToken(ctx, wsID, batch[j].Token); delErr != nil {
log.Printf("push: failed to delete invalid token for workspace %s: %v", wsID, delErr)
}
}
}
}
}()
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "…"
}
-159
View File
@@ -1,159 +0,0 @@
package push
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSenderSend(t *testing.T) {
gin.SetMode(gin.TestMode)
expoResponse := map[string]interface{}{
"data": []map[string]interface{}{
{"status": "ok", "id": "abc123"},
{"status": "error", "message": "Invalid token", "details": map[string]string{"error": "DeviceNotRegistered"}},
},
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
var msgs []Message
require.NoError(t, json.NewDecoder(r.Body).Decode(&msgs))
assert.Len(t, msgs, 2)
assert.Equal(t, "ExponentPushToken[test1]", msgs[0].To)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(expoResponse)
}))
defer server.Close()
sender := NewSender("")
sender.apiURL = server.URL
results, err := sender.Send(context.Background(), []Message{
{To: "ExponentPushToken[test1]", Title: "Test", Body: "Hello"},
{To: "ExponentPushToken[test2]", Title: "Test", Body: "World"},
})
require.NoError(t, err)
require.Len(t, results, 2)
assert.Equal(t, "ok", results[0].Status)
assert.Equal(t, "error", results[1].Status)
assert.True(t, ShouldRemoveToken(results[1]))
}
func TestSenderSendEmpty(t *testing.T) {
sender := NewSender("")
results, err := sender.Send(context.Background(), nil)
require.NoError(t, err)
assert.Nil(t, results)
}
func TestHandlerCreate(t *testing.T) {
gin.SetMode(gin.TestMode)
db, mock, err := sqlmock.New()
require.NoError(t, err)
defer db.Close()
mock.ExpectExec("INSERT INTO push_tokens").
WithArgs("11111111-1111-1111-1111-111111111111", "ExponentPushToken[abc]", "ios").
WillReturnResult(sqlmock.NewResult(1, 1))
repo := NewRepo(db)
handler := NewHandler(repo)
router := gin.New()
group := router.Group("/workspaces/:id")
handler.RegisterRoutes(group)
w := httptest.NewRecorder()
body := `{"token":"ExponentPushToken[abc]","platform":"ios"}`
req, _ := http.NewRequest("POST", "/workspaces/11111111-1111-1111-1111-111111111111/push-tokens", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNoContent, w.Code)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestHandlerCreateInvalidPlatform(t *testing.T) {
gin.SetMode(gin.TestMode)
db, _, err := sqlmock.New()
require.NoError(t, err)
defer db.Close()
handler := NewHandler(NewRepo(db))
router := gin.New()
group := router.Group("/workspaces/:id")
handler.RegisterRoutes(group)
w := httptest.NewRecorder()
body := `{"token":"ExponentPushToken[abc]","platform":"windows"}`
req, _ := http.NewRequest("POST", "/workspaces/11111111-1111-1111-1111-111111111111/push-tokens", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestHandlerDelete(t *testing.T) {
gin.SetMode(gin.TestMode)
db, mock, err := sqlmock.New()
require.NoError(t, err)
defer db.Close()
mock.ExpectExec("DELETE FROM push_tokens").
WithArgs("22222222-2222-2222-2222-222222222222", "ExponentPushToken[del]").
WillReturnResult(sqlmock.NewResult(0, 1))
repo := NewRepo(db)
handler := NewHandler(repo)
router := gin.New()
group := router.Group("/workspaces/:id")
handler.RegisterRoutes(group)
w := httptest.NewRecorder()
body := `{"token":"ExponentPushToken[del]"}`
req, _ := http.NewRequest("DELETE", "/workspaces/22222222-2222-2222-2222-222222222222/push-tokens", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNoContent, w.Code)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestRepoGetTokens(t *testing.T) {
db, mock, err := sqlmock.New()
require.NoError(t, err)
defer db.Close()
mock.ExpectQuery("SELECT id, workspace_id, token, platform, created_at FROM push_tokens").
WithArgs("ws-1").
WillReturnRows(sqlmock.NewRows([]string{"id", "workspace_id", "token", "platform", "created_at"}).
AddRow("1", "ws-1", "ExponentPushToken[a]", "ios", "2026-01-01T00:00:00Z").
AddRow("2", "ws-1", "ExponentPushToken[b]", "android", "2026-01-01T00:00:00Z"))
repo := NewRepo(db)
tokens, err := repo.GetTokens(context.Background(), "ws-1")
require.NoError(t, err)
require.Len(t, tokens, 2)
assert.Equal(t, "ExponentPushToken[a]", tokens[0].Token)
assert.Equal(t, "ios", tokens[0].Platform)
assert.Equal(t, "ExponentPushToken[b]", tokens[1].Token)
require.NoError(t, mock.ExpectationsWereMet())
}
-76
View File
@@ -1,76 +0,0 @@
package push
import (
"context"
"database/sql"
"fmt"
)
// Token is one registered push token for a workspace.
type Token struct {
ID string
WorkspaceID string
Token string
Platform string
CreatedAt string
}
// Repo reads and writes push tokens in Postgres.
type Repo struct {
db *sql.DB
}
// NewRepo creates a token repository backed by db.
func NewRepo(db *sql.DB) *Repo {
return &Repo{db: db}
}
// SaveToken registers a push token for a workspace. If the same token already
// exists for the workspace, it updates the timestamp.
func (r *Repo) SaveToken(ctx context.Context, workspaceID, token, platform string) error {
_, err := r.db.ExecContext(ctx, `
INSERT INTO push_tokens (workspace_id, token, platform)
VALUES ($1, $2, $3)
ON CONFLICT (workspace_id, token) DO UPDATE
SET updated_at = now()
`, workspaceID, token, platform)
if err != nil {
return fmt.Errorf("push_tokens: save: %w", err)
}
return nil
}
// DeleteToken removes a push token. Returns nil even if the token did not exist.
func (r *Repo) DeleteToken(ctx context.Context, workspaceID, token string) error {
_, err := r.db.ExecContext(ctx, `
DELETE FROM push_tokens
WHERE workspace_id = $1 AND token = $2
`, workspaceID, token)
if err != nil {
return fmt.Errorf("push_tokens: delete: %w", err)
}
return nil
}
// GetTokens returns all active push tokens for a workspace.
func (r *Repo) GetTokens(ctx context.Context, workspaceID string) ([]Token, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, workspace_id, token, platform, created_at
FROM push_tokens
WHERE workspace_id = $1
`, workspaceID)
if err != nil {
return nil, fmt.Errorf("push_tokens: list: %w", err)
}
defer rows.Close()
var tokens []Token
for rows.Next() {
var t Token
if err := rows.Scan(&t.ID, &t.WorkspaceID, &t.Token, &t.Platform, &t.CreatedAt); err != nil {
return nil, fmt.Errorf("push_tokens: scan: %w", err)
}
tokens = append(tokens, t)
}
return tokens, rows.Err()
}
-104
View File
@@ -1,104 +0,0 @@
package push
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
const expoPushAPI = "https://exp.host/--/api/v2/push/send"
// Message is one Expo push notification.
type Message struct {
To string `json:"to"`
Title string `json:"title,omitempty"`
Body string `json:"body,omitempty"`
Data map[string]string `json:"data,omitempty"`
Sound string `json:"sound,omitempty"`
Priority string `json:"priority,omitempty"`
}
// Sender delivers push notifications via the Expo Push Service.
type Sender struct {
apiURL string
httpClient *http.Client
expoToken string // optional Expo access token for authenticated requests
}
// NewSender creates a Sender. expoToken may be empty for unauthenticated
// requests (sufficient for most use cases).
func NewSender(expoToken string) *Sender {
return &Sender{
apiURL: expoPushAPI,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
expoToken: expoToken,
}
}
// SendResult is the per-recipient status from Expo.
type SendResult struct {
Status string `json:"status"`
ID string `json:"id"`
Message string `json:"message,omitempty"`
Details struct {
Error string `json:"error,omitempty"`
} `json:"details,omitempty"`
}
// expoResponse is the wrapper shape returned by the Expo API.
type expoResponse struct {
Data []SendResult `json:"data"`
}
// Send fires a batch of push messages. It returns a slice of results in the
// same order as the input, plus an error only when the HTTP call itself fails.
// Callers should inspect each result's Status field for per-message errors
// (e.g. "DeviceNotRegistered" → token should be deleted).
func (s *Sender) Send(ctx context.Context, messages []Message) ([]SendResult, error) {
if len(messages) == 0 {
return nil, nil
}
body, err := json.Marshal(messages)
if err != nil {
return nil, fmt.Errorf("push: marshal: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.apiURL, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("push: new request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("Accept-Encoding", "gzip, deflate")
if s.expoToken != "" {
req.Header.Set("Authorization", "Bearer "+s.expoToken)
}
res, err := s.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("push: post: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("push: expo returned %d", res.StatusCode)
}
var resp expoResponse
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
return nil, fmt.Errorf("push: decode: %w", err)
}
return resp.Data, nil
}
// ShouldRemoveToken reports whether a SendResult indicates the token is no
// longer valid and should be deleted from the database.
func ShouldRemoveToken(r SendResult) bool {
return r.Status == "error" && r.Details.Error == "DeviceNotRegistered"
}
+2 -15
View File
@@ -20,7 +20,6 @@ import (
"github.com/Molecule-AI/molecule-monorepo/platform/internal/pendinguploads"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/plugins"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/push"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/supervised"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/ws"
"github.com/docker/docker/client"
@@ -319,25 +318,13 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
// Remaining auth-gated workspace sub-routes — appended to wsAuth group declared above.
{
// Push notifications (mobile)
var pushNotifier *push.Notifier
if expoToken := os.Getenv("EXPO_ACCESS_TOKEN"); expoToken != "" {
pushNotifier = push.NewNotifier(db.DB, push.NewSender(expoToken))
}
// Activity Logs
acth := handlers.NewActivityHandler(broadcaster, pushNotifier)
acth := handlers.NewActivityHandler(broadcaster)
wsAuth.GET("/activity", acth.List)
wsAuth.GET("/session-search", acth.SessionSearch)
wsAuth.POST("/activity", acth.Report)
wsAuth.POST("/notify", acth.Notify)
// Push token registration (mobile)
if pushNotifier != nil {
pushH := push.NewHandler(push.NewRepo(db.DB))
pushH.RegisterRoutes(wsAuth)
}
// Chat history — RFC #2945 PR-C (issue #3017) + PR-D (issue
// #3026). Server-side rendering of activity_logs rows into
// the canonical ChatMessage shape; storage is plugin-shaped
@@ -441,7 +428,7 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
// opencode session cannot saturate the platform.
// C3: commit_memory/recall_memory with scope=GLOBAL → permission error;
// send_message_to_user excluded unless MOLECULE_MCP_ALLOW_SEND_MESSAGE=true.
mcpH := handlers.NewMCPHandler(db.DB, broadcaster, pushNotifier)
mcpH := handlers.NewMCPHandler(db.DB, broadcaster)
if memBundle != nil {
mcpH.WithMemoryV2(memBundle.Plugin, memBundle.Resolver)
}
@@ -1 +0,0 @@
DROP TABLE IF EXISTS push_tokens;
@@ -1,11 +0,0 @@
CREATE TABLE push_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
token TEXT NOT NULL,
platform TEXT NOT NULL CHECK (platform IN ('ios', 'android')),
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(workspace_id, token)
);
CREATE INDEX idx_push_tokens_workspace ON push_tokens(workspace_id);