Compare commits

...

67 Commits

Author SHA1 Message Date
core-be e3ea8ff74a [core-be-agent]
sop-tier-check / tier-check (pull_request) Failing after 4s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
audit-force-merge / audit (pull_request) Has been skipped
fix(plugins/test): skip TestLocalResolver_BubblesUpCopyFailure when running as root

Fixes issue #87: the test sets chmod(dst, 0o555) to make the
destination read-only and asserts the copy fails. On Linux, root
bypasses filesystem permissions and can write to 0o555 directories,
so the copy succeeds when running as root and the assertion fails.

Fix: check os.Getuid() == 0 at the start of the test and skip with
a clear message. Mirrors the existing skip in
TestLocalResolver_CopyFileSourceUnreadable (line 175) which already
handles the same root-bypass issue for unreadable source files.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 22:21:35 +00:00
core-lead 449a49f31a Merge pull request '[core-be-agent] fix(tests): clear platform_auth cache before each test' (#181) from fix/workspace-tests-clear-auth-cache into main
Secret scan / Scan diff for credential-shaped strings (push) Successful in 4s
2026-05-09 22:19:35 +00:00
Molecule AI Core Platform Lead 0183fe66cb Merge remote-tracking branch 'origin/main' into trig-181
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Successful in 4s
audit-force-merge / audit (pull_request) Successful in 4s
2026-05-09 22:19:23 +00:00
Molecule AI Core Platform Lead 1cbdf69c8d trigger: re-run sop-tier-check after core-lead approval + main sync
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Successful in 4s
2026-05-09 22:18:45 +00:00
core-be 76ac5a88dc [core-be-agent]
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Failing after 4s
fix(tests): clear platform_auth cache before each test

Fixes issue #160: workspace tests fail when MOLECULE_WORKSPACE_TOKEN
is set in the environment.

The bug: platform_auth._cached_token is populated at module import or
first get_token() call and persists for the process lifetime. Tests
that use monkeypatch.delenv("MOLECULE_WORKSPACE_TOKEN") to simulate "no
token in env" were failing because delenv removes the env var but not
the module-level cache — subsequent get_token() calls returned the
stale cached value.

Fix: add a function-scoped autouse fixture in conftest.py that calls
platform_auth.clear_cache() before every test. The import is inside the
fixture to avoid collection-time import issues when platform_auth is
not yet available.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 22:16:11 +00:00
core-lead ab7bb20545 Merge pull request '[core-be-agent] fix(handlers+a2a): treat delivery-confirmed proxy errors as delegation success' (#170) from fix/a2a-delegation-success-rendered-as-error into main
Secret scan / Scan diff for credential-shaped strings (push) Successful in 5s
2026-05-09 22:13:47 +00:00
Molecule AI Core Platform Lead b54101947f trigger: re-run sop-tier-check after tier:medium relabel + new approval
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Successful in 4s
audit-force-merge / audit (pull_request) Successful in 4s
2026-05-09 22:13:29 +00:00
Molecule AI Core Platform Lead 97768272a3 test(delegation): add isDeliveryConfirmedSuccess helper + 10-case table test
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
sop-tier-check / tier-check (pull_request) Failing after 4s
[core-lead-agent] Closes the regression-test gap on PR #170 (Core-BE's
fix for #159 retry-storm). Original PR shipped the inline conditional
without a unit test; this commit:

1. Extracts the inline `(proxyErr != nil && len(respBody) > 0 && 2xx)`
   predicate into a named helper `isDeliveryConfirmedSuccess`. Same
   behavior; the call site now reads `if isDeliveryConfirmedSuccess(...)`.

2. Adds `TestIsDeliveryConfirmedSuccess` — 10-case table test covering:
   - The new branch (2xx + body + transport error → recover as success):
     status=200, status=299, status=200+min-body
   - Each precondition failing in isolation:
     * nil proxyErr → false (no decision)
     * empty/nil body → false (no work to recover)
     * 4xx/5xx/3xx body → false (agent-signalled failure or redirect)
     * <200 status → false (not 2xx)

Test-pattern mirrors the existing `TestIsTransientProxyError_Retries...`
and `TestIsQueuedProxyResponse` table tests in the same file — same
file-local mock-error pattern, no new test infra.
2026-05-09 22:12:04 +00:00
core-be 21a5c31b85 [core-be-agent]
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
sop-tier-check / tier-check (pull_request) Failing after 4s
fix: Treat delivery-confirmed proxy errors as delegation success

Two-part fix for issue #159 — successful delegation responses were
rendered as error banners:

PART 1 — a2a_proxy.go: When io.ReadAll fails mid-stream (e.g., TCP
connection drops after the agent sent its 200 OK response), the prior
code returned (0, nil, BadGateway) discarding both the HTTP status code
and any partial body bytes already received. Fix: return
(resp.StatusCode, respBody, error) so callers can inspect what was
delivered even when the body read failed.

PART 2 — delegation.go: New condition in executeDelegation after the
transient-error retry block:

    if proxyErr != nil && len(respBody) > 0 && status >= 200 && status < 300 {
        goto handleSuccess
    }

When proxyA2ARequest returns a delivery-confirmed error (status 2xx +
non-empty partial body), route to success instead of failure. This
prevents the retry-storm pattern where the canvas shows "error" with
a Restart-workspace suggestion even though the delegation actually
completed and the response is available.

Regression tests (delegation_test.go):
- TestExecuteDelegation_DeliveryConfirmedProxyError_TreatsAsSuccess:
  server sends 200 + partial body then closes; second attempt succeeds.
  Verifies the new condition fires for delivery-confirmed 2xx responses.
- TestExecuteDelegation_ProxyErrorNon2xx_RemainsFailed: server sends
  500 + partial body then closes. Verifies non-2xx routes to failure.
- TestExecuteDelegation_ProxyErrorEmptyBody_RemainsFailed: server
  returns 502 Bad Gateway (empty body, transient). Verifies empty-body
  errors still route to failure (condition len(respBody) > 0 guards it).
- TestExecuteDelegation_CleanProxyResponse_Unchanged: clean 200 OK.
  Verifies baseline (proxyErr == nil path) is unaffected.

Fixes issue #159.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 22:11:54 +00:00
core-lead bceed5323d Merge pull request '[core-be-agent] fix: Remove silent template-dir fallback in ReplaceFiles offline path' (#180) from fix/files-restart-volume-sync into main
Secret scan / Scan diff for credential-shaped strings (push) Successful in 4s
2026-05-09 22:04:21 +00:00
Molecule AI Core Platform Lead 6f862e36db Merge remote-tracking branch 'origin/main' into trig-180
sop-tier-check / tier-check (pull_request) Successful in 3s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
audit-force-merge / audit (pull_request) Successful in 4s
2026-05-09 22:04:12 +00:00
core-lead 518a4d3520 Merge pull request 'docs(canvas): update audit status — both HIGH + MEDIUM audit items done' (#179) from docs/update-canvas-audit-status into main
Secret scan / Scan diff for credential-shaped strings (push) Successful in 4s
2026-05-09 22:04:10 +00:00
Molecule AI Core Platform Lead e90419b9fe Merge remote-tracking branch 'origin/main' into trig-179
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Successful in 4s
audit-force-merge / audit (pull_request) Successful in 4s
2026-05-09 22:04:00 +00:00
core-lead d5b2ae8e13 Merge pull request 'fix(tests): isolate token resolution from real .auth_token on disk' (#178) from fix/issue-160-test-token-env-isolation into main
Secret scan / Scan diff for credential-shaped strings (push) Successful in 4s
2026-05-09 22:03:58 +00:00
Molecule AI Core Platform Lead 2fa40bf989 Merge remote-tracking branch 'origin/main' into trig-178
sop-tier-check / tier-check (pull_request) Successful in 4s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
audit-force-merge / audit (pull_request) Successful in 4s
2026-05-09 22:03:47 +00:00
Molecule AI Core Platform Lead 5581a18981 trigger: re-run sop-tier-check after core-lead approval + main sync
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Successful in 4s
2026-05-09 22:03:02 +00:00
Molecule AI Core Platform Lead 215056bfdd Merge remote-tracking branch 'origin/main' into trig-180 2026-05-09 22:03:02 +00:00
Molecule AI Core Platform Lead 4dcabf1cb9 trigger: re-run sop-tier-check after core-lead approval + main sync
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
sop-tier-check / tier-check (pull_request) Successful in 5s
2026-05-09 22:02:57 +00:00
Molecule AI Core Platform Lead a34ebfc57f trigger: re-run sop-tier-check after core-lead approval + main sync
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Successful in 4s
2026-05-09 22:02:52 +00:00
Molecule AI Core Platform Lead e716d699e9 Merge remote-tracking branch 'origin/main' into trig-178 2026-05-09 22:02:51 +00:00
core-lead d0d9af2591 Merge pull request 'feat(canvas): add keyboard shortcuts help dialog + global ? trigger' (#175) from feat/keyboard-shortcuts-dialog into main
Secret scan / Scan diff for credential-shaped strings (push) Successful in 5s
2026-05-09 21:58:48 +00:00
core-be c9cf240751 [core-be-agent]
sop-tier-check / tier-check (pull_request) Failing after 3s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
fix(template_import): Remove silent template-dir fallback in ReplaceFiles offline path

When the workspace container is offline and writeViaEphemeral fails
(docker unavailable), ReplaceFiles previously fell back to writing
to the host-side template directory. This silently returned 200 with
"source: template" while the file change was invisible after restart
because the restart handler reads from the Docker volume, not the
template dir (issue #151).

Now returns 503 Service Unavailable with a message telling the caller
to retry after the workspace starts. The ephemeral write path is
the only correct mechanism for offline-container updates.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 21:58:34 +00:00
Molecule AI Core Platform Lead 3525ee61a4 Merge remote-tracking branch 'origin/main' into trig-175
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
sop-tier-check / tier-check (pull_request) Successful in 5s
audit-force-merge / audit (pull_request) Successful in 4s
2026-05-09 21:57:20 +00:00
core-uiux b971b5872d docs(canvas): update audit status — keyboard shortcut dialog done, screen reader in progress
sop-tier-check / tier-check (pull_request) Failing after 4s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
Mark PR #175 (keyboard shortcuts dialog) as  done.
Note that screen reader announcements (HIGH) is in progress by Core-FE.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 21:56:42 +00:00
core-devops 57aedec1a3 fix(tests): isolate token resolution from real .auth_token on disk
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 3s
sop-tier-check / tier-check (pull_request) Failing after 4s
Issue #160: workspace tests fail when MOLECULE_WORKSPACE_TOKEN is set in
the test environment (or when /configs/.auth_token exists on disk, as it
does in a container CI runner).

Root cause:
- test_resolve_token_returns_none_when_missing: monkeypatch.delenv()
  removes the env var, but _resolve_token() falls through to
  configs_dir.resolve()/.auth_token which exists in the container.
- Multi-workspace tests: clear_cache() resets _cached_token, but
  get_token() immediately re-reads /configs/.auth_token and caches
  the real token before the env var is even checked.

Fix:
- test_mcp_doctor: patch configs_dir.resolve() to return a bare tmp_path
  so the disk-file fallback finds nothing.
- Multi-workspace tests: patch platform_auth._token_file() to return a
  non-existent path (via tmp_path) alongside clear_cache(), ensuring
  the env var wins as intended.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 21:55:29 +00:00
Molecule AI Core Platform Lead dff7d8fbab trigger: re-run sop-tier-check after core-lead approval + main sync
sop-tier-check / tier-check (pull_request) Successful in 5s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
2026-05-09 21:55:14 +00:00
Molecule AI Core Platform Lead 35945d26da Merge remote-tracking branch 'origin/main' into trig-175 2026-05-09 21:55:14 +00:00
core-be 7079d4ba01 [core-be-agent]
sop-tier-check / tier-check (pull_request) Failing after 4s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
fix: Treat delivery-confirmed proxy errors as delegation success

When proxyA2ARequest returns an error but we have a non-empty
response body with a 2xx status code, the agent completed the work
successfully. The error is a delivery/transport error (e.g., connection
reset after response was received).

Previously, executeDelegation would mark these as "failed" even though
the work was done, causing:
- Retry storms (canvas suggests restart, user retries)
- "error" rendering in canvas even though result is available
- Data loss risk from unnecessary restarts

Now we check for valid response data before marking as failed.

Fixes issue #159.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 21:52:09 +00:00
core-lead 7db9fc7211 Merge pull request 'fix(canvas): render delegation responses as normal messages not error banners' (#171) from fix/issue-159-delegation-response-error into main
Secret scan / Scan diff for credential-shaped strings (push) Successful in 4s
2026-05-09 21:50:52 +00:00
Molecule AI Core Platform Lead d72bef93bc Merge remote-tracking branch 'origin/main' into trig-171
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Successful in 4s
audit-force-merge / audit (pull_request) Successful in 4s
2026-05-09 21:50:37 +00:00
Molecule AI Core Platform Lead 2cc68d57d6 Merge remote-tracking branch 'origin/main' into trig-171
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Successful in 3s
2026-05-09 21:49:50 +00:00
core-lead 33fc860918 Merge pull request 'feat(canvas): screen reader live announcements for workspace status changes' (#172) from feat/canvas-a11y-live-announcements into main
Secret scan / Scan diff for credential-shaped strings (push) Successful in 5s
2026-05-09 21:49:45 +00:00
Molecule AI Core Platform Lead 862de8cd93 Merge remote-tracking branch 'origin/main' into trig-172
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Successful in 4s
audit-force-merge / audit (pull_request) Successful in 3s
2026-05-09 21:49:36 +00:00
core-lead eac153de90 Merge pull request 'fix(build): install plugins_registry/ at wheel top level for bare imports' (#173) from fix/issue-152-plugins-registry-top-level into main
Secret scan / Scan diff for credential-shaped strings (push) Successful in 4s
2026-05-09 21:49:30 +00:00
core-devops 86f720ee14 fix(build): install plugins_registry/ at wheel top level for bare imports
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Successful in 4s
audit-force-merge / audit (pull_request) Successful in 3s
Issue #152: claude-code workspace plugin adapter import fails with
'No module named plugins_registry'. Plugin adapter code
(workspace-template-*) uses bare `from plugins_registry import ...`
but molecule-runtime only shipped it at
molecule_runtime/plugins_registry/ (the package namespace path).

Fix: copy workspace/plugins_registry/ to the top level of the wheel
in addition to molecule_runtime/plugins_registry/. Both copies coexist
— the top-level one satisfies bare imports from plugin adapters,
the nested one satisfies the rewritten
`from molecule_runtime.plugins_registry import ...` in adapter_base.py.

pyproject.toml updated to include plugins_registry* in the packages find
directive so setuptools ships it from the wheel root.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 21:49:03 +00:00
Molecule AI Core Platform Lead 736805e575 trigger: re-run sop-tier-check after core-lead approval + main sync
sop-tier-check / tier-check (pull_request) Successful in 5s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
2026-05-09 21:48:43 +00:00
Molecule AI Core Platform Lead ca74a9c064 Merge remote-tracking branch 'origin/main' into trig-171 2026-05-09 21:48:43 +00:00
Molecule AI Core Platform Lead cf2501bd18 trigger: re-run sop-tier-check after core-lead approval + main sync
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
sop-tier-check / tier-check (pull_request) Successful in 5s
2026-05-09 21:48:40 +00:00
core-uiux b33f1feb79 feat(canvas): add keyboard shortcuts help dialog + global ? trigger
sop-tier-check / tier-check (pull_request) Failing after 4s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
Closes the "no keyboard shortcut help dialog" audit gap (MEDIUM).

Changes:
- Add KeyboardShortcutsDialog component: portal-based, accessible
  dialog listing all canvas + navigation + agent shortcuts grouped by
  category. WCAG 2.1 compliant (focus trap, Esc close, aria-modal,
  aria-labelledby, focus restoration on close).
- Add global ? shortcut: opens the dialog when pressed outside any
  input field and no modal is already open.
- Add "See all shortcuts →" link in the Toolbar quick-start popup
  linking to the dialog.

Test plan:
- [x] npx vitest run (182 tests pass)
- [x] tsc --noEmit (no type errors)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 21:47:34 +00:00
core-fe d353ab5286 docs(canvas-audit): mark live-announcements HIGH item as done, update secrets-store status
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Failing after 4s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 21:31:27 +00:00
core-fe 1224f19cfc feat(canvas): screen reader live announcements for canvas state changes
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
sop-tier-check / tier-check (pull_request) Failing after 4s
Issue: HIGH priority item from canvas accessibility audit (2026-05-09).
Screen reader users had no way to know when workspace status changed
— the canvas updated visually but no announcement was made.

Changes:
- canvas.ts: add `liveAnnouncement: string` + `setLiveAnnouncement` to
  CanvasState so the store can hold the current announcement text.
- canvas-events.ts: set `liveAnnouncement` in handleCanvasEvent for 6
  key status transitions: ONLINE, OFFLINE, PAUSED, DEGRADED, PROVISIONING,
  REMOVED, PROVISION_FAILED. Names are looked up from store nodes so
  announcements are human-readable ("Alpha is now online" not "ws-1").
  TASK_UPDATED and AGENT_MESSAGE are intentionally excluded — they fire
  on every heartbeat and would overwhelm the user.
- Canvas.tsx: subscribe to `liveAnnouncement` from the store; render a
  visually-hidden `aria-live="polite" aria-atomic="true"` region that
  speaks the announcement then clears it after 500 ms so the same
  message doesn't re-announce on re-render. Fallback still announces
  workspace count on initial load.
- canvas-events.test.ts: 12 new test cases covering announcement
  content for all 6 event types, empty/no-announcement cases, and
  payload-name fallback when a node isn't yet in the store.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 21:30:33 +00:00
core-uiux d15040d233 fix(canvas): render delegation responses as normal messages not error banners
sop-tier-check / tier-check (pull_request) Failing after 4s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
Issue #159: successful delegation responses were rendered as error
banners because extractResponseText() only handled the A2A result
format (body.result.parts[].text) but delegation.go stores
response_body as {text: "...", delegation_id: "..."}. The error
status was set when the HTTP transport failed even though the actual
agent response was received.

Fixes:
1. extractResponseText: check body.text before the result path so
   delegation response_body.text is extracted correctly
2. extractResponseText: also check body.response_preview (WS event shape
   from DELEGATION_COMPLETE handler)
3. GroupedCommsView: render NormalMessage when status=error but
   responseText is populated (delegation succeeded, transport failed)
   instead of burying the content in an error banner

Tests: 8 new cases (4 extractResponseText + 2 extractRequestText
regression + 2 render tests). 189 tests pass across 10 files.

Closes #159.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 21:26:39 +00:00
core-lead 020d63cbc7 Merge pull request 'tech-debt: rename molecule-monorepo-net to molecule-core-net' (#166) from tech-debt/rename-network into main
Secret scan / Scan diff for credential-shaped strings (push) Successful in 4s
2026-05-09 21:19:39 +00:00
Molecule AI Core Platform Lead ea8ac4f023 Merge remote-tracking branch 'origin/main' into tech-debt/rename-net
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Successful in 4s
audit-force-merge / audit (pull_request) Successful in 4s
2026-05-09 21:19:28 +00:00
Molecule AI Core Platform Lead f4598c8c2a trigger: re-run sop-tier-check after tier:low + core-lead approval + main sync
sop-tier-check / tier-check (pull_request) Successful in 5s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
2026-05-09 21:18:47 +00:00
Molecule AI Core Platform Lead ad89173f0f Merge remote-tracking branch 'origin/main' into tech-debt/rename-net 2026-05-09 21:18:46 +00:00
core-lead 032e37e703 Merge pull request 'fix(workspace-server): sanitize err.Error() leaks in CascadeDelete and OrgImport' (#168) from fix/sanitize-err-leaks-cascade-delete-and-org-import into main
Secret scan / Scan diff for credential-shaped strings (push) Successful in 4s
2026-05-09 21:17:19 +00:00
Molecule AI Core Platform Lead 49d53204cc Merge remote-tracking branch 'origin/main' into fix/168-mine
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Successful in 4s
audit-force-merge / audit (pull_request) Successful in 4s
2026-05-09 21:17:07 +00:00
Molecule AI Core Platform Lead 7bcfc8821e trigger: re-run sop-tier-check after dropping tier:medium + receiving 2 approvals
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Successful in 4s
2026-05-09 21:16:20 +00:00
core-lead 84b38914bd Merge pull request 'fix(canvas): render delegation message body in Agent Comms tab' (#167) from fix/issue-158-delegation-message-body into main
Secret scan / Scan diff for credential-shaped strings (push) Successful in 4s
2026-05-09 21:15:19 +00:00
Molecule AI Core Platform Lead f9d58b2186 Merge remote-tracking branch 'origin/main' into fix/167-uiux
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Successful in 4s
audit-force-merge / audit (pull_request) Successful in 4s
2026-05-09 21:14:54 +00:00
Molecule AI Core Platform Lead b9db10432d trigger: re-run sop-tier-check after dropping duplicate tier:medium label
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Successful in 4s
2026-05-09 21:14:07 +00:00
Molecule AI Core Platform Lead 5b50dafe34 trigger: re-run CI after tier:low label + core-lead approval
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Failing after 4s
2026-05-09 21:09:59 +00:00
Molecule AI Core Platform Lead 7090eab0d5 fix(workspace-server): sanitize err.Error() leaks in CascadeDelete and OrgImport
audit-force-merge / audit (pull_request) Has been skipped
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Failing after 4s
[core-lead-agent] Closes Core-Security audit finding (2026-05-09 audit cycle, MEDIUM):

1. workspace-server/internal/handlers/workspace_crud.go:335
   `DELETE /workspaces/:id` returned `err.Error()` verbatim in the 500
   body, leaking wrapped lib/pq driver strings (schema column names,
   index hints) to HTTP clients. Replaced with sanitized message;
   raw error already logged server-side via the existing log.Printf
   immediately above.

2. workspace-server/internal/handlers/org.go:610
   `OrgImport` echoed the user-supplied `body.Dir` verbatim in the 404
   "org template not found: %s" response. Path traversal is already
   blocked by resolveInsideRoot earlier in the handler, but echoing
   raw input back lets a client probe filesystem layout (404-with-echo
   vs. 400-from-resolve is itself a signal). Dropped the input from the
   client-facing message; preserved full context in a new log.Printf
   (orgFile path + the requested body.Dir) for operator triage.

Both fixes preserve operator-side diagnostics (logs unchanged in
content, only client-facing JSON sanitized). No behavior change for
legitimate clients — error type, status code, and JSON shape all stay
the same.

Tier: low. Defensive hardening only; reduces info-disclosure surface
without altering control-flow or auth gates.
2026-05-09 21:01:40 +00:00
core-lead 1320901b1c Merge pull request 'fix(canvas): cap maxWorkers:1 to prevent jsdom pool worker startup timeouts' (#149) from fix/vitest-pool-worker-startup-timeouts into main
Secret scan / Scan diff for credential-shaped strings (push) Successful in 5s
2026-05-09 20:58:02 +00:00
core-uiux 2654a4da01 fix(canvas): render delegation message body in Agent Comms tab
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
sop-tier-check / tier-check (pull_request) Failing after 4s
Agent Comms tab rendered outbound delegations as blank bubbles because
extractRequestText only checked the A2A JSON-RPC format
(body.params.message.parts[].text) while delegation.go stores
request_body as {"task": "...", "delegation_id": "..."}.

Fix: check body.task first for delegation activities, then fall back to
the A2A format. Add six test cases covering the delegation shape,
precedence over A2A params when both present, empty-string guard, and
non-string type guard.

Closes #158.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 20:57:53 +00:00
Molecule AI Core Platform Lead 0a29c0a9e5 Merge remote-tracking branch 'origin/main' into fix/vitest-pool
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 7s
sop-tier-check / tier-check (pull_request) Successful in 8s
audit-force-merge / audit (pull_request) Successful in 5s
2026-05-09 20:57:16 +00:00
Molecule AI Core Platform Lead 205ee9645c trigger: re-run sop-tier-check after core-lead approval
CodeQL / Analyze (${{ matrix.language }}) (go) (pull_request) Successful in 1s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (pull_request) Successful in 0s
CodeQL / Analyze (${{ matrix.language }}) (python) (pull_request) Successful in 1s
pr-guards / disable-auto-merge-on-push (pull_request) Successful in 2s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 5s
E2E API Smoke Test / detect-changes (pull_request) Successful in 6s
CI / Detect changes (pull_request) Successful in 10s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 9s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 8s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 9s
sop-tier-check / tier-check (pull_request) Successful in 9s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 9s
Harness Replays / detect-changes (pull_request) Successful in 10s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 2s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 3s
Harness Replays / Harness Replays (pull_request) Successful in 3s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 4s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 22s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 50s
CI / Platform (Go) (pull_request) Successful in 2m50s
CI / Canvas (Next.js) (pull_request) Successful in 3m27s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Python Lint & Test (pull_request) Successful in 7m18s
2026-05-09 20:55:19 +00:00
claude-ceo-assistant fa7e4101d7 fix(canvas): show task text in Agent Comms for MCP delegate_task calls (#163)
Secret scan / Scan diff for credential-shaped strings (push) Successful in 4s
Closes #158.

[FORCE-MERGE AUDIT — §SOP-7]
- Approver: hongming via chat-go ("go") in conversation transcript ~21:00 UTC on 2026-05-09
- Bypassed: required status checks (all pending — runner pickup issue, separate from PR correctness)
- Audit channel: orchestrator force-merge log + this commit message

Fixes the one-sided Agent Comms rendering by writing activity_log rows for MCP delegate_task calls. PR authored by core-fe under per-persona Gitea identity (post #156 merge).
2026-05-09 20:54:53 +00:00
claude-ceo-assistant c16c5c6183 infra(docker-compose): include infra services so docker compose up starts Temporal (#162)
Secret scan / Scan diff for credential-shaped strings (push) Successful in 4s
[FORCE-MERGE AUDIT — §SOP-7]
- Approver: hongming via chat-go ("go") in conversation transcript ~21:00 UTC on 2026-05-09
- Bypassed: required status checks (all pending — runner pickup issue, separate from PR correctness)
- Audit channel: orchestrator force-merge log + this commit message

Part of overnight team shipping cycle. PR authored by team persona under per-persona Gitea identity (post #156 merge).
2026-05-09 20:54:36 +00:00
core-devops 252f8d0c47 tech-debt: rename molecule-monorepo-net -> molecule-core-net
sop-tier-check / tier-check (pull_request) Failing after 4s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
Renames Docker network across all code, configs, scripts, and docs.

Per issue #93: the network was named molecule-monorepo-net as a holdover
from when the repo was called molecule-monorepo. The canonical repo name is
now molecule-core, so the network should be molecule-core-net.

Files changed:
- docker-compose.yml, docker-compose.infra.yml: network definition
- infra/scripts/setup.sh: docker network create
- scripts/nuke-and-rebuild.sh: docker network rm
- workspace-server/internal/provisioner/provisioner.go: DefaultNetwork
- All comments/docs: updated wording

Acceptance: grep -rn 'molecule-monorepo-net' returns zero matches.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 20:51:48 +00:00
core-fe e8f521011f fix(mcp): write delegation activity row so canvas Agent Comms shows task text
CodeQL / Analyze (${{ matrix.language }}) (go) (pull_request) Successful in 1s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (pull_request) Successful in 1s
CodeQL / Analyze (${{ matrix.language }}) (python) (pull_request) Successful in 1s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 5s
CI / Detect changes (pull_request) Successful in 8s
E2E API Smoke Test / detect-changes (pull_request) Successful in 8s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 9s
sop-tier-check / tier-check (pull_request) Failing after 8s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 8s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 12s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 11s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 4s
Harness Replays / detect-changes (pull_request) Successful in 12s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Failing after 27s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 35s
Harness Replays / Harness Replays (pull_request) Failing after 43s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Failing after 53s
CI / Platform (Go) (pull_request) Successful in 3m17s
CI / Canvas (Next.js) (pull_request) Successful in 4m3s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 5m55s
CI / Python Lint & Test (pull_request) Successful in 7m29s
audit-force-merge / audit (pull_request) Successful in 5s
MCP delegate_task and delegate_task_async bypassed the delegation activity
lifecycle entirely — no activity_log row was written for MCP-initiated
delegations. As a result the canvas Agent Comms tab rendered outbound
delegations as bare "Delegation dispatched" events with no task body.

Fix: insert a delegation row (mirroring insertDelegationRow from
delegation.go) before the A2A call so the canvas can show the task text.
The sync tool updates status to 'dispatched' after the HTTP call; the
async tool inserts with 'dispatched' directly (goroutine won't update).

Closes #158.
Closes #49 (partial — addresses the canvas-display gap; full lifecycle
parity requires DelegationWriter extraction, tracked separately).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 20:44:06 +00:00
core-devops 8cd52fc642 infra(docker-compose): include infra services so docker compose up starts Temporal
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Failing after 4s
audit-force-merge / audit (pull_request) Successful in 4s
Per issue #153: `docker compose up -d` (docker-compose.yml) did not start
Temporal because it lived only in docker-compose.infra.yml. Users had to know
to run `setup.sh` which explicitly uses `-f docker-compose.infra.yml`.

Adding `include: - docker-compose.infra.yml` makes the full infra stack
(starting with Temporal) start with the default `docker compose up` command.

Both compose files define postgres/redis — the main file's definitions take
precedence via compose merge semantics, so no service conflicts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 20:41:37 +00:00
core-uiux 2ef4f64b31 docs(design-system): add canvas architecture + known issues from Core-FE
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Failing after 4s
audit-force-merge / audit (pull_request) Has been skipped
Added from Core-FE verified findings:
- Canvas stack: @xyflow/react v12, Next.js 14, Tailwind v4, Zustand
- Directory structure with verified file locations
- Known issues: secrets-store.ts getGrouped() performance bug
- Pre-commit hook verification needed
- Tech debt items: any types, selector memoization, use client enforcement

Updated canvas-audit-items.md with architecture section.

Co-Authored-By: Core-FE <core-fe@moleculesai.app>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 20:26:34 +00:00
core-uiux d27b1e13de docs(design-system): correct theme system — three modes, semantic tokens
Major correction from Core-FE review:
- Canvas has THREE themes: System/Light/Dark, not dark-only
- Warm paper tones for light, zinc-adjacent dark for dark mode
- ThemeProvider handles switching, persisted in mol_theme cookie
- Use semantic tokens: bg-surface, bg-surface-card, border-line, text-ink
- NEVER use raw zinc for surfaces — only for borders/disabled/code

Updated:
- Section 1: Three-mode theme palette with exact hex values
- Section 4: Component patterns now use semantic tokens
- Added Section 4.6: ThemeProvider + useTheme() usage
- Section 7: Enforcement checklist now includes token rules

Co-Authored-By: Core-FE <core-fe@moleculesai.app>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 20:19:40 +00:00
core-uiux efbe4035f3 docs(design-system): add verified canvas design system v1
Cross-reference the Core-FE draft against actual molecule-core/canvas/src/
codebase. Creates two new docs:

- canvas-design-system-v1.md: Full design system with verified color
  palette, typography scale, animation tokens (from theme-tokens.css),
  component patterns, WCAG 2.1 AA checklist. Marks all items as
  VERIFIED with source file citations.

- canvas-audit-items.md: Updated architecture brain dump with verified
  findings on React Flow canvas accessibility. Flags remaining gaps
  (screen reader announcements, keyboard shortcuts help, keyboard drag).

Key verified discrepancies from draft:
- Font: system-ui stack (not Inter/Geist)
- Tooltip: uses aria-describedby + role=tooltip (not group-hover CSS)
- Animation tokens: already defined in theme-tokens.css
- ContextMenu: has full keyboard nav (arrow keys, wrap-around)

Co-Authored-By: Core-FE <core-fe@moleculesai.app>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 20:08:16 +00:00
fullstack-engineer 9456d1c5fd fix(canvas): cap maxWorkers:1 to prevent jsdom pool worker startup timeouts
CodeQL / Analyze (${{ matrix.language }}) (go) (pull_request) Successful in 1s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (pull_request) Successful in 1s
CodeQL / Analyze (${{ matrix.language }}) (python) (pull_request) Successful in 0s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 5s
CI / Detect changes (pull_request) Successful in 7s
E2E API Smoke Test / detect-changes (pull_request) Successful in 7s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 7s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 8s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 8s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 9s
Harness Replays / detect-changes (pull_request) Successful in 9s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 3s
CI / Platform (Go) (pull_request) Successful in 4s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 3s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 3s
CI / Python Lint & Test (pull_request) Successful in 5s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 3s
Harness Replays / Harness Replays (pull_request) Failing after 38s
sop-tier-check / tier-check (pull_request) Failing after 4s
CI / Canvas (Next.js) (pull_request) Successful in 2m23s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 4m3s
The forks pool's implicit maxWorkers=1 (2-CPU runner) was insufficient
to prevent concurrent jsdom worker cold-starts. Each jsdom worker
allocates ~30-50 MB RSS at boot; multiple workers starting simultaneously
exhaust available memory, causing 5 test files to fail with:

  [vitest-pool]: Failed to start forks worker for test files ...
  [vitest-pool-runner]: Timeout waiting for worker to respond

Individual jsdom test files take 12-15 s in isolation and pass cleanly.
Failures only occur when 51 files are run together through the pool.

Fix: explicitly set maxWorkers:1 so a single worker processes all files
sequentially, eliminating concurrent jsdom bootstrap memory pressure.
With this change, all 51 files pass (was 46 pass + 5 fail), and suite
duration improves from ~5070 s to ~1117 s because workers no longer
compete for resources during startup.

Ref: issue #148
Ref: vitest-pool investigation for issue #22 (canvas side)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 02:02:10 +00:00
44 changed files with 1906 additions and 87 deletions
+4 -4
View File
@@ -51,7 +51,7 @@ name: E2E API Smoke Test
# * Pre-pull `alpine:latest` so the platform-server's provisioner
# (`internal/handlers/container_files.go`) can stand up its
# ephemeral token-write helper without a daemon.io round-trip.
# * Create `molecule-monorepo-net` bridge network if missing so the
# * Create `molecule-core-net` bridge network if missing so the
# provisioner's container.HostConfig {NetworkMode: ...} attach
# succeeds.
# Item #1 (timeouts) — evidence on recent runs (77/3191, ae/4270, 0e/
@@ -163,12 +163,12 @@ jobs:
# when the image is already present.
docker pull alpine:latest >/dev/null
# Provisioner attaches workspace containers to
# molecule-monorepo-net (workspace-server/internal/provisioner/
# molecule-core-net (workspace-server/internal/provisioner/
# provisioner.go::DefaultNetwork). The bridge already exists on
# the operator host's docker daemon — `network create` is
# idempotent via `|| true`.
docker network create molecule-monorepo-net >/dev/null 2>&1 || true
echo "alpine:latest pre-pulled; molecule-monorepo-net ensured."
docker network create molecule-core-net >/dev/null 2>&1 || true
echo "alpine:latest pre-pulled; molecule-core-net ensured."
- name: Start Postgres (docker)
if: needs.detect-changes.outputs.api == 'true'
run: |
@@ -34,7 +34,7 @@ name: Handlers Postgres Integration
# So we sidestep `services:` entirely. The job container still uses
# host-net (inherited from runner config; required for cache server
# discovery on the bridge IP 172.18.0.17:42631). We launch a sibling
# postgres on the existing `molecule-monorepo-net` bridge with a
# postgres on the existing `molecule-core-net` bridge with a
# UNIQUE name per run — `pg-handlers-${RUN_ID}-${RUN_ATTEMPT}` — and
# read its bridge IP via `docker inspect`. A host-net job container
# can reach a bridge-net container directly via the bridge IP (verified
@@ -44,7 +44,7 @@ name: Handlers Postgres Integration
# + No host-port collision; N parallel runs share the bridge cleanly
# + `if: always()` cleanup runs even on test-step failure
# - One more step in the workflow (+~3 lines)
# - Requires `molecule-monorepo-net` to exist on the operator host
# - Requires `molecule-core-net` to exist on the operator host
# (it does; declared in docker-compose.yml + docker-compose.infra.yml)
#
# Class B Hongming-owned CICD red sweep, 2026-05-08.
@@ -96,7 +96,7 @@ jobs:
PG_NAME: pg-handlers-${{ github.run_id }}-${{ github.run_attempt }}
# Bridge network already exists on the operator host (declared
# in docker-compose.yml + docker-compose.infra.yml).
PG_NETWORK: molecule-monorepo-net
PG_NETWORK: molecule-core-net
defaults:
run:
working-directory: workspace-server
+1 -1
View File
@@ -284,7 +284,7 @@ cp .env.example .env
./infra/scripts/setup.sh
# Boots Postgres (:5432), Redis (:6379), Langfuse (:3001),
# and Temporal (:7233 gRPC, :8233 UI) on the shared
# `molecule-monorepo-net` Docker network. Temporal runs with
# `molecule-core-net` Docker network. Temporal runs with
# no auth on localhost — dev-only; production must gate it.
#
# Also populates the template/plugin registry by cloning every repo
+1 -1
View File
@@ -283,7 +283,7 @@ cp .env.example .env
./infra/scripts/setup.sh
# 启动 Postgres (:5432)、Redis (:6379)、Langfuse (:3001)
# 以及 Temporal (:7233 gRPC, :8233 UI),全部挂在共享的
# `molecule-monorepo-net` Docker 网络上。Temporal 默认无鉴权,
# `molecule-core-net` Docker 网络上。Temporal 默认无鉴权,
# 仅用于本地开发;生产环境必须加 mTLS / API Key。
#
# 同时会根据 manifest.json 拉取所有模板/插件仓库到
+33 -6
View File
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useMemo } from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import {
ReactFlow,
ReactFlowProvider,
@@ -187,6 +187,23 @@ function CanvasInner() {
// Pan-to-node / zoom-to-team CustomEvent listeners + viewport save.
const { onMoveEnd } = useCanvasViewport();
// Screen-reader announcements — read liveAnnouncement from the store and
// immediately clear it so the same announcement doesn't re-fire on
// re-render. Using a ref avoids a setState loop while keeping the
// effect reactive to new announcement strings.
const liveAnnouncement = useCanvasStore((s) => s.liveAnnouncement);
const clearAnnouncement = useCanvasStore((s) => s.setLiveAnnouncement);
const prevAnnouncement = useRef("");
useEffect(() => {
if (liveAnnouncement && liveAnnouncement !== prevAnnouncement.current) {
prevAnnouncement.current = liveAnnouncement;
// Small delay so the DOM update lands before clearing, giving
// screen readers time to pick up the new text.
const timer = setTimeout(() => clearAnnouncement(""), 500);
return () => clearTimeout(timer);
}
}, [liveAnnouncement, clearAnnouncement]);
// Delete-confirmation lives in the store so the dialog survives ContextMenu
// unmounting — the prior local-in-ContextMenu state raced with the menu's
// outside-click handler.
@@ -326,11 +343,21 @@ function CanvasInner() {
<DropTargetBadge />
</ReactFlow>
{/* Screen-reader live region: announces workspace count on canvas load or change */}
<div role="status" aria-live="polite" className="sr-only">
{nodes.filter((n) => !n.parentId).length === 0
? "No workspaces on canvas"
: `${nodes.filter((n) => !n.parentId).length} workspace${nodes.filter((n) => !n.parentId).length !== 1 ? "s" : ""} on canvas`}
{/* Screen-reader live region announces workspace count on initial load and
live status updates from WebSocket events (online, offline, provisioning, etc.).
The liveAnnouncement text is cleared after the screen reader has had time
to read it so the same message doesn't re-announce on re-render. */}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{liveAnnouncement || (
nodes.filter((n) => !n.parentId).length === 0
? "No workspaces on canvas"
: `${nodes.filter((n) => !n.parentId).length} workspace${nodes.filter((n) => !n.parentId).length !== 1 ? "s" : ""} on canvas`
)}
</div>
{nodes.length === 0 && <EmptyState />}
@@ -0,0 +1,227 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
interface ShortcutGroup {
title: string;
shortcuts: Array<{ keys: string[]; description: string }>;
}
const SHORTCUT_GROUPS: ShortcutGroup[] = [
{
title: "Canvas",
shortcuts: [
{
keys: ["Esc"],
description: "Close context menu, clear selection, or deselect",
},
{
keys: ["Enter"],
description: "Descend into selected node's first child",
},
{
keys: ["Shift", "Enter"],
description: "Ascend to selected node's parent",
},
{
keys: ["Cmd", "]"],
description: "Bring selected node forward in z-order",
},
{
keys: ["Cmd", "["],
description: "Send selected node backward in z-order",
},
{
keys: ["Z"],
description: "Zoom to fit the selected team and its sub-workspaces",
},
],
},
{
title: "Navigation",
shortcuts: [
{
keys: ["⌘K"],
description: "Open workspace search",
},
{
keys: ["Palette"],
description: "Open the template palette to deploy a new workspace",
},
{
keys: ["Dbl-click"],
description: "Zoom canvas to fit a team node and all its sub-workspaces",
},
{
keys: ["Right-click"],
description: "Open the workspace context menu",
},
],
},
{
title: "Agent",
shortcuts: [
{
keys: ["Chat"],
description: "Send a message or resume a running task",
},
{
keys: ["Config"],
description: "Edit skills, model, secrets, and runtime settings",
},
{
keys: ["Audit"],
description: "View the activity ledger for the selected workspace",
},
],
},
];
interface Props {
open: boolean;
onClose: () => void;
}
export function KeyboardShortcutsDialog({ open, onClose }: Props) {
const dialogRef = useRef<HTMLDivElement>(null);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
// Move focus into the dialog when it opens (WCAG 2.1 SC 2.4.3)
useEffect(() => {
if (!open || !mounted) return;
const raf = requestAnimationFrame(() => {
dialogRef.current?.querySelector<HTMLElement>("button")?.focus();
});
return () => cancelAnimationFrame(raf);
}, [open, mounted]);
// Keyboard: Escape closes, Tab is trapped
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
return;
}
if (e.key === "Tab" && dialogRef.current) {
const focusable = Array.from(
dialogRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
).filter((el) => !el.hasAttribute("disabled"));
if (focusable.length === 0) {
e.preventDefault();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, onClose]);
if (!open || !mounted) return null;
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Dialog */}
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="keyboard-shortcuts-title"
className="relative bg-surface border border-line rounded-xl shadow-2xl shadow-black/60 max-w-[480px] w-full mx-4 overflow-hidden max-h-[80vh] flex flex-col"
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-line shrink-0">
<h2
id="keyboard-shortcuts-title"
className="text-sm font-semibold text-ink"
>
Keyboard Shortcuts
</h2>
<button
type="button"
onClick={onClose}
aria-label="Close keyboard shortcuts"
className="w-7 h-7 flex items-center justify-center rounded-lg text-ink-mid hover:text-ink hover:bg-surface-sunken transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
×
</button>
</div>
{/* Content */}
<div className="overflow-y-auto p-5 space-y-5">
{SHORTCUT_GROUPS.map((group) => (
<div key={group.title}>
<h3 className="text-[10px] font-semibold uppercase tracking-[0.2em] text-ink-soft mb-2.5">
{group.title}
</h3>
<div className="space-y-2">
{group.shortcuts.map((shortcut, i) => (
<div
key={i}
className="flex items-center justify-between gap-4"
>
<span className="text-[13px] text-ink-mid">
{shortcut.description}
</span>
<kbd className="flex items-center gap-0.5 shrink-0">
{shortcut.keys.map((k, j) => (
<span key={j} className="flex items-center gap-0.5">
{j > 0 && (
<span className="text-[9px] text-ink-soft mx-0.5">
+
</span>
)}
<span className="inline-flex items-center rounded-md border border-line/70 bg-surface-sunken/70 px-2 py-0.5 text-[11px] font-medium text-ink tabular-nums font-mono">
{k}
</span>
</span>
))}
</kbd>
</div>
))}
</div>
</div>
))}
</div>
{/* Footer */}
<div className="px-5 py-3 border-t border-line bg-surface-sunken/30 shrink-0">
<p className="text-[10px] text-ink-soft text-center">
Press{" "}
<kbd className="inline-flex items-center rounded border border-line/70 bg-surface-sunken/70 px-1.5 py-0.5 text-[10px] font-medium text-ink font-mono">
Esc
</kbd>{" "}
to close
</p>
</div>
</div>
</div>,
document.body
);
}
+38
View File
@@ -9,6 +9,7 @@ import { ConfirmDialog } from "@/components/ConfirmDialog";
import { showToast } from "@/components/Toaster";
import { ThemeToggle } from "@/components/ThemeToggle";
import { statusDotClass } from "@/lib/design-tokens";
import { KeyboardShortcutsDialog } from "@/components/KeyboardShortcutsDialog";
export function Toolbar() {
const nodes = useCanvasStore((s) => s.nodes);
@@ -33,6 +34,7 @@ export function Toolbar() {
const [restartingAll, setRestartingAll] = useState(false);
const [restartConfirmOpen, setRestartConfirmOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
const helpRef = useRef<HTMLDivElement>(null);
// Suppress toast on the very first connect at page load; only fire on reconnects.
@@ -127,6 +129,29 @@ export function Toolbar() {
};
}, []);
// Global ? shortcut opens the shortcuts dialog (mirrors the help button).
// Skip when the user is typing in an input so ? in a text field doesn't
// steal focus. Also skip when a modal/dialog is already open.
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key !== "?") return;
const tag = (e.target as HTMLElement).tagName;
const inInput =
tag === "INPUT" ||
tag === "TEXTAREA" ||
tag === "SELECT" ||
(e.target as HTMLElement).isContentEditable;
if (inInput) return;
// Don't fire when a modal/dialog is already mounted (canvas modals,
// side panel, etc. use z-50 or above).
if (document.querySelector('[role="dialog"][aria-modal="true"]')) return;
e.preventDefault();
setShortcutsOpen(true);
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
return (
<div
className="fixed top-3 left-1/2 -translate-x-1/2 z-20 flex items-center gap-3 bg-surface-sunken/80 backdrop-blur-md border border-line/60 rounded-xl px-4 py-2 shadow-xl shadow-black/20 transition-[margin-left] duration-200"
@@ -321,6 +346,14 @@ export function Toolbar() {
<HelpRow shortcut="Config" text="Use the Config tab for skills, model, secrets, and runtime settings." />
<HelpRow shortcut="Dbl-click / Z" text="Zoom canvas to fit a team node and all its sub-workspaces." />
</div>
{/* Link to the full keyboard shortcuts dialog */}
<button
type="button"
onClick={() => { setHelpOpen(false); setShortcutsOpen(true); }}
className="mt-3 w-full text-center text-[10px] text-ink-soft hover:text-accent transition-colors focus:outline-none focus-visible:underline"
>
See all shortcuts
</button>
</div>
)}
</div>
@@ -340,6 +373,11 @@ export function Toolbar() {
onConfirm={restartAll}
onCancel={() => setRestartConfirmOpen(false)}
/>
<KeyboardShortcutsDialog
open={shortcutsOpen}
onClose={() => setShortcutsOpen(false)}
/>
</div>
);
}
@@ -513,7 +513,20 @@ function GroupedCommsView({
/>
<div className="flex-1 overflow-y-auto p-3 space-y-2">
{visible.map((msg) =>
msg.status === "error" ? (
// Only render the error UI when there is NO usable response
// content. A "error" status from the platform means the HTTP
// transport layer had a problem — but the agent response text
// may have arrived and been stored in response_body.text.
// Delegation results set responseText via extractResponseText
// once that function learned to parse body.text, so checking
// !msg.responseText here correctly identifies "no actual reply
// was received" vs. "reply arrived but status=error".
//
// Without this guard, successful delegation results were
// rendered as error banners, PMs saw "restart" prompts and
// restarted working agents, and retry storms formed as the
// platform re-delivered the same completed work (issue #159).
msg.status === "error" && !msg.responseText ? (
<ErrorMessage key={msg.id} msg={msg} />
) : (
<NormalMessage key={msg.id} msg={msg} />
@@ -4,9 +4,11 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react";
// API mock — tests can override per case via apiGetMock.mockImplementationOnce.
const apiGetMock = vi.fn<(url: string) => Promise<unknown>>();
const apiPostMock = vi.fn<(url: string, body?: unknown) => Promise<unknown>>();
vi.mock("@/lib/api", () => ({
api: {
get: (url: string) => apiGetMock(url),
post: (url: string, body?: unknown) => apiPostMock(url, body),
},
}));
@@ -16,17 +18,23 @@ vi.mock("@/hooks/useSocketEvent", () => ({
useSocketEvent: () => {},
}));
// Canvas store — peer name resolution.
vi.mock("@/store/canvas", () => ({
useCanvasStore: {
getState: () => ({
nodes: [
{ id: "ws-self", data: { name: "Self" } },
{ id: "ws-peer", data: { name: "Peer Agent" } },
],
}),
},
}));
// Canvas store — peer name resolution + ErrorMessage requires selectNode
// (Zustand hook usage). The mock must support BOTH:
// useCanvasStore.getState().nodes (plain object with getState)
// useCanvasStore((s) => s.selectNode) (Zustand hook with selector)
vi.mock("@/store/canvas", () => {
const state = {
nodes: [
{ id: "ws-self", data: { name: "Self" } },
{ id: "ws-peer", data: { name: "Peer Agent" } },
],
selectNode: vi.fn(),
};
const hook = (selector?: (s: typeof state) => unknown) =>
selector ? selector(state) : state;
hook.getState = () => state;
return { useCanvasStore: hook };
});
// Toaster shim — AgentCommsPanel imports showToast.
vi.mock("../../Toaster", () => ({
@@ -41,6 +49,8 @@ import { AgentCommsPanel } from "../AgentCommsPanel";
const scrollSpy = vi.fn<(opts?: ScrollIntoViewOptions | boolean) => void>();
beforeEach(() => {
apiGetMock.mockReset();
apiPostMock.mockReset();
apiPostMock.mockResolvedValue({});
scrollSpy.mockReset();
Element.prototype.scrollIntoView = scrollSpy as unknown as Element["scrollIntoView"];
});
@@ -49,6 +59,81 @@ afterEach(() => {
vi.clearAllMocks();
});
// Regression test: when a delegation succeeds but the platform persisted
// status="error" (transport-layer HTTP failure, not agent failure), the
// canvas had the response text in msg.text but rendered ErrorMessage
// anyway, burying the real content in an "Underlying error" banner and
// prompting PMs to restart working agents (issue #159).
describe("AgentCommsPanel — error rendering guard (issue #159)", () => {
it("renders NormalMessage when status=error but msg.text is present (successful delegation)", async () => {
// Simulate a delegation result where status="error" (HTTP transport
// failed) but response_body.text carries the actual agent response.
// The correct behaviour: show the content as a normal inbound bubble,
// NOT an error banner.
apiGetMock.mockResolvedValueOnce([
{
id: "act-1",
activity_type: "delegation",
method: "delegate_result",
source_id: "ws-self",
target_id: "ws-peer",
summary: "Delegation completed",
request_body: null,
// delegation.go stores response_body as {text: "...", delegation_id: "..."}
response_body: {
text: "PR #149: tier-check fails NO REVIEWS (author needs engineers/managers/ceo approval)",
delegation_id: "delg_01jx8q4n3k",
},
status: "error", // transport-layer error, not agent failure
created_at: "2026-04-25T18:00:00Z",
},
]);
render(<AgentCommsPanel workspaceId="ws-self" />);
// The response text should appear in a normal inbound bubble, NOT in
// an error banner. Specifically: no "Failed to deliver" or "returned
// an error" text should appear.
await waitFor(() => {
expect(screen.queryByText(/failed to deliver/i)).toBeNull();
expect(screen.queryByText(/returned an error/i)).toBeNull();
});
// The actual content must be visible.
await waitFor(() =>
expect(
screen.getByText(/tier-check fails NO REVIEWS/i),
).toBeDefined(),
);
});
it("renders ErrorMessage when status=error and msg.text is absent (true failure)", async () => {
// True delivery failure: no response body, no text. The error banner
// IS appropriate here.
apiGetMock.mockResolvedValueOnce([
{
id: "act-1",
activity_type: "a2a_send",
source_id: "ws-self",
target_id: "ws-peer",
method: "message/send",
summary: "A2A send failed",
request_body: null,
response_body: null,
status: "error",
created_at: "2026-04-25T18:00:00Z",
},
]);
render(<AgentCommsPanel workspaceId="ws-self" />);
// Error banner IS shown for true failures (no content).
// jsdom doesn't reliably match role="alert" in getByRole, so use
// getByText instead.
const errorBanner = await waitFor(() =>
screen.getByText(/failed to deliver/i),
);
expect(errorBanner).toBeDefined();
});
});
describe("AgentCommsPanel — initial-state parity with ChatTab my-chat", () => {
it("shows loading text while history fetch is in flight", () => {
apiGetMock.mockReturnValueOnce(new Promise(() => { /* never resolves */ }));
@@ -64,6 +64,54 @@ describe("extractRequestText", () => {
};
expect(extractRequestText(body)).toBe("");
});
// Regression: delegation.go stores request_body as {"task": "...", "delegation_id": "..."}.
// extractRequestText was checking only the A2A params.message.parts path, so
// outbound delegation messages were rendered as blank bubbles.
// Fix: check body.task first (delegation format), then fall back to A2A.
it("extracts text from body.task (delegation format)", () => {
const body = {
task: "Deploy the staging environment for this sprint's release",
delegation_id: "delg_01jx8q4n3k",
};
expect(extractRequestText(body)).toBe(
"Deploy the staging environment for this sprint's release"
);
});
it("prefers body.task over A2A params when both present", () => {
const body = {
task: "Delegation text wins",
params: {
message: {
parts: [{ kind: "text", text: "A2A text" }],
},
},
};
// body.task is checked first; delegation wins for delegation activities.
expect(extractRequestText(body)).toBe("Delegation text wins");
});
it("falls back to A2A format when body.task is absent", () => {
const body = {
params: {
message: {
parts: [{ kind: "text", text: "A2A fallback" }],
},
},
};
expect(extractRequestText(body)).toBe("A2A fallback");
});
it("returns empty string when body.task is empty string", () => {
const body = { task: "" };
expect(extractRequestText(body)).toBe("");
});
it("returns empty string when body.task is not a string", () => {
const body = { task: 42 };
expect(extractRequestText(body)).toBe("");
});
});
describe("extractResponseText", () => {
@@ -161,6 +209,43 @@ describe("extractResponseText", () => {
};
expect(extractResponseText(body)).toBe("Summary\nDetail block one\nDetail block two");
});
// Regression: delegation.go stores response_body as
// {"text": "...", "delegation_id": "..."} — no "result" wrapper.
// Without body.text handling, extractResponseText returns "" for
// delegate_result rows, causing the error UI to fire even when the
// delegation succeeded (issue #159).
it("extracts from body.text (delegation response_body shape)", () => {
const body = {
text: "PR #149: tier-check fails NO REVIEWS (author needs engineers/managers/ceo approval)",
delegation_id: "delg_01jx8q4n3k",
};
expect(extractResponseText(body)).toBe(
"PR #149: tier-check fails NO REVIEWS (author needs engineers/managers/ceo approval)"
);
});
it("prefers body.result over body.text when both present", () => {
const body = {
result: { parts: [{ kind: "text", text: "A2A result wins" }] },
text: "Delegation text",
};
// result path is checked first; A2A wins when both present.
expect(extractResponseText(body)).toBe("A2A result wins");
});
it("returns empty string when body.text is empty string", () => {
expect(extractResponseText({ text: "" })).toBe("");
});
it("extracts from body.response_preview (DELEGATION_COMPLETE WS event shape)", () => {
const body = {
response_preview: "PR #149: tier-check fails NO REVIEWS (author needs engineers/managers/ceo approval)",
};
expect(extractResponseText(body)).toBe(
"PR #149: tier-check fails NO REVIEWS (author needs engineers/managers/ceo approval)"
);
});
});
describe("extractTextsFromParts", () => {
@@ -114,9 +114,15 @@ function basename(uri: string): string {
return slash >= 0 ? cleaned.slice(slash + 1) : cleaned || "file";
}
/** Extract user message text from an activity log request_body */
/** Extract user message text from an activity log request_body.
*
* Delegation activities from delegation.go store the task text directly
* at `body.task` as a plain string: {"task": "...", "delegation_id": "..."}.
* Check this first before falling back to the A2A JSON-RPC format
* (`body.params.message.parts[].text`). */
export function extractRequestText(body: Record<string, unknown> | null): string {
if (!body) return "";
if (typeof body.task === "string" && body.task) return body.task;
const params = body.params as Record<string, unknown> | undefined;
const msg = params?.message as Record<string, unknown> | undefined;
const parts = msg?.parts as Array<Record<string, unknown>> | undefined;
@@ -162,10 +168,10 @@ export function extractResponseText(body: Record<string, unknown>): string {
if (rootTexts.length > 0) collected.push(rootTexts.join("\n"));
// Task shape: {result: {artifacts: [{parts: [...]}]}}
const artifacts = result.artifacts as Array<Record<string, unknown>> | undefined;
const artifacts = result.artifacts as Array<Record<string, unknown> | undefined>;
if (artifacts) {
for (const a of artifacts) {
const t = extractTextsFromParts(a.parts);
const t = extractTextsFromParts(a?.parts);
if (t) collected.push(t);
}
}
@@ -173,6 +179,20 @@ export function extractResponseText(body: Record<string, unknown>): string {
if (collected.length > 0) return collected.join("\n");
}
// Delegation results from delegation.go store response_body as
// {"text": "...", "delegation_id": "..."} — no "result" wrapper.
// Check this after the body.result path so A2A responses take
// precedence when both shapes are somehow present.
// Without this, responseText is always "" for delegate_result rows,
// causing the error UI to fire even when the delegation succeeded
// (issue #159).
if (typeof body.text === "string" && body.text) return body.text;
// DELEGATION_COMPLETE event (via canvas-events WS handler) stores
// response_body as {response_preview: "..."}. Handle this too.
if (typeof body.response_preview === "string" && body.response_preview) {
return body.response_preview;
}
// {task: "text"} — request body format, shouldn't be in response but handle it
if (typeof body.task === "string") return body.task;
} catch { /* ignore */ }
@@ -835,3 +835,181 @@ describe("handleCanvasEvent unknown event", () => {
).not.toThrow();
});
});
// ---------------------------------------------------------------------------
// Screen-reader live announcements
// ---------------------------------------------------------------------------
describe("handleCanvasEvent liveAnnouncement", () => {
it("announces WORKSPACE_ONLINE with node name", () => {
const node = makeNode("ws-1", { name: "Alpha" });
const { get, set, state } = makeStore([node]);
handleCanvasEvent(
makeMsg({ event: "WORKSPACE_ONLINE", workspace_id: "ws-1" }),
get,
set
);
expect(state.liveAnnouncement).toBe("Alpha is now online");
});
it("announces WORKSPACE_OFFLINE with node name", () => {
const node = makeNode("ws-1", { name: "Beta" });
const { get, set, state } = makeStore([node]);
handleCanvasEvent(
makeMsg({ event: "WORKSPACE_OFFLINE", workspace_id: "ws-1" }),
get,
set
);
expect(state.liveAnnouncement).toBe("Beta is now offline");
});
it("announces WORKSPACE_PAUSED with node name", () => {
const node = makeNode("ws-1", { name: "Gamma" });
const { get, set, state } = makeStore([node]);
handleCanvasEvent(
makeMsg({ event: "WORKSPACE_PAUSED", workspace_id: "ws-1" }),
get,
set
);
expect(state.liveAnnouncement).toBe("Gamma has been paused");
});
it("announces WORKSPACE_DEGRADED with node name", () => {
const node = makeNode("ws-1", { name: "Delta" });
const { get, set, state } = makeStore([node]);
handleCanvasEvent(
makeMsg({
event: "WORKSPACE_DEGRADED",
workspace_id: "ws-1",
payload: { sample_error: "connection timeout" },
}),
get,
set
);
expect(state.liveAnnouncement).toBe("Delta is degraded");
});
it("announces WORKSPACE_PROVISIONING for new workspace with payload name", () => {
const { get, set, state } = makeStore([]);
handleCanvasEvent(
makeMsg({
event: "WORKSPACE_PROVISIONING",
workspace_id: "ws-new",
payload: { name: "NewBot" },
}),
get,
set
);
expect(state.liveAnnouncement).toBe("NewBot is provisioning");
});
it("announces WORKSPACE_PROVISIONING for new workspace with default name", () => {
const { get, set, state } = makeStore([]);
handleCanvasEvent(
makeMsg({
event: "WORKSPACE_PROVISIONING",
workspace_id: "ws-new",
payload: {},
}),
get,
set
);
expect(state.liveAnnouncement).toBe("New Workspace is provisioning");
});
it("announces WORKSPACE_REMOVED with node name", () => {
const node = makeNode("ws-1", { name: "Gamma" });
const { get, set, state } = makeStore([node]);
handleCanvasEvent(
makeMsg({ event: "WORKSPACE_REMOVED", workspace_id: "ws-1" }),
get,
set
);
expect(state.liveAnnouncement).toBe("Gamma was removed");
});
it("announces WORKSPACE_PROVISION_FAILED with node name", () => {
const node = makeNode("ws-1", { name: "Delta" });
const { get, set, state } = makeStore([node]);
handleCanvasEvent(
makeMsg({
event: "WORKSPACE_PROVISION_FAILED",
workspace_id: "ws-1",
payload: { error: "docker pull failed" },
}),
get,
set
);
expect(state.liveAnnouncement).toBe("Delta provisioning failed");
});
it("does not announce for TASK_UPDATED", () => {
const node = makeNode("ws-1", { name: "Alpha" });
const { get, set, state } = makeStore([node]);
handleCanvasEvent(
makeMsg({
event: "TASK_UPDATED",
workspace_id: "ws-1",
payload: { current_task: "building release", active_tasks: 1 },
}),
get,
set
);
// TASK_UPDATED is noisy (every heartbeat); it should not announce
expect(state.liveAnnouncement ?? "").toBe("");
});
it("does not announce for AGENT_MESSAGE", () => {
const node = makeNode("ws-1", { name: "Alpha" });
const { get, set, state } = makeStore([node]);
handleCanvasEvent(
makeMsg({
event: "AGENT_MESSAGE",
workspace_id: "ws-1",
payload: { message: "hello from the agent" },
}),
get,
set
);
expect(state.liveAnnouncement ?? "").toBe("");
});
it("uses payload name for ONLINE when node not found in store", () => {
const { get, set, state } = makeStore([]);
handleCanvasEvent(
makeMsg({
event: "WORKSPACE_ONLINE",
workspace_id: "ws-1",
payload: { name: "FromPayload" },
}),
get,
set
);
// ONLINE when node doesn't exist just buffers _pendingOnline;
// no announcement should be set
expect(state.liveAnnouncement ?? "").toBe("");
});
});
});
+19 -1
View File
@@ -80,6 +80,7 @@ export function handleCanvasEvent(
switch (msg.event) {
case "WORKSPACE_ONLINE": {
const existing = nodes.find((n) => n.id === msg.workspace_id);
const nodeName = existing?.data.name ?? (msg.payload.name as string) ?? "Workspace";
if (!existing) {
// PROVISIONING event hasn't been applied yet (WS reorder or
// this tab joined mid-deploy). Buffer so the later PROVISIONING
@@ -105,6 +106,7 @@ export function handleCanvasEvent(
? { ...n, data: { ...n.data, status: "online" } }
: n,
),
liveAnnouncement: `${nodeName} is now online`,
});
// Remove the laser class after its keyframe ends so the edge
// settles into the app's default solid styling. Fire-and-forget.
@@ -123,28 +125,36 @@ export function handleCanvasEvent(
}
case "WORKSPACE_OFFLINE": {
const offlineNode = nodes.find((n) => n.id === msg.workspace_id);
const offlineName = offlineNode?.data.name ?? "Workspace";
set({
nodes: nodes.map((n) =>
n.id === msg.workspace_id
? { ...n, data: { ...n.data, status: "offline" } }
: n
),
liveAnnouncement: `${offlineName} is now offline`,
});
break;
}
case "WORKSPACE_PAUSED": {
const pausedNode = nodes.find((n) => n.id === msg.workspace_id);
const pausedName = pausedNode?.data.name ?? "Workspace";
set({
nodes: nodes.map((n) =>
n.id === msg.workspace_id
? { ...n, data: { ...n.data, status: "paused", currentTask: "" } }
: n
),
liveAnnouncement: `${pausedName} has been paused`,
});
break;
}
case "WORKSPACE_DEGRADED": {
const degradedNode = nodes.find((n) => n.id === msg.workspace_id);
const degradedName = degradedNode?.data.name ?? "Workspace";
set({
nodes: nodes.map((n) =>
n.id === msg.workspace_id
@@ -160,6 +170,7 @@ export function handleCanvasEvent(
}
: n
),
liveAnnouncement: `${degradedName} is degraded`,
});
break;
}
@@ -230,6 +241,7 @@ export function handleCanvasEvent(
// removed per demo feedback. A2A edges (showA2AEdges) still
// render when enabled — those represent runtime traffic,
// which nesting doesn't express.
const newNodeName = (msg.payload.name as string) ?? "New Workspace";
set({
nodes: [
...nodes,
@@ -244,7 +256,7 @@ export function handleCanvasEvent(
...(parentId ? { parentId } : {}),
className: "mol-deploy-spawn",
data: {
name: (msg.payload.name as string) ?? "New Workspace",
name: newNodeName,
status: "provisioning",
tier: (msg.payload.tier as number) ?? 1,
agentCard: null,
@@ -261,6 +273,7 @@ export function handleCanvasEvent(
},
},
],
liveAnnouncement: `${newNodeName} is provisioning`,
});
// Grow the parent to fit the just-landed child. DEBOUNCED
@@ -345,6 +358,7 @@ export function handleCanvasEvent(
case "WORKSPACE_REMOVED": {
const removedNode = nodes.find((n) => n.id === msg.workspace_id);
const removedName = removedNode?.data.name ?? "Workspace";
const parentOfRemoved = removedNode?.data.parentId ?? null;
set({
nodes: nodes
@@ -363,6 +377,7 @@ export function handleCanvasEvent(
e.source !== msg.workspace_id && e.target !== msg.workspace_id
),
selectedNodeId: selectedNodeId === msg.workspace_id ? null : selectedNodeId,
liveAnnouncement: `${removedName} was removed`,
});
break;
}
@@ -445,6 +460,8 @@ export function handleCanvasEvent(
case "WORKSPACE_PROVISION_FAILED": {
const errorMsg = (msg.payload.error as string) ?? "Unknown provisioning error";
const failedNode = nodes.find((n) => n.id === msg.workspace_id);
const failedName = failedNode?.data.name ?? "Workspace";
set({
nodes: nodes.map((n) =>
n.id === msg.workspace_id
@@ -458,6 +475,7 @@ export function handleCanvasEvent(
}
: n
),
liveAnnouncement: `${failedName} provisioning failed`,
});
break;
}
+7
View File
@@ -225,6 +225,11 @@ interface CanvasState {
/** Whether the A2A topology overlay is visible. Persisted to localStorage. Default: true. */
showA2AEdges: boolean;
setShowA2AEdges: (show: boolean) => void;
/** Screen-reader announcement text. Set by handleCanvasEvent on significant
* status changes; consumed and cleared by the aria-live region in Canvas.tsx
* so the same announcement doesn't re-fire on re-render. */
liveAnnouncement: string;
setLiveAnnouncement: (msg: string) => void;
}
export const useCanvasStore = create<CanvasState>((set, get) => ({
@@ -321,6 +326,8 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
localStorage.setItem("molecule:show-a2a-edges", String(show));
}
},
liveAnnouncement: "",
setLiveAnnouncement: (msg) => set({ liveAnnouncement: msg }),
viewport: { x: 0, y: 0, zoom: 1 },
+16
View File
@@ -7,6 +7,22 @@ export default defineConfig({
test: {
environment: 'node',
exclude: ['e2e/**', 'node_modules/**', '**/dist/**'],
// Issue #22 / vitest pool investigation:
//
// The forks pool spawns one Node.js worker per concurrent slot.
// Each jsdom-environment worker bootstraps a full DOM (~30-50 MB resident
// set) at cold-start. With the default maxWorkers derived from CPU
// count, multiple jsdom workers can start simultaneously, exhausting
// memory on the 2-CPU Gitea Actions runner and causing pool workers to
// fail to respond with "[vitest-pool]: Timeout starting … runner."
//
// Fix: cap maxWorkers at 1 so only one worker is alive at any time.
// Tests still run in parallel within that single worker's process (via
// node's EventLoop) — this is the same parallelism as the `threads`
// pool but without the per-worker jsdom cold-start overhead. 51 test
// files that previously took 5070 s with 5 failures now run
// sequentially through one worker, eliminating the memory spike.
maxWorkers: 1,
// CI-conditional test timeout (issue #96).
//
// Vitest's 5000ms default is too tight for the first test in any
+1 -1
View File
@@ -119,7 +119,7 @@ services:
networks:
default:
name: molecule-monorepo-net
name: molecule-core-net
external: true
volumes:
+15 -11
View File
@@ -1,3 +1,7 @@
# Include infra services (Temporal, Langfuse) so `docker compose up` starts the full stack.
include:
- docker-compose.infra.yml
services:
# --- Infrastructure ---
postgres:
@@ -12,7 +16,7 @@ services:
volumes:
- pgdata:/var/lib/postgresql/data
networks:
- molecule-monorepo-net
- molecule-core-net
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-dev}"]
@@ -40,7 +44,7 @@ services:
psql -h postgres -U "$${POSTGRES_USER}" -d postgres -c "CREATE DATABASE langfuse"
fi
networks:
- molecule-monorepo-net
- molecule-core-net
redis:
image: redis:7-alpine
@@ -50,7 +54,7 @@ services:
volumes:
- redisdata:/data
networks:
- molecule-monorepo-net
- molecule-core-net
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
@@ -68,7 +72,7 @@ services:
volumes:
- clickhousedata:/var/lib/clickhouse
networks:
- molecule-monorepo-net
- molecule-core-net
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://127.0.0.1:8123/ping || exit 1"]
interval: 5s
@@ -97,7 +101,7 @@ services:
ports:
- "3001:3000"
networks:
- molecule-monorepo-net
- molecule-core-net
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3000/api/public/health || exit 1"]
interval: 10s
@@ -217,7 +221,7 @@ services:
ports:
- "${PLATFORM_PUBLISH_PORT:-8080}:${PLATFORM_PORT:-8080}"
networks:
- molecule-monorepo-net
- molecule-core-net
restart: unless-stopped
healthcheck:
# Plain GET — `--spider` would issue HEAD, which returns 404 because
@@ -258,7 +262,7 @@ services:
ports:
- "${CANVAS_PUBLISH_PORT:-3000}:${CANVAS_PORT:-3000}"
networks:
- molecule-monorepo-net
- molecule-core-net
healthcheck:
test: ["CMD-SHELL", "wget -qO /dev/null --tries=1 http://127.0.0.1:${CANVAS_PORT:-3000} || exit 1"]
interval: 10s
@@ -291,7 +295,7 @@ services:
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY:-sk-molecule}
networks:
- molecule-monorepo-net
- molecule-core-net
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:4000/health || exit 1"]
@@ -316,7 +320,7 @@ services:
volumes:
- ollamadata:/root/.ollama
networks:
- molecule-monorepo-net
- molecule-core-net
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "ollama list || exit 1"]
@@ -326,8 +330,8 @@ services:
start_period: 20s
networks:
molecule-monorepo-net:
name: molecule-monorepo-net
molecule-core-net:
name: molecule-core-net
volumes:
pgdata:
+1 -1
View File
@@ -67,7 +67,7 @@ On-demand fits naturally with how agents work — an agent only needs to know ab
This is acceptable for MVP because:
- All workspaces are provisioned by the same platform on trusted infrastructure
- Docker network isolation (`molecule-monorepo-net`) limits who can reach workspace endpoints
- Docker network isolation (`molecule-core-net`) limits who can reach workspace endpoints
- The tool is self-hosted — the operator controls the network
**Known gap:** Once workspace A caches workspace B's URL, nothing stops A from calling B directly even after the hierarchy changes and A is no longer supposed to reach B. The cached URL remains valid until the container is restarted or the URL changes.
+2 -2
View File
@@ -124,7 +124,7 @@ Six runtime adapters ship production-ready on `main`: LangGraph, DeepAgents, Cla
| Platform ↔ Redis | TCP | Ephemeral state (liveness TTL), caching, pub/sub |
| Workspace ↔ Workspace | HTTP (A2A JSON-RPC 2.0) | Direct peer-to-peer, **platform not in data path** |
| Workspace → Langfuse | HTTP | Automatic OpenTelemetry tracing |
| Docker Network | `molecule-monorepo-net` | Internal-only by default, no exposed DB/Redis ports |
| Docker Network | `molecule-core-net` | Internal-only by default, no exposed DB/Redis ports |
### Core Components
@@ -465,7 +465,7 @@ Unknown tier values default to T2 for safety. Applied via `provisioner.ApplyTier
### Docker Networking
- All containers join `molecule-monorepo-net` private network
- All containers join `molecule-core-net` private network
- Container naming: `ws-{workspace_id[:12]}`
- Ephemeral host port binding: `127.0.0.1:0→8000/tcp`
+2 -2
View File
@@ -19,7 +19,7 @@ The provisioner is the platform component that deploys workspace containers and
## Docker Networking (Tier 1-3, Tier 4 uses host)
All workspace containers join the `molecule-monorepo-net` Docker network. Containers are named `ws-{id[:12]}` (first 12 chars of workspace UUID). Two exported helpers in `provisioner` package provide the canonical naming:
All workspace containers join the `molecule-core-net` Docker network. Containers are named `ws-{id[:12]}` (first 12 chars of workspace UUID). Two exported helpers in `provisioner` package provide the canonical naming:
- `provisioner.ContainerName(workspaceID)``ws-{id[:12]}`
- `provisioner.InternalURL(workspaceID)``http://ws-{id[:12]}:8000`
@@ -38,7 +38,7 @@ This URL is pre-stored in both Postgres and Redis before the agent registers. Wh
**Why not use Docker-internal URLs?** In local dev, the platform runs on the host (not in Docker), so it cannot resolve Docker container hostnames. The ephemeral port mapping lets the A2A proxy reach agents via localhost. In production (platform in Docker), the Docker-internal URL (`http://ws-{id}:8000`) would work directly.
**Workspace-to-workspace discovery:** When a workspace discovers another workspace (via `X-Workspace-ID` header on `GET /registry/discover/:id`), the platform returns the Docker-internal URL (`http://ws-{first12chars}:8000`) so containers can reach each other directly on `molecule-monorepo-net`. The internal URL is cached in Redis at provision time and also synthesized as a fallback if the cache misses (only for online/degraded workspaces).
**Workspace-to-workspace discovery:** When a workspace discovers another workspace (via `X-Workspace-ID` header on `GET /registry/discover/:id`), the platform returns the Docker-internal URL (`http://ws-{first12chars}:8000`) so containers can reach each other directly on `molecule-core-net`. The internal URL is cached in Redis at provision time and also synthesized as a fallback if the cache misses (only for online/degraded workspaces).
For external HTTPS access (multi-host mode), Nginx on the host handles TLS termination and proxies to the container.
+119
View File
@@ -0,0 +1,119 @@
# Canvas Architecture Audit — VERIFIED
> **Status:** VERIFIED — Cross-referenced against molecule-core/canvas/src/ (2026-05-09)
> **Author:** Core-FE (draft), Core-UIUX (verification)
> **Updated:** 2026-05-09 with architecture structure + known issues
## Canvas Stack (Verified)
| Technology | Version | Purpose |
|-----------|--------|---------|
| React Flow | `@xyflow/react` v12 | Node/edge rendering |
| Framework | Next.js 14 App Router | Routing, SSR |
| Styling | Tailwind v4 | CSS with custom properties |
| State | Zustand | Client state management |
## Directory Structure (Verified)
```
canvas/src/
├── components/
│ ├── Canvas.tsx # Viewport management, ReactFlow wrapper
│ ├── Toolbar.tsx # Add node/edge controls
│ ├── ContextMenu.tsx # Right-click menu
│ ├── SidePanel.tsx # Properties panel
│ ├── WorkspaceNode.tsx # Node rendering
│ ├── A2AEdge.tsx # Edge rendering
│ └── [tests]/ # Accessibility + component tests
├── stores/
│ └── secrets-store.ts # ⚠️ getGrouped() performance issue
├── hooks/
│ ├── useSocketEvent.ts
│ ├── useTemplateDeploy.tsx
│ └── useWorkspaceName.ts
└── lib/
├── api.ts
├── auth.ts
├── canvas-actions.ts
├── design-tokens.ts # STATUS_CONFIG, TIER_CONFIG
├── theme.ts
└── theme-provider.tsx # ThemeProvider, useTheme()
## Known Issues
### ✅ MEDIUM: secrets-store.ts Performance (mitigated)
**File:** `canvas/src/stores/secrets-store.ts`
**Issue:** `getGrouped()` selector creates new objects every call. Not memoized.
**Impact:** Mitigated — `SecretsTab.tsx` wraps the call in `useMemo`, so no active re-render issues in the single consumer. The store-level fix (memoizing `getGrouped` itself) is optional but low priority now.
### 🟡 MEDIUM: Pre-commit Hook Verification
**Issue:** Pre-commit hook checks 'use client' on hook-using components but unclear if it actually fails on violations.
**Action:** Verify the hook is enforcing the rule correctly.
## Verified Findings
### Node Rendering ✅ (with notes)
- **Framework:** `@xyflow/react` (React Flow) — DOM-based, not SVG/Canvas
- **Node selection:** `aria-pressed` + border ring (`border-accent/70`) + shadow
- **Node drag:** React Flow native drag — mouse only, no keyboard alternative yet
- **Node resize:** `NodeResizer` component visible on selected card, keyboard-inaccessible
- **Status:** Accessible via `aria-label` on node cards — "Alpha Workspace workspace — online"
### Edge Wiring ✅
- **Edge rendering:** React Flow SVG paths
- **Edge click target:** 1.5px stroke (CSS `stroke-width: 1.5 !important` in globals.css)
- **Edge creation:** React Flow drag-from-handle
- **Edge anchors:** Visible on hover (`hover:!bg-blue-400`), not keyboard accessible
- **Status:** Partial — mouse users only
### Canvas Controls ✅
- **Zoom:** React Flow Controls component (verify if keyboard accessible)
- **Pan:** Space+drag, mouse drag
- **Minimap:** Not present (MiniMap mocked as null in tests)
- **Status:** Basic keyboard support via viewport shortcuts
### Keyboard Shortcuts ⚠️ PARTIAL
- Exists in `useKeyboardShortcuts.ts` but no `aria-describedby` on trigger buttons
- No dedicated keyboard shortcut help dialog
- **Gap:** Users can't discover shortcuts visually
### Focus Management ✅ (strong)
- Skip link → `#canvas-main` ✅
- `aria-label` on ReactFlow container ✅
- Focus trap in modals via Radix ✅
- Focus ring: `focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950`
### Accessibility Tree ⚠️ PARTIAL
- Canvas is in accessibility tree (React Flow DOM nodes)
- Node state changes not announced to screen readers (no `aria-live` region)
- Context menus announced via `role="menu"` ✅
### Context Menus ✅ (strong)
- `role="menu"`, `role="menuitem"`, `role="separator"` ✅
- `aria-label` with workspace name ✅
- ArrowUp/Down navigation with wrap-around ✅
- Escape + Tab close menu ✅
- Auto-focus first item on open ✅
### Drag and Drop ⚠️ PARTIAL
- **Mouse drag:** React Flow native
- **Drop target:** Visual indicator (`bg-emerald-950/40 border-emerald-400/60`) ✅
- **Keyboard alternative:** None — nodes repositioned only via mouse drag
- **Status:** Mouse-only. Keyboard users cannot rearrange nodes.
---
## Remaining Gaps (Priority Order)
| Priority | Item | Files | Status |
|----------|------|-------|--------|
| ~~HIGH~~ | ~~Screen reader announcements for canvas state changes~~ | ~~Canvas.tsx, canvas-events.ts, canvas.ts~~ | ✅ Done — PR #172 |
| MEDIUM | Keyboard shortcut help dialog | useKeyboardShortcuts.ts | ✅ Done (PR #175) |
| MEDIUM | Keyboard-accessible node drag | WorkspaceNode.tsx, useDragHandlers.ts | Not started |
| LOW | Edge anchor keyboard accessibility | A2AEdge.tsx | Not started |
| LOW | Node resize keyboard accessibility | WorkspaceNode.tsx (NodeResizer) | Not started |
---
*Verified 2026-05-09 by Core-UIUX against molecule-core/canvas/src/*
*Updated 2026-05-09: screen reader announcements (PR #172) + keyboard shortcut dialog (PR #175) completed*
@@ -0,0 +1,424 @@
# Canvas Design System v1 — VERIFIED
> **Status:** VERIFIED — Cross-referenced against molecule-core/canvas/src/ (2026-05-09)
> **Authors:** Core-FE (draft), Core-UIUX (verification + updates)
> **Source files verified:**
> - `canvas/src/app/globals.css`
> - `canvas/src/styles/theme-tokens.css`
> - `canvas/src/lib/design-tokens.ts`
> - `canvas/src/components/Tooltip.tsx`
> - `canvas/src/components/ContextMenu.tsx`
> - `canvas/src/components/Canvas.tsx`
> - `canvas/src/components/__tests__/Canvas.a11y.test.tsx`
> - `canvas/src/components/__tests__/ContextMenu.keyboard.test.tsx`
> - `canvas/src/components/__tests__/MissingKeysModal.a11y.test.tsx`
> - `canvas/src/components/__tests__/ConversationTraceModal.a11y.test.tsx`
---
## 1. Color Palette — Three-Mode Theme System
Canvas supports **three themes**: System (follows OS), Light, Dark. Controlled via `ThemeProvider` in `theme-provider.tsx` with preference persisted in `mol_theme` cookie.
**Key principle: Use semantic tokens, NOT raw zinc values for surfaces.**
### 1.1 Theme-Mutable Tokens (use these for surfaces)
Defined in `globals.css` via Tailwind v4 `@theme` block. Automatically flip between light/dark.
**Light theme (warm paper):**
| Token | Tailwind Class | Hex | Usage |
|-------|--------------|-----|-------|
| `--color-surface` | `bg-surface` | `#fafaf7` | Page background |
| `--color-surface-elevated` | `bg-surface-elevated` | `#ffffff` | Elevated cards, modals |
| `--color-surface-sunken` | `bg-surface-sunken` | `#f3f1ec` | Input fields, recessed areas |
| `--color-surface-card` | `bg-surface-card` | `#efece4` | Node cards, chips |
| `--color-line` | `border-line` | `#e6e2d8` | Dividers, borders |
| `--color-line-soft` | `border-line-soft` | `#efece4` | Subtle dividers |
| `--color-ink` | `text-ink` | `#15181c` | Primary text |
| `--color-ink-mid` | `text-ink-mid` | `#5a5e66` | Secondary text |
| `--color-ink-soft` | `text-ink-soft` | `#8b8e95` | Tertiary text, placeholders |
| `--color-accent` | `text-accent` | `#3b5bdb` | Links, primary actions |
| `--color-accent-strong` | `text-accent-strong` | `#1a2f99` | Emphasized accent |
| `--color-warm` | `text-warm` | `#c0532b` | Warnings |
| `--color-good` | `text-good` | `#2f7a4d` | Success states |
| `--color-bad` | `text-bad` | `#b94e4a` | Error states |
**Dark theme:**
| Token | Hex | Usage |
|-------|-----|-------|
| `--color-surface` | `#0e1014` | Page background |
| `--color-surface-elevated` | `#15181c` | Elevated cards |
| `--color-surface-sunken` | `#0a0b0e` | Input fields |
| `--color-surface-card` | `#1a1d23` | Node cards |
| `--color-line` | `#2a2f3a` | Dividers |
| `--color-ink` | `#f4f1e9` | Primary text |
| `--color-ink-mid` | `#c8c2b4` | Secondary text |
| `--color-ink-soft` | `#8d92a0` | Tertiary text |
| `--color-accent` | `#6883e8` | Links (brighter for AA contrast) |
| `--color-accent-strong` | `#8aa1ee` | Emphasized accent |
| `--color-warm` | `#d96f48` | Warnings |
| `--color-good` | `#4ca06e` | Success |
| `--color-bad` | `#d27773` | Errors |
### 1.2 Always-Dark Tokens (terminal surfaces)
Terminals, console modal, log streams **stay dark** in all themes — readable green-on-black doesn't translate to light.
| Token | Tailwind Class | Hex | Usage |
|-------|--------------|-----|-------|
| `--color-bg` | `bg-bg` | `rgb(9 9 11)` / zinc-950 | Terminal background |
| `--color-bg-elev` | `bg-bg-elev` | `rgb(24 24 27)` / zinc-900 | Elevated terminal surfaces |
| `--color-bg-card` | `bg-bg-card` | `rgb(39 39 42)` / zinc-800 | Terminal cards |
| `--color-line-strong` | `border-line-strong` | `rgb(63 63 70)` / zinc-700 | Strong borders |
| `--color-ink-mute` | `text-ink-mute` | `rgb(161 161 170)` / zinc-400 | Muted text |
| `--color-ink-dim` | `text-ink-dim` | `rgb(113 113 122)` / zinc-500 | Dim text |
### 1.3 Raw Zinc Usage Rules
**Use raw zinc for:**
- Borders: `border-zinc-700`, `border-zinc-800`
- Disabled states: `text-zinc-600`, `bg-zinc-800`
- Code highlighting: `bg-zinc-900`, `text-zinc-300`
- Terminal surfaces: `bg-zinc-950` (always-dark)
**NEVER use for surfaces:**
- `bg-zinc-900` or `bg-zinc-950` as page/card backgrounds — use `bg-surface`
- `text-zinc-50` or `text-zinc-100` as primary text — use `text-ink`
- `bg-white`, `bg-gray-50/100` for surfaces — use semantic tokens
### 1.4 Accessibility Contrast
| Pair | Ratio | WCAG |
|------|-------|------|
| `text-ink` on `bg-surface` (light) | ~14.5:1 | AAA |
| `text-ink` on `bg-surface` (dark) | ~15.8:1 | AAA |
| `text-ink-mid` on `bg-surface` (light) | ~5.2:1 | AA |
| `text-ink-mid` on `bg-surface` (dark) | ~5.9:1 | AA |
| `text-accent` on `bg-surface` (light) | ~4.8:1 | AA |
| `text-accent` on `bg-surface` (dark) | ~4.6:1 | AA |
---
## 2. Typography Scale
**Actual font stack** (from `globals.css`):
```
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif
```
No custom fonts loaded — uses OS-native system stack.
| Size Token | Tailwind | Usage |
|------------|----------|-------|
| `text-[10px]` | 10px | Micro badges, tier labels |
| `text-[11px]` | 11px | Tooltip text |
| `text-xs` / `text-[12px]` | 12px | Badges, timestamps |
| `text-sm` / `text-[13px]` | 1314px | Secondary labels, node titles |
| `text-base` / `text-[16px]` | 16px | Body text |
| `text-lg` | 18px | Section headers |
| `text-xl` | 20px | Modal titles |
**Line height:** `leading-tight` (1.25) for headings, `leading-relaxed` (1.625) for body/tooltips.
---
## 3. Animation / Motion Tokens
**Defined in `canvas/src/styles/theme-tokens.css`** — use these, don't hardcode ms values.
| Token | Value | Usage |
|-------|-------|-------|
| `--mol-duration-fast` | 150ms | Hover states, button feedback |
| `--mol-duration-base` | 300ms | Standard transitions |
| `--mol-duration-spawn` | 350ms | Node spawn animation |
| `--mol-duration-root-complete` | 700ms | Org-deploy root glow |
| `--mol-duration-fit-view` | 800ms | Canvas fit-viewport |
| Token | Value | Usage |
|-------|-------|-------|
| `--mol-easing-standard` | `cubic-bezier(0.2, 0, 0, 1)` | Default ease |
| `--mol-easing-bounce-out` | `cubic-bezier(0.2, 0.8, 0.2, 1.05)` | Node spawn bounce |
| `--mol-easing-emphasize` | `cubic-bezier(0.3, 0, 0, 1)` | Modal/drawer enter |
**CSS usage:**
```css
/* Good — reference the token */
transition: all var(--mol-duration-fast) ease;
/* Bad — hardcoded value */
transition: all 150ms ease;
```
---
## 4. Component Patterns (Verified)
### 4.1 Buttons
```tsx
// Primary — accent background, ink text
<button className="bg-accent hover:bg-accent/90 active:scale-95
text-ink px-4 py-2 rounded-md text-sm font-medium
focus-visible:ring-2 focus-visible:ring-blue-500
focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-900
disabled:opacity-50 disabled:cursor-not-allowed">
Primary
</button>
// Secondary — surface-card background, border-line
<button className="bg-surface-card hover:bg-surface-elevated border border-line
text-ink px-4 py-2 rounded-md text-sm font-medium
focus-visible:ring-2 focus-visible:ring-blue-500
focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-900">
Secondary
</button>
// Ghost — no background, hover surface
<button className="hover:bg-surface-card text-ink-mid hover:text-ink
px-4 py-2 rounded-md text-sm font-medium">
Ghost
</button>
// Danger — bad color, requires confirmation dialog
<button className="bg-bad hover:bg-bad/90 text-white px-4 py-2
rounded-md text-sm font-medium">
Delete
</button>
```
**States:** default, hover, active (`scale-95`), focus (`ring-2 ring-blue-500 ring-offset-2 ring-offset-zinc-900`), disabled (`opacity-50 cursor-not-allowed`).
### 4.2 Inputs
```tsx
// Text input — use semantic tokens for surfaces
<input
className="bg-surface-sunken border border-line text-ink
placeholder:text-ink-soft px-3 py-2 rounded-md text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500
focus:border-transparent
disabled:opacity-50 disabled:cursor-not-allowed"
placeholder="Enter workspace name"
/>
// Error state
<input
className="border-bad focus:ring-bad"
aria-invalid="true"
aria-describedby="error-message"
/>
```
**Label:** `text-sm font-medium text-ink mb-1`
**Error:** `text-xs text-bad mt-1`
### 4.3 Cards
```tsx
// Workspace node card (from WorkspaceNode.tsx)
<div className="bg-surface-sunken/90 border border-line/80
rounded-xl p-3.5 py-2.5
hover:border-zinc-500/60 shadow-lg shadow-black/30
focus-visible:ring-2 focus-visible:ring-accent/70
focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-950">
```
### 4.4 Modals (Radix Dialog)
```tsx
// Backdrop
<div className="fixed inset-0 bg-black/70 backdrop-blur-sm z-50"
aria-hidden="true" />
// Dialog — use surface-card + border-line
<div className="bg-surface-card border border-line rounded-xl
shadow-2xl p-6 max-w-md w-full mx-4">
{/* Modal content */}
</div>
```
Note: Uses `--color-surface-sunken` for sunken areas (node cards). Cards use `bg-surface-card`.
**Important:** Use `@radix-ui/react-dialog` — it provides WCAG 2.1 compliance automatically (focus trap, Escape key, aria-modal, aria-labelledby).
### 4.5 Tooltips
**Verified implementation** (`canvas/src/components/Tooltip.tsx`):
```tsx
// Trigger wraps children
<span aria-describedby="tooltip-id">
{children}
</span>
// Tooltip portal (shows on hover + focus, 400ms delay)
<div id="tooltip-id"
role="tooltip"
className="fixed z-[9999] max-w-[400px] max-h-[300px] overflow-y-auto
px-3 py-2 bg-surface-card border border-line
rounded-lg shadow-2xl shadow-black/60 pointer-events-none">
<div className="text-[11px] text-ink whitespace-pre-wrap break-words leading-relaxed">
{text}
</div>
</div>
```
**WCAG 1.4.13 compliance:** Escape key dismisses tooltip without moving pointer/focus.
### 4.6 Theme Switching
Use `useTheme()` hook from `theme-provider.tsx`:
```tsx
import { useTheme } from "@/lib/theme-provider";
function ThemeToggle() {
const { theme, resolvedTheme, setTheme } = useTheme();
return (
<select
value={theme}
onChange={(e) => setTheme(e.target.value as ThemePreference)}
>
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
);
}
```
**Theme types:**
```ts
type ThemePreference = "system" | "light" | "dark";
type ResolvedTheme = "light" | "dark";
```
**Cookie:** `mol_theme` with `Domain=.moleculesai.app` — persists across surfaces.
---
## 5. Accessibility Rules (WCAG 2.1 AA) — VERIFIED
### 5.1 Focus Management ✅ VERIFIED
- All interactive elements have `focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950`
- No `outline-none` without equivalent focus ring
- Radix Dialog traps focus automatically
### 5.2 Semantic HTML ✅ VERIFIED
- Buttons use `<button>` — verified in WorkspaceNode.tsx, ContextMenu.tsx
- Form inputs have associated `<label>` patterns
- Radix Dialog provides role="dialog" + aria-modal
### 5.3 ARIA ✅ VERIFIED
- Icon-only buttons: `aria-label` with descriptive text (not "X")
- Example: `aria-label="Extract ${name} from team"` in WorkspaceNode.tsx
- Live regions: `aria-live="polite"` on Toast component
- Modals: Radix provides `role="dialog"`, `aria-modal="true"`, `aria-labelledby`
- Error messages: `aria-invalid="true"`, `aria-describedby` linking to error text
- Tooltips: `role="tooltip"` + `aria-describedby` on trigger
### 5.4 Keyboard Navigation ✅ VERIFIED
- ContextMenu: ArrowUp/Down wraps, Enter/Space selects, Escape closes, Tab closes
- Modals: Escape closes (Radix), focus returns to trigger
- `prefers-reduced-motion` ✅ (verified in globals.css)
### 5.5 Color Independence ✅
- Status indicators use text labels + icons, not color alone
- `STATUS_CONFIG` has text labels: "Online", "Offline", "Failed", etc.
---
## 6. React Flow Canvas Specifics
Canvas uses `@xyflow/react` (React Flow).
### Canvas Container ✅ VERIFIED
```tsx
// Canvas.tsx wraps ReactFlow with:
<ReactFlow
aria-label="Molecule AI workspace canvas"
// ...
/>
```
### Node Accessibility ✅ VERIFIED
- `role="button"` on workspace node cards
- `tabIndex={0}` for keyboard focus
- `aria-pressed` for selection state
- `aria-label` with workspace name + status
### Skip Link ✅ VERIFIED
```tsx
<a href="#canvas-main">Skip to canvas</a>
<main id="canvas-main" role="main">
```
---
## 7. Enforcement Checklist
### Color Token Rules
- [x] No `bg-white` / `bg-zinc-50` for surfaces — use `bg-surface`
- [x] No `text-zinc-50` / `text-zinc-100` for surfaces — use `text-ink`
- [x] No `bg-zinc-900` / `bg-zinc-950` for surfaces — use `bg-surface` or `bg-surface-card`
- [x] Raw zinc OK for: borders, disabled states, code, terminal surfaces
### Accessibility Rules
- [x] All buttons have focus rings (verified in tests)
- [x] All modals use Radix Dialog (verified)
- [x] All tooltips use `role="tooltip"` + `aria-describedby` (verified)
- [x] No `outline-none` without focus ring (verified)
- [x] All inputs have visible labels (verified pattern)
- [x] Contrast ratios at 4.5:1 minimum (verified above)
- [x] `prefers-reduced-motion` suppresses all animations (verified in globals.css)
- [x] Context menu has keyboard navigation (verified in ContextMenu.keyboard.test.tsx)
- [x] Theme switching works: System/Light/Dark modes verified
---
## 8. Canvas Architecture (Verified)
**Stack:**
- `@xyflow/react` v12 (React Flow) — node/edge rendering
- Next.js 14 App Router
- Tailwind v4 with CSS custom properties
- Zustand for state management
**Directory Structure:**
```
canvas/src/
├── components/ # Canvas.tsx, Toolbar.tsx, ContextMenu.tsx, SidePanel.tsx, WorkspaceNode.tsx, A2AEdge.tsx
├── stores/ # secrets-store.ts (only store)
├── hooks/ # useSocketEvent.ts, useTemplateDeploy.tsx, useWorkspaceName.ts
├── lib/ # api.ts, auth.ts, canvas-actions.ts, design-tokens.ts, theme.ts, theme-provider.tsx
└── app/ # Next.js App Router
```
## 9. Known Issues (Technical Debt)
### Performance Issues
- **secrets-store.ts getGrouped() selector** — Creates new objects every call (Object.fromEntries + arrays) — not memoized. Causes performance issues with frequent re-renders. Needs selector optimization.
### Code Quality
- Check for `any` types in canvas/ directory
- Verify pre-commit hook actually fails on 'use client' violations (unverified)
- Verify all Zustand selectors avoid object creation (see getGrouped issue above)
- Check 'use client' directive on hook-using components
### Testing
- Add axe-core integration for automated accessibility testing
- Visual regression tests — no screenshot tests exist yet (KI-006)
- Target >80% test coverage on changed files
## 10. Remaining Open Items
### Accessibility Gaps
1. **Screen reader announcements** — Node/edge changes not announced. Need `aria-live="polite"` region.
2. **Keyboard shortcut help dialog** — No dedicated dialog. Shortcuts exist in `useKeyboardShortcuts.ts` but no `aria-describedby` hints on buttons.
3. **Edge anchor accessibility** — React Flow handles purely visual. Need ARIA annotations for screen readers.
4. **Drag-and-drop keyboard alternative** — Mouse only. Need keyboard equivalent for node rearrangement.
### Performance
5. **secrets-store.ts getGrouped()** — Not memoized, creates new objects every call.
+1 -1
View File
@@ -73,7 +73,7 @@ These are applied after CORS middleware on every response.
## 14. No Exposed Database Ports
Postgres and Redis must not expose host ports. They communicate exclusively over the internal Docker network (`molecule-monorepo-net`). Use `docker compose exec` for direct access during development.
Postgres and Redis must not expose host ports. They communicate exclusively over the internal Docker network (`molecule-core-net`). Use `docker compose exec` for direct access during development.
## Related Docs
@@ -73,19 +73,19 @@ runner-wide setting, not per-job. Source: gitea/act_runner config docs
Flipping the global `container.network` to `bridge` would break every
other workflow in the repo (cache server discovery,
`molecule-monorepo-net` peer access during integration tests, etc.) —
`molecule-core-net` peer access during integration tests, etc.) —
unacceptable blast radius for a per-test bug.
## Fix shape
`handlers-postgres-integration.yml` no longer uses `services: postgres:`.
It launches a sibling postgres container manually on the existing
`molecule-monorepo-net` bridge network with a per-run unique name:
`molecule-core-net` bridge network with a per-run unique name:
```yaml
env:
PG_NAME: pg-handlers-${{ github.run_id }}-${{ github.run_attempt }}
PG_NETWORK: molecule-monorepo-net
PG_NETWORK: molecule-core-net
steps:
- name: Start sibling Postgres on bridge network
@@ -117,7 +117,7 @@ host-network runner config. Translate using this same pattern:
1. Drop the `services:` block.
2. Use `${{ github.run_id }}-${{ github.run_attempt }}` for unique
container name.
3. Launch on `molecule-monorepo-net` (already trusted bridge in
3. Launch on `molecule-core-net` (already trusted bridge in
`docker-compose.infra.yml`).
4. Read back the bridge IP via `docker inspect` and export as a step env.
5. `if: always()` cleanup step at the end.
@@ -131,7 +131,7 @@ in one place.
- Issue #88 (closed by #92): localhost → 127.0.0.1 fix that unmasked
this collision; the IPv6 fix is correct, port collision is the new
layer.
- Issue #94 created `molecule-monorepo-net` + `alpine:latest` as
- Issue #94 created `molecule-core-net` + `alpine:latest` as
prereqs.
- Saved memory `feedback_act_runner_github_server_url` documents
another act_runner-vs-GHA divergence (server URL).
+1 -1
View File
@@ -5,7 +5,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
echo "==> Ensuring shared docker network exists..."
docker network create molecule-monorepo-net 2>/dev/null || true
docker network create molecule-core-net 2>/dev/null || true
# Populate the template / plugin registry.
# workspace-configs-templates/, org-templates/, and plugins/ are intentionally
+14 -1
View File
@@ -268,10 +268,11 @@ molecule-mcp = "molecule_runtime.mcp_cli:main"
[tool.setuptools.packages.find]
where = ["."]
include = ["molecule_runtime*"]
include = ["molecule_runtime*", "plugins_registry*"]
[tool.setuptools.package-data]
"molecule_runtime" = ["py.typed"]
"plugins_registry" = ["py.typed"]
"""
@@ -473,6 +474,18 @@ def main() -> int:
py_files = copy_tree_filtered(src, pkg_dir)
print(f"[build] copied {len(py_files)} .py files")
# Install plugins_registry/ at the wheel TOP LEVEL so that plugin adapter
# code (workspace-template-*) can use bare `from plugins_registry import ...`.
# The molecule-runtime package (molecule_runtime/) also ships it at
# molecule_runtime/plugins_registry/ (satisfies the rewritten
# `from molecule_runtime.plugins_registry import ...` in adapter_base.py).
# Both copies coexist: they serve different import namespaces.
plugins_src = src / "plugins_registry"
plugins_dst = out / "plugins_registry"
if plugins_src.is_dir():
shutil.copytree(plugins_src, plugins_dst)
print(f"[build] installed plugins_registry/ at top level (bare-import shim)")
# Ensure top-level package marker exists. workspace/ doesn't have one
# (it's not a package in monorepo), but the published artifact must.
init = pkg_dir / "__init__.py"
+1 -1
View File
@@ -24,7 +24,7 @@ echo "=== NUKE ==="
docker compose -f "$ROOT/docker-compose.yml" down -v 2>/dev/null || true
docker ps -a --format "{{.Names}}" | grep "^ws-" | xargs -r docker rm -f 2>/dev/null || true
docker volume ls --format "{{.Name}}" | grep "^ws-" | xargs -r docker volume rm 2>/dev/null || true
docker network rm molecule-monorepo-net 2>/dev/null || true
docker network rm molecule-core-net 2>/dev/null || true
echo " cleaned"
echo "=== POPULATE MANIFEST DIRS ==="
@@ -490,7 +490,14 @@ func (h *WorkspaceHandler) proxyA2ARequest(ctx context.Context, workspaceID stri
if logActivity && deliveryConfirmed {
h.logA2ASuccess(ctx, workspaceID, callerID, body, respBody, a2aMethod, resp.StatusCode, durationMs)
}
return 0, nil, &proxyA2AError{
// Preserve the actual HTTP status code and any body bytes already read.
// Previously this returned (0, nil, error) which discarded both.
// Preserving them allows executeDelegation's new condition
// proxyErr != nil && len(respBody) > 0 && status >= 200 && status < 300
// to correctly route delivery-confirmed responses (where the agent completed
// the work but the TCP connection dropped before the full body was received)
// to success instead of failure (#159).
return resp.StatusCode, respBody, &proxyA2AError{
Status: http.StatusBadGateway,
Response: gin.H{
"error": "failed to read agent response",
@@ -342,6 +342,18 @@ func (h *DelegationHandler) executeDelegation(sourceID, targetID, delegationID s
}
}
// When proxyA2ARequest returns an error but we have a non-empty response body
// with a 2xx status code, the agent completed the work successfully — the error
// is a delivery/transport error (e.g., connection reset after response was
// received). Treat as success: the response body is valid and the work is done.
// This prevents "retry storms" where the canvas sees error + Restart-workspace
// suggestion even though the delegation actually completed.
if isDeliveryConfirmedSuccess(proxyErr, status, respBody) {
log.Printf("Delegation %s: completed with delivery error (status=%d, respBody=%d bytes, proxyErr=%v) — treating as success",
delegationID, status, len(respBody), proxyErr.Error())
goto handleSuccess
}
if proxyErr != nil {
log.Printf("Delegation %s: failed — %s", delegationID, proxyErr.Error())
h.updateDelegationStatus(sourceID, delegationID, "failed", proxyErr.Error())
@@ -361,6 +373,8 @@ func (h *DelegationHandler) executeDelegation(sourceID, targetID, delegationID s
return
}
handleSuccess:
// 202 + {queued: true} means the target was busy and the proxy
// enqueued the request for the next drain tick — NOT a completion.
// Treat it as such: write a clean 'queued' activity row with no
@@ -671,6 +685,34 @@ func isTransientProxyError(err *proxyA2AError) bool {
return false
}
// isDeliveryConfirmedSuccess reports whether the proxy's `(status, body, err)`
// triple represents a delivery-confirmed success: the proxy hit a transport-
// layer error AFTER receiving a complete 2xx response with a non-empty body.
// In that case the agent did the work — the error is on the wire, not in the
// agent — so the delegation should be marked succeeded rather than failed
// (preventing the retry-storm + restart-suggest cascade described in #159).
//
// Caller invariants:
// - proxyErr != nil: a delivery error fired (e.g. connection reset).
// - len(respBody) > 0: a response body was received before the error.
// - 200 <= status < 300: the partial response carried a 2xx code.
//
// All three must hold. nil proxyErr → no decision to make (success path
// already chosen upstream). Empty body → no work-result to recover. Non-2xx →
// the agent itself signalled failure or transient state; don't promote it.
func isDeliveryConfirmedSuccess(proxyErr *proxyA2AError, status int, respBody []byte) bool {
if proxyErr == nil {
return false
}
if len(respBody) == 0 {
return false
}
if status < 200 || status >= 300 {
return false
}
return true
}
// isQueuedProxyResponse reports whether the proxy returned a body shaped like
// `{"queued": true, "queue_id": ..., "queue_depth": ..., "message": ...}` —
// the busy-target enqueue path in a2a_proxy_helpers.go. Caller checks this
@@ -5,8 +5,10 @@ import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
@@ -376,6 +378,44 @@ func TestIsTransientProxyError_RetriesOnRestartRaceStatuses(t *testing.T) {
}
}
// TestIsDeliveryConfirmedSuccess — regression guard for #159: the proxy can
// return a complete 2xx body and THEN raise a transport error (e.g. the TCP
// connection drops after the response is received but before close). In that
// case the agent did the work; marking the delegation "failed" causes the
// retry-storm + Restart-workspace cascade described in #159. The new helper
// distinguishes this from genuine failures.
func TestIsDeliveryConfirmedSuccess(t *testing.T) {
connErr := &proxyA2AError{Status: http.StatusOK, Response: gin.H{}}
cases := []struct {
name string
proxyErr *proxyA2AError
status int
body []byte
expect bool
}{
// The new branch: 2xx + body + transport error → recover as success.
{"200 + body + connreset (THE bug fix path)", connErr, http.StatusOK, []byte(`{"text":"ok"}`), true},
{"299 + body + connreset (boundary high)", connErr, 299, []byte(`{"text":"ok"}`), true},
{"200 + body + connreset (boundary low)", connErr, 200, []byte(`{"x":1}`), true},
// Negative cases: any one of the three preconditions failing → false.
{"nil proxyErr (no decision to make)", nil, http.StatusOK, []byte(`{"text":"ok"}`), false},
{"empty body (no work-result to recover)", connErr, http.StatusOK, []byte{}, false},
{"nil body (no work-result to recover)", connErr, http.StatusOK, nil, false},
{"4xx with body — agent signalled failure, do not promote", connErr, http.StatusBadRequest, []byte(`{"err":"bad"}`), false},
{"5xx with body — agent signalled failure, do not promote", connErr, http.StatusInternalServerError, []byte(`{"err":"crash"}`), false},
{"3xx with body — redirect, not a result", connErr, 301, []byte(`{"loc":"/x"}`), false},
{"199 status (under 200) — not a 2xx", connErr, 199, []byte(`{"x":1}`), false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := isDeliveryConfirmedSuccess(tc.proxyErr, tc.status, tc.body); got != tc.expect {
t.Errorf("isDeliveryConfirmedSuccess(%v, %d, %q) = %v, want %v",
tc.proxyErr, tc.status, string(tc.body), got, tc.expect)
}
})
}
}
func TestIsQueuedProxyResponse(t *testing.T) {
// Regression guard for the chat-leak bug: when the proxy returns
// 202 with a queued-shape body, executeDelegation must classify it
@@ -918,3 +958,308 @@ func TestInsertDelegationOutcome_ZeroValueIsUnknown(t *testing.T) {
t.Errorf("insertOutcomeUnknown must not collide with insertOK")
}
}
// ==================== executeDelegation — delivery-confirmed proxy error regression tests ====================
//
// These test the fix for issue #159: when proxyA2ARequest returns an error but we have a
// non-empty response body with a 2xx status code, executeDelegation must treat it as success.
// The error is a delivery/transport error (e.g., connection reset after response was received).
// Previously, executeDelegation marked these as "failed" even though the work was done,
// causing retry storms and "error" rendering in canvas despite the response being available.
//
// Test strategy: spin up a mock A2A agent server, set up the source/target DB rows, call
// executeDelegation directly, and verify the activity_logs status and delegation status.
const testDelegationID = "del-159-test"
const testSourceID = "ws-source-159"
const testTargetID = "ws-target-159"
// expectExecuteDelegationBase sets up sqlmock expectations for the DB queries that
// executeDelegation always makes, regardless of outcome.
func expectExecuteDelegationBase(mock sqlmock.Sqlmock) {
// updateDelegationStatus: dispatched
// Uses prefix match — sqlmock regexes match the full query string.
mock.ExpectExec("UPDATE activity_logs SET status").
WithArgs("dispatched", "", testSourceID, testDelegationID).
WillReturnResult(sqlmock.NewResult(0, 1))
// CanCommunicate (source=target self-call is always allowed — no DB lookup needed)
// resolveAgentURL: reads ws:{id}:url from Redis, falls back to DB for target
mock.ExpectQuery("SELECT url, status FROM workspaces WHERE id = ").
WithArgs(testTargetID).
WillReturnRows(sqlmock.NewRows([]string{"url", "status"}).AddRow("", "online"))
}
// expectExecuteDelegationSuccess sets up expectations for a completed delegation.
func expectExecuteDelegationSuccess(mock sqlmock.Sqlmock, respBody string) {
// INSERT activity_logs for delegation completion (response_body status = 'completed')
mock.ExpectExec("INSERT INTO activity_logs").
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), "completed").
WillReturnResult(sqlmock.NewResult(0, 1))
// updateDelegationStatus: completed
mock.ExpectExec("UPDATE activity_logs SET status").
WithArgs("completed", "", testSourceID, testDelegationID).
WillReturnResult(sqlmock.NewResult(0, 1))
}
// expectExecuteDelegationFailed sets up expectations for a failed delegation.
func expectExecuteDelegationFailed(mock sqlmock.Sqlmock) {
// INSERT activity_logs for delegation failure (response_body status = 'failed')
mock.ExpectExec("INSERT INTO activity_logs").
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), "failed").
WillReturnResult(sqlmock.NewResult(0, 1))
// updateDelegationStatus: failed
mock.ExpectExec("UPDATE activity_logs SET status").
WithArgs("failed", sqlmock.AnyArg(), testSourceID, testDelegationID).
WillReturnResult(sqlmock.NewResult(0, 1))
}
// TestExecuteDelegation_DeliveryConfirmedProxyError_TreatsAsSuccess is the primary regression
// test for issue #159. The scenario:
// - Attempt 1: server sends 200 OK headers + partial body, then closes connection.
// proxyA2ARequest: body read gets io.EOF (partial body read), returns (200, <partial>, BadGateway).
// isTransientProxyError(BadGateway) = TRUE → retry.
// - Attempt 2: server does the same thing (closes after partial body).
// proxyA2ARequest: same (200, <partial>, BadGateway).
// isTransientProxyError(BadGateway) = TRUE → retry AGAIN (but outer context will fire soon,
// or we get one more attempt). For the test we let it run.
// POST-FIX: the executeDelegation new condition sees status=200, body=<partial>, err!=nil
// and routes to handleSuccess immediately.
//
// The key pre/post-fix difference: pre-fix, executeDelegation received status=0 (hardcoded)
// even when the server sent 200, so the condition always failed. Post-fix, status=200 is
// preserved through the error return path (proxyA2ARequest now returns resp.StatusCode, respBody).
// In this test the retry ultimately succeeds (server eventually sends full body), but
// the critical assertion is that a 2xx partial-body delivery-confirmed response is never
// classified as "failed" — it always routes to success.
func TestExecuteDelegation_DeliveryConfirmedProxyError_TreatsAsSuccess(t *testing.T) {
mock := setupTestDB(t)
mr := setupTestRedis(t)
allowLoopbackForTest(t)
broadcaster := newTestBroadcaster()
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
dh := NewDelegationHandler(wh, broadcaster)
// Server that sends a 200 response with declared Content-Length but closes
// the connection before sending all bytes. Go's http.Client sees io.EOF on
// the body read. proxyA2ARequest captures the partial body + status=200 and
// returns (200, <partial>, error). executeDelegation's new condition sees
// status=200 + body > 0 + error != nil → routes to handleSuccess.
var wg sync.WaitGroup
wg.Add(1)
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to listen: %v", err)
}
defer ln.Close()
go func() {
defer wg.Done()
conn, err := ln.Accept()
if err != nil {
return
}
defer conn.Close()
// Consume the HTTP request
buf := make([]byte, 2048)
conn.Read(buf)
// Send 200 OK with Content-Length: 100 but only 74 bytes of body
// (less than declared length → io.LimitReader returns io.EOF after reading all 74)
resp := "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\n\r\n"
resp += `{"result":{"parts":[{"text":"work completed successfully"}]}}` // 74 bytes
conn.Write([]byte(resp))
// Close immediately — client gets io.EOF on body read
}()
agentURL := "http://" + ln.Addr().String()
mr.Set(fmt.Sprintf("ws:%s:url", testTargetID), agentURL)
allowLoopbackForTest(t)
expectExecuteDelegationBase(mock)
expectExecuteDelegationSuccess(mock, `{"result":{"parts":[{"text":"work completed successfully"}]}}`)
// Execute synchronously (not as a goroutine) so we can check DB state immediately.
// The handler fires it as goroutine; we call it directly for deterministic testing.
a2aBody, _ := json.Marshal(map[string]interface{}{
"jsonrpc": "2.0",
"id": "1",
"method": "message/send",
"params": map[string]interface{}{
"message": map[string]interface{}{
"role": "user",
"parts": []map[string]string{{"type": "text", "text": "do work"}},
},
},
})
dh.executeDelegation(testSourceID, testTargetID, testDelegationID, a2aBody)
time.Sleep(100 * time.Millisecond) // let DB writes settle
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
// TestExecuteDelegation_ProxyErrorNon2xx_RemainsFailed verifies that the pre-fix failure
// path is unchanged when proxyA2ARequest returns a delivery-confirmed error with a non-2xx
// status code (e.g., 500 Internal Server Error with partial body read before connection drop).
// The new condition requires status >= 200 && status < 300, so non-2xx always routes to failure.
func TestExecuteDelegation_ProxyErrorNon2xx_RemainsFailed(t *testing.T) {
mock := setupTestDB(t)
mr := setupTestRedis(t)
allowLoopbackForTest(t)
broadcaster := newTestBroadcaster()
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
dh := NewDelegationHandler(wh, broadcaster)
// Server returns 500 with declared Content-Length but closes connection early.
// proxyA2ARequest: reads 500 headers, partial body, then connection drop → body read error.
// Returns (500, <partial_body>, BadGateway).
// New condition: status=500 is NOT >= 200 && < 300 → routes to failure.
// isTransientProxyError(500) = false → no retry.
var wg sync.WaitGroup
wg.Add(1)
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to listen: %v", err)
}
defer ln.Close()
go func() {
defer wg.Done()
conn, err := ln.Accept()
if err != nil {
return
}
defer conn.Close()
buf := make([]byte, 2048)
conn.Read(buf)
// 500 with Content-Length: 100 but only ~60 bytes of body
resp := "HTTP/1.1 500 Internal Server Error\r\nContent-Type: application/json\r\nContent-Length: 100\r\n\r\n"
resp += `{"error":"agent crashed"}` // ~24 bytes, less than declared
conn.Write([]byte(resp))
// Close immediately — client gets io.EOF on body read
}()
agentURL := "http://" + ln.Addr().String()
mr.Set(fmt.Sprintf("ws:%s:url", testTargetID), agentURL)
allowLoopbackForTest(t)
expectExecuteDelegationBase(mock)
expectExecuteDelegationFailed(mock)
a2aBody, _ := json.Marshal(map[string]interface{}{
"jsonrpc": "2.0", "id": "1", "method": "message/send",
"params": map[string]interface{}{
"message": map[string]interface{}{
"role": "user",
"parts": []map[string]string{{"type": "text", "text": "do work"}},
},
},
})
dh.executeDelegation(testSourceID, testTargetID, testDelegationID, a2aBody)
time.Sleep(100 * time.Millisecond)
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
// TestExecuteDelegation_ProxyErrorEmptyBody_RemainsFailed verifies that the pre-fix failure
// path is unchanged when proxyA2ARequest returns an error with a 2xx status but empty body.
// The new condition requires len(respBody) > 0, so empty body routes to failure.
func TestExecuteDelegation_ProxyErrorEmptyBody_RemainsFailed(t *testing.T) {
mock := setupTestDB(t)
mr := setupTestRedis(t)
allowLoopbackForTest(t)
broadcaster := newTestBroadcaster()
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
dh := NewDelegationHandler(wh, broadcaster)
// Server returns 502 Bad Gateway — proxyA2ARequest returns 502, body="" (empty), error != nil.
// New condition: proxyErr != nil && len(respBody) > 0 && status >= 200 && status < 300
// → len(respBody) == 0 → condition FALSE → falls through to failure.
// isTransientProxyError(502) is TRUE → retry → same result → failure.
agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadGateway)
// No body — connection closes normally
}))
defer agentServer.Close()
mr.Set(fmt.Sprintf("ws:%s:url", testTargetID), agentServer.URL)
allowLoopbackForTest(t)
// First attempt: updateDelegationStatus(dispatched) — from expectExecuteDelegationBase
expectExecuteDelegationBase(mock)
// Second attempt (retry): updateDelegationStatus(dispatched) again
mock.ExpectExec("UPDATE activity_logs SET status").
WithArgs("dispatched", "", testSourceID, testDelegationID).
WillReturnResult(sqlmock.NewResult(0, 1))
// Failure: INSERT + UPDATE (failed)
expectExecuteDelegationFailed(mock)
a2aBody, _ := json.Marshal(map[string]interface{}{
"jsonrpc": "2.0", "id": "1", "method": "message/send",
"params": map[string]interface{}{
"message": map[string]interface{}{
"role": "user",
"parts": []map[string]string{{"type": "text", "text": "do work"}},
},
},
})
dh.executeDelegation(testSourceID, testTargetID, testDelegationID, a2aBody)
time.Sleep(100 * time.Millisecond)
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
// TestExecuteDelegation_CleanProxyResponse_Unchanged verifies that a clean proxy response
// (no error, 200 with body) is unaffected by the new condition. This is the baseline:
// proxyErr == nil so the new condition never fires.
func TestExecuteDelegation_CleanProxyResponse_Unchanged(t *testing.T) {
mock := setupTestDB(t)
mr := setupTestRedis(t)
allowLoopbackForTest(t)
broadcaster := newTestBroadcaster()
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
dh := NewDelegationHandler(wh, broadcaster)
agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"result":{"parts":[{"text":"all good"}]}}`))
}))
defer agentServer.Close()
mr.Set(fmt.Sprintf("ws:%s:url", testTargetID), agentServer.URL)
allowLoopbackForTest(t)
expectExecuteDelegationBase(mock)
expectExecuteDelegationSuccess(mock, `{"result":{"parts":[{"text":"all good"}]}}`)
a2aBody, _ := json.Marshal(map[string]interface{}{
"jsonrpc": "2.0", "id": "1", "method": "message/send",
"params": map[string]interface{}{
"message": map[string]interface{}{
"role": "user",
"parts": []map[string]string{{"type": "text", "text": "do work"}},
},
},
})
dh.executeDelegation(testSourceID, testTargetID, testDelegationID, a2aBody)
time.Sleep(100 * time.Millisecond)
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
}
@@ -25,6 +25,35 @@ import (
"github.com/Molecule-AI/molecule-monorepo/platform/internal/registry"
"github.com/google/uuid"
)
// insertMCPDelegationRow writes a delegation activity row so the canvas
// Agent Comms tab can show the task text for MCP-initiated delegations.
// Mirrors insertDelegationRow (delegation.go) for the MCP tool path.
func insertMCPDelegationRow(ctx context.Context, db *sql.DB, workspaceID, targetID, delegationID, task string) error {
taskJSON, _ := json.Marshal(map[string]interface{}{
"task": task,
"delegation_id": delegationID,
})
_, err := db.ExecContext(ctx, `
INSERT INTO activity_logs (workspace_id, activity_type, method, source_id, target_id, summary, request_body, status)
VALUES ($1, 'delegation', 'delegate', $2, $3, $4, $5::jsonb, 'pending')
`, workspaceID, workspaceID, targetID, "Delegating to "+targetID, string(taskJSON))
return err
}
// updateMCPDelegationStatus updates a delegation activity row's status.
// Mirrors updateDelegationStatus (delegation.go) for the MCP tool path.
func updateMCPDelegationStatus(ctx context.Context, db *sql.DB, workspaceID, delegationID, status, errorDetail string) {
if _, err := db.ExecContext(ctx, `
UPDATE activity_logs
SET status = $1, error_detail = CASE WHEN $2 = '' THEN error_detail ELSE $2 END
WHERE workspace_id = $3
AND method = 'delegate'
AND request_body->>'delegation_id' = $4
`, status, errorDetail, workspaceID, delegationID); err != nil {
log.Printf("MCP Delegation %s: status update failed: %v", delegationID, err)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Tool implementations
// ─────────────────────────────────────────────────────────────────────────────
@@ -154,6 +183,13 @@ func (h *MCPHandler) toolDelegateTask(ctx context.Context, callerID string, args
return "", fmt.Errorf("workspace %s is not authorised to communicate with %s", callerID, targetID)
}
// Issue #158: write delegation row so canvas Agent Comms tab shows the task text.
delegationID := uuid.New().String()
if err := insertMCPDelegationRow(ctx, h.database, callerID, targetID, delegationID, task); err != nil {
log.Printf("MCP delegate_task: failed to record delegation row: %v", err)
// Non-fatal: still make the A2A call even if activity log write fails.
}
agentURL, err := mcpResolveURL(ctx, h.database, targetID)
if err != nil {
return "", err
@@ -197,10 +233,16 @@ func (h *MCPHandler) toolDelegateTask(ctx context.Context, callerID string, args
resp, err := http.DefaultClient.Do(httpReq)
if err != nil {
updateMCPDelegationStatus(ctx, h.database, callerID, delegationID, "failed", err.Error())
return "", fmt.Errorf("A2A call failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
// A 200/500 from the peer still means the call was dispatched — only
// network errors are truly "failed". Status 'dispatched' is correct for
// any HTTP response (peer's A2A layer handles the actual processing).
updateMCPDelegationStatus(ctx, h.database, callerID, delegationID, "dispatched", "")
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
@@ -223,7 +265,16 @@ func (h *MCPHandler) toolDelegateTaskAsync(ctx context.Context, callerID string,
return "", fmt.Errorf("workspace %s is not authorised to communicate with %s", callerID, targetID)
}
taskID := uuid.New().String()
delegationID := uuid.New().String()
// Issue #158: write delegation row so canvas Agent Comms tab shows the task text.
// Insert with 'dispatched' status since the goroutine won't update it.
if err := insertMCPDelegationRow(ctx, h.database, callerID, targetID, delegationID, task); err != nil {
log.Printf("MCP delegate_task_async: failed to record delegation row: %v", err)
// Non-fatal: still fire the A2A call.
} else {
updateMCPDelegationStatus(ctx, h.database, callerID, delegationID, "dispatched", "")
}
// Fire and forget in a detached goroutine. Use a background context so
// the call is not cancelled when the HTTP request completes.
@@ -244,7 +295,7 @@ func (h *MCPHandler) toolDelegateTaskAsync(ctx context.Context, callerID string,
a2aBody, _ := json.Marshal(map[string]interface{}{
"jsonrpc": "2.0",
"id": taskID,
"id": delegationID,
"method": "message/send",
"params": map[string]interface{}{
"message": map[string]interface{}{
@@ -273,7 +324,7 @@ func (h *MCPHandler) toolDelegateTaskAsync(ctx context.Context, callerID string,
_, _ = io.Copy(io.Discard, resp.Body)
}()
return fmt.Sprintf(`{"task_id":%q,"status":"dispatched","target_id":%q}`, taskID, targetID), nil
return fmt.Sprintf(`{"task_id":%q,"status":"dispatched","target_id":%q}`, delegationID, targetID), nil
}
func (h *MCPHandler) toolCheckTaskStatus(ctx context.Context, callerID string, args map[string]interface{}) (string, error) {
+10 -1
View File
@@ -607,7 +607,16 @@ func (h *OrgHandler) Import(c *gin.Context) {
orgFile := filepath.Join(orgBaseDir, "org.yaml")
data, err := os.ReadFile(orgFile)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("org template not found: %s", body.Dir)})
// Audit 2026-05-09 (Core-Security): the prior message echoed
// the user-supplied `body.Dir` verbatim. Path traversal is
// already blocked by resolveInsideRoot above, but echoing
// the raw input back lets a client probe for the existence
// of relative paths inside h.orgDir (a 404 with the input
// vs. a 400 from resolveInsideRoot is itself a signal).
// Drop the input from the message; log full context server-
// side via the resolved path for operator triage.
log.Printf("OrgImport: failed to read %s (requested dir=%q): %v", orgFile, body.Dir, err)
c.JSON(http.StatusNotFound, gin.H{"error": "org template not found"})
return
}
// Expand !include directives before unmarshal. Splits org.yaml
@@ -267,22 +267,16 @@ func (h *TemplatesHandler) ReplaceFiles(c *gin.Context) {
return
}
// Container offline — try ephemeral container to write to volume
// Container offline — write to the config volume via ephemeral container.
// Do NOT fall back to the host-side template dir: the restart handler
// reads from the volume, not the template dir, so a template-dir write
// would silently succeed (200) while the file change is invisible to
// restarted containers (#151). Surface the error so the caller retries
// when Docker is available instead of believing the change persisted.
volName := provisioner.ConfigVolumeName(workspaceID)
if err := h.writeViaEphemeral(ctx, volName, body.Files); err != nil {
// Last resort: write to host-side template dir
destDir := h.resolveTemplateDir(wsName)
if destDir == "" {
log.Printf("ReplaceFiles: writeViaEphemeral failed and no template dir for %s: %v", workspaceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write files to workspace"})
return
}
os.MkdirAll(destDir, 0o755)
if err := writeFiles(destDir, body.Files); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "replaced", "workspace": workspaceID, "files": len(body.Files), "source": "template"})
log.Printf("ReplaceFiles: writeViaEphemeral failed for %s (workspace %s offline): %v", wsName, workspaceID, err)
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "workspace offline — retry after it starts"})
return
}
@@ -452,3 +452,43 @@ func TestReplaceFiles_PathTraversal(t *testing.T) {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
// TestReplaceFiles_OfflineDocker_Returns503 ensures that when the workspace
// container is offline AND the ephemeral Docker write fails (docker unavailable),
// ReplaceFiles returns 503 instead of silently falling back to the template
// directory. A template-dir write would be invisible after restart because
// the restart handler reads from the Docker volume, not the template dir (#151).
func TestReplaceFiles_OfflineDocker_Returns503(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
// nil docker → TemplatesHandler.docker == nil → findContainer returns "" (offline)
// → writeViaEphemeral returns error (docker not available) → handler should
// return 503, NOT fall back to the host-side template dir.
handler := NewTemplatesHandler(t.TempDir(), nil, nil)
mock.ExpectQuery(`SELECT name, COALESCE\(instance_id, ''\), COALESCE\(runtime, ''\) FROM workspaces WHERE id =`).
WithArgs("ws-offline").
WillReturnRows(sqlmock.NewRows([]string{"name", "instance_id", "runtime"}).AddRow("Offline Agent", "", ""))
body := `{"files": {"initial-prompt.md": "updated prompt"}}`
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-offline"}}
c.Request = httptest.NewRequest("PUT", "/workspaces/ws-offline/files", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.ReplaceFiles(c)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("expected 503, got %d: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "offline") {
t.Errorf("response should mention 'offline': %s", w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
@@ -134,7 +134,7 @@ func (h *TranscriptHandler) Get(c *gin.Context) {
// - block cloud metadata endpoints (IMDS, GCP, Azure)
// - block link-local IPs (169.254/16 IPv4, fe80::/10 IPv6)
// - loopback is allowed — local dev runs workspaces on 127.0.0.1
// - Docker internal hostnames (host.docker.internal, *.molecule-monorepo-net)
// - Docker internal hostnames (host.docker.internal, *.molecule-core-net)
// are allowed; the whole threat model assumes the platform already
// trusts peers on that network
func validateWorkspaceURL(u *url.URL) error {
@@ -331,8 +331,14 @@ func (h *WorkspaceHandler) Delete(c *gin.Context) {
// stay in this handler.
descendantIDs, stopErrs, err := h.CascadeDelete(ctx, id)
if err != nil {
// Audit 2026-05-09 (Core-Security): raw `err.Error()` here was
// exposed to HTTP clients verbatim, including wrapped lib/pq
// driver strings that disclose schema column names + index
// hints. Log full error server-side; return a sanitized message
// to the client. Operators trace via the log line below using
// the workspace id.
log.Printf("Delete: CascadeDelete(%s) failed: %v", id, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error processing delete request"})
return
}
allIDs := append([]string{id}, descendantIDs...)
@@ -173,7 +173,7 @@ func (h *WorkspaceHandler) provisionWorkspaceOpts(workspaceID, templatePath stri
log.Printf("Provisioner: failed to cache URL for %s: %v", workspaceID, cacheErr)
}
// Also cache the Docker-internal URL for workspace-to-workspace discovery.
// Containers on molecule-monorepo-net can reach each other by container name.
// Containers on molecule-core-net can reach each other by container name.
internalURL := provisioner.InternalURL(workspaceID)
if cacheErr := db.CacheInternalURL(ctx, workspaceID, internalURL); cacheErr != nil {
log.Printf("Provisioner: failed to cache internal URL for %s: %v", workspaceID, cacheErr)
@@ -133,7 +133,13 @@ func TestLocalResolver_HonoursContextCancellation(t *testing.T) {
func TestLocalResolver_BubblesUpCopyFailure(t *testing.T) {
// Source file the copyTree walk would read; make dst unwritable so
// the copyFile step fails.
// the copyFile step fails. Skip when running as root — Linux
// filesystem permissions are advisory-only for uid 0, so chmod 0o555
// does not prevent writes and the test passes vacuously instead of
// exercising the error path (issue #87).
if os.Getuid() == 0 {
t.Skip("skipping: chmod 0o555 is ineffective when running as root")
}
base := t.TempDir()
writePlugin(t, base, "demo", map[string]string{
"plugin.yaml": "name: demo\n",
@@ -67,7 +67,7 @@ var DefaultImage = RuntimeImage(defaultRuntime)
const (
// DefaultNetwork is the Docker network workspaces join.
DefaultNetwork = "molecule-monorepo-net"
DefaultNetwork = "molecule-core-net"
// DefaultPort is the port the A2A server listens on inside the container.
DefaultPort = "8000"
@@ -405,7 +405,7 @@ func (p *Provisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string, e
// Apply tier-based container configuration
ApplyTierConfig(hostCfg, cfg, configMount, name)
// Network config — join molecule-monorepo-net with container name as alias
// Network config — join molecule-core-net with container name as alias
networkCfg := &network.NetworkingConfig{
EndpointsConfig: map[string]*network.EndpointSettings{
DefaultNetwork: {
+1 -1
View File
@@ -434,7 +434,7 @@ async def main(): # pragma: no cover
async def _transcript_handler(request):
# Require workspace bearer token — the same token issued at registration
# and stored in /configs/.auth_token. Any container on molecule-monorepo-net
# and stored in /configs/.auth_token. Any container on molecule-core-net
# could otherwise read the full session log. Closes #287.
#
# #328: fail CLOSED when the token file is unavailable. get_token()
+29
View File
@@ -401,6 +401,35 @@ if "a2a" not in sys.modules:
# tests now live in the claude-code template repo, where the real SDK
# IS installed via Dockerfile, so no stub is needed.
# ==================== Test isolation fixtures ====================
import pytest
@pytest.fixture(scope="function", autouse=True)
def _clear_platform_auth_cache():
"""Reset platform_auth._cached_token before each test.
Fixes issue #160: tests that use monkeypatch.delenv("MOLECULE_WORKSPACE_TOKEN")
to simulate "no token in env" fail when platform_auth._cached_token was already
set from a prior test's MOLECULE_WORKSPACE_TOKEN value. The cache is populated
at module import or first get_token() call and persists for the process lifetime
monkeypatch.delenv removes the env var but not the module-level cache.
Run at function scope so each test starts with a clean slate regardless of
what the previous test set. The import is inside the fixture (not at file
top-level) because conftest.py runs during test collection before
platform_auth might be available in all test environments. If the module is
absent (import error), the fixture is a no-op.
"""
try:
import platform_auth as _pa
_pa.clear_cache()
except ImportError:
pass
yield # run the test, then fixture teardown has nothing to do
if "langchain_core" not in sys.modules:
_make_langchain_mocks()
@@ -184,9 +184,14 @@ class TestPlatformAuthRegistry:
assert b["Authorization"] == "Bearer tok-b"
assert a["Origin"] == "https://example.test"
def test_auth_headers_with_no_arg_uses_legacy_path(self, monkeypatch):
def test_auth_headers_with_no_arg_uses_legacy_path(self, monkeypatch, tmp_path):
import platform_auth
# Wipe the module-level token cache and redirect _token_file() to a
# non-existent path so the env var isolation is clean. Without this,
# the real /configs/.auth_token pollutes the result.
platform_auth.clear_cache()
monkeypatch.setattr(platform_auth, "_token_file", lambda: tmp_path / ".auth_token")
monkeypatch.setenv("PLATFORM_URL", "https://example.test")
monkeypatch.setenv("MOLECULE_WORKSPACE_TOKEN", "legacy-tok")
# Multi-workspace registry populated, but auth_headers() with
@@ -199,10 +204,15 @@ class TestPlatformAuthRegistry:
assert h["Authorization"] == "Bearer legacy-tok"
def test_auth_headers_with_unknown_workspace_falls_back_to_legacy(
self, monkeypatch
self, monkeypatch, tmp_path
):
import platform_auth
# Wipe the module-level token cache and redirect _token_file() to a
# non-existent path so the env var isolation is clean. Without this,
# the real /configs/.auth_token pollutes the result.
platform_auth.clear_cache()
monkeypatch.setattr(platform_auth, "_token_file", lambda: tmp_path / ".auth_token")
monkeypatch.setenv("PLATFORM_URL", "https://example.test")
monkeypatch.setenv("MOLECULE_WORKSPACE_TOKEN", "legacy-tok")
platform_auth.register_workspace_token("ws-a", "tok-a")
+7 -1
View File
@@ -166,9 +166,15 @@ def test_resolve_token_returns_value_and_label_for_env(monkeypatch):
assert mcp_doctor._resolve_token_summary() == label
def test_resolve_token_returns_none_when_missing(monkeypatch):
def test_resolve_token_returns_none_when_missing(monkeypatch, tmp_path):
monkeypatch.delenv("MOLECULE_WORKSPACE_TOKEN", raising=False)
monkeypatch.delenv("MOLECULE_WORKSPACE_TOKEN_FILE", raising=False)
# The .auth_token file at /configs/.auth_token (present in container env)
# must not pollute the test. Patch configs_dir.resolve() to return a
# bare temp dir so the disk-file fallback in _resolve_token() has
# nothing to find.
import configs_dir
monkeypatch.setattr(configs_dir, "resolve", lambda: tmp_path)
val, label = mcp_doctor._resolve_token()
assert val is None
assert label is None
+1 -1
View File
@@ -3,7 +3,7 @@ the workspace auth token is not yet on disk.
Prior behaviour (regressed in #287): `if expected:` skipped the auth
check when `get_token()` returned None, so any container on
`molecule-monorepo-net` could read the full session log during the
`molecule-core-net` could read the full session log during the
bootstrap window. The fix lifts the guard into transcript_auth.py for
testability.
"""