Compare commits

...

421 Commits

Author SHA1 Message Date
Hongming Wang 314277769e Merge pull request #2758 from Molecule-AI/staging
staging → main: auto-promote 4f9e3fe
2026-05-04 10:53:03 -07:00
hongming e0b567e992 Merge pull request #2757 from Molecule-AI/fix/memory-v2-wiring-real-tests
Memory v2 wiring: replace decorative tests with real integration
2026-05-04 17:43:09 +00:00
Hongming Wang 707e4d7342 Memory v2 wiring: replace decorative tests with real integration
Self-review of #2755 found two tests that didn't actually exercise the
production code path:

- TestNamespaceCleanupFn_NamespaceFormat asserted
  "workspace:" + "abc-123" == "workspace:abc-123" — a compile-time
  invariant, not runtime behavior. Provided no protection if the closure
  in Bundle.NamespaceCleanupFn ever stopped using that prefix.

- TestNamespaceCleanupFn_FailureLogsButReturns built a *parallel*
  cleanup closure inline with errors.New, then invoked the parallel
  closure. The production closure was never exercised. A regression
  in NamespaceCleanupFn (e.g. forgetting the deferred recover, calling
  the plugin without nil-check) would still pass this test.

Replaced both with real integration:

- TestNamespaceCleanupFn_HitsPluginAtCorrectNamespace spins up
  httptest.Server, points MEMORY_PLUGIN_URL at it, calls Build(),
  invokes the production closure, and asserts the server actually
  saw DELETE /v1/namespaces/workspace:abc-123.

- TestNamespaceCleanupFn_PluginErrorDoesNotPanic exercises the
  failure path for real: server returns 500 on DELETE, closure must
  log and return without propagating. defer-recover is belt-and-
  suspenders since production calls this from a for-loop in
  workspace_crud.go that has no recover.

Couldn't ship with #2755 because the merge queue locks the branch
once enqueued. Following up now that #2755 is merged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 10:38:59 -07:00
Hongming Wang 4f9e3feece Merge pull request #2756 from Molecule-AI/fix/agent-card-decouple-from-setup
fix(runtime): decouple agent-card readiness from adapter.setup()
2026-05-04 17:32:02 +00:00
Hongming Wang 10752fe330 Merge pull request #2755 from Molecule-AI/fix/memory-v2-main-wiring
Memory v2 fixup CRITICAL: wire plugin from main.go (was fully dormant)
2026-05-04 17:31:01 +00:00
Hongming Wang 8f7122a9b6 Merge branch 'staging' into fix/agent-card-decouple-from-setup 2026-05-04 10:24:41 -07:00
Hongming Wang b3982035b3 Merge branch 'staging' into fix/memory-v2-main-wiring 2026-05-04 10:24:31 -07:00
Hongming Wang d1122f8d28 fix(build): register not_configured_handler in TOP_LEVEL_MODULES
The wheel-build drift gate caught the new module added in this PR —
without registering it, the published wheel would ship `import
not_configured_handler` un-rewritten, which would `ModuleNotFoundError`
at runtime under `molecule_runtime.main`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 10:24:02 -07:00
Hongming Wang 4b35d25d86 fix(runtime): decouple agent-card readiness from adapter.setup()
Today, if `adapter.setup()` raises (most often: an LLM credential is
missing/rotated), main.py crashes before the agent-card route is mounted.
start.sh restart-loops, /.well-known/agent-card.json never returns 200,
and the workspace is invisible to the bench/canvas — operators see
"stuck booting forever" with no clear error to act on.

The agent-card is a static capability advertisement (name, version,
skills, supported protocols). It doesn't need a working LLM. Coupling
its mount to setup() conflates *availability* ("am I up?") with
*configuration* ("can I actually answer?"). They're different concerns.

This change:
- Builds AgentCard from `config.skills` (static names from config.yaml)
  BEFORE adapter.setup(), so the route mounts independent of setup state.
- Wraps setup() + create_executor in try/except. On success, mounts
  the real DefaultRequestHandler with rich loaded_skills metadata
  swapped into the card in-place. On failure, mounts a JSON-RPC
  handler that returns -32603 "agent not configured" with the
  setup() exception in error.data.
- Heartbeat keeps running on misconfigured boots so the platform
  marks the workspace as reachable-but-misconfigured rather than
  crash-looping. Operators redeploy with corrected env without
  chasing a restart loop.
- initial_prompt and idle_loop are skipped on misconfigured boots —
  they self-fire to /, which would land in -32603 anyway, and the
  marker would consume on the first useless attempt.

Bench impact (RFC #388 strict <120s): codex/openclaw bench-time-outs
were the agent-card-never-returns-200 symptom. With this fix those
runtimes serve the card immediately on EC2 boot, so the bench
measures infrastructure cold-start (claude-code class: ~50–80s)
instead of credential-coupled boot.

Adds workspace/not_configured_handler.py (factory + module-level so
behavior is unit-testable; main.py is `# pragma: no cover`) and
workspace/tests/test_not_configured_handler.py (6 tests covering
status code, JSON-RPC envelope shape, id-echo, malformed-body
fallback, reason surfacing, batch-body safety).

All 1665 existing workspace tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 10:22:31 -07:00
Hongming Wang 46731729d4 Memory v2 fixup Critical: wire plugin from main.go (was fully dormant)
Caught during continued review: the entire v2 plugin system shipped
in PRs #2729-#2742 + #2744-#2751 was never actually invoked because
main.go and router.go don't construct the plugin client/resolver or
attach the WithMemoryV2 / WithNamespaceCleanup hooks.

Operators setting MEMORY_PLUGIN_URL=... saw zero behavior change
because nothing read it. Every fixup we shipped (idempotency, verify
mode, expires_at validation, audit JSON, namespace cleanup, O(N)
export, boot E2E) was also dormant for the same reason.

Root cause: when a multi-handler feature lands across many PRs, none
of them are individually responsible for wiring main.go — and the
master-task-tracking issue didn't gate-check that the wiring landed.
Add main.go integration to every multi-handler RFC checklist.

What ships:

  * internal/memory/wiring/wiring.go: new package that constructs the
    plugin client + resolver from MEMORY_PLUGIN_URL once. Returns nil
    when unset (preserves zero-config legacy behavior). Probes
    /v1/health at boot but doesn't fail-closed — the MCP layer's
    circuit breaker handles ongoing unavailability.

  * internal/memory/wiring/wiring_test.go: 6 tests covering the
    nil/non-nil bundle paths + the namespace-cleanup closure
    contract (nil-safe, format-stable, failure-tolerant).

  * cmd/server/main.go: imports memwiring, calls Build(db.DB) once
    after WorkspaceHandler creation, attaches WithNamespaceCleanup,
    threads the bundle through router.Setup.

  * internal/router/router.go: Setup signature gains *memwiring.Bundle
    param. Inside, attaches WithMemoryV2 to AdminMemoriesHandler and
    MCPHandler when the bundle is non-nil.

After this, the v2 plugin is reachable end-to-end:

  Operator sets MEMORY_PLUGIN_URL → main.Build instantiates client +
  resolver → WorkspaceHandler gets cleanup hook → router wires
  AdminMemoriesHandler + MCPHandler with WithMemoryV2 → MCP tool
  calls (commit_memory_v2, search_memory, etc.) actually do
  something → admin export/import respects MEMORY_V2_CUTOVER.

Prerequisite for #292 (staging verification) — without this, the
operator runbook's step 2 (set MEMORY_PLUGIN_URL, observe behavior)
silently no-ops.

Verified: all 9 affected test packages still green
(memory/{client,contract,e2e,namespace,pgplugin,wiring}, handlers,
router, plus the build).
2026-05-04 10:22:30 -07:00
Hongming Wang 6dc2d907a2 Merge pull request #2754 from Molecule-AI/auto-sync/main-849bc973
chore: sync main → staging (auto, ff to 849bc973)
2026-05-04 17:19:03 +00:00
molecule-ai[bot] 849bc97349 Merge pull request #2753 from Molecule-AI/staging
staging → main: auto-promote e13dcab
2026-05-04 17:08:11 +00:00
Hongming Wang e13dcab5e0 Merge pull request #2749 from Molecule-AI/fix/memory-v2-i3-export-on
Memory v2 fixup I3: admin export O(workspaces) → O(N_roots+1)
2026-05-04 16:49:43 +00:00
Hongming Wang 721010307c Merge pull request #2752 from Molecule-AI/auto-sync/main-73a949bb
chore: sync main → staging (auto, ff to 73a949bb)
2026-05-04 16:49:23 +00:00
Hongming Wang 9f47ecf86e Merge branch 'staging' into fix/memory-v2-i3-export-on 2026-05-04 09:44:37 -07:00
Hongming Wang ebc20794f3 fix(admin-memories): include each member's private namespace in export
ReadableNamespaces(rootID) returns {workspace:rootID, team:rootID,
org:rootID} — the workspace: namespace it surfaces is the root's only.
The I3 batching change resolved namespaces once per root which silently
dropped every child workspace's private memories from admin export
(workspace:childID never reached the plugin search).

Keep the per-root batching win for team:/org:/custom: namespaces;
inject each member's workspace:<id> + owner mapping explicitly so
coverage matches the legacy per-workspace iteration.

Cost stays at 1 SQL + N_roots resolver + 1 plugin search.

Test changes:
- New TestExport_IncludesEveryMembersPrivateNamespace uses a
  per-workspace resolver stub (mirrors real behaviour) and asserts
  every member's workspace:<id> reaches the plugin search AND that
  children's private memories appear in the response with correct
  owner attribution. Verified to FAIL on the pre-fix code.
- TestExport_BatchesPluginCallsByRoot updated to expect 5 namespaces
  (3 workspace + team + org) instead of 3 — it had pinned the buggy
  3-namespace behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 09:44:06 -07:00
Hongming Wang 73a949bb5c Merge pull request #2737 from Molecule-AI/staging
staging → main: auto-promote f74fff6
2026-05-04 09:37:55 -07:00
Hongming Wang 281cb04163 Merge pull request #2751 from Molecule-AI/fix/memory-v2-opt2-boot-e2e
Memory v2 fixup Opt-2: real-subprocess boot E2E
2026-05-04 16:27:56 +00:00
Hongming Wang fe7ff5440d Memory v2 fixup Opt-2: add E2E.md operator runbook
Companion to boot_e2e_test.go (just merged). Documents:
  - When the E2E suite runs (build tag + env var)
  - Local run with docker postgres
  - CI integration example (label-gated workflow step)
  - What each test pins
  - Explicit gap list (migration drift, recovery, TTL)
2026-05-04 09:24:16 -07:00
Hongming Wang 5b0a75ab73 Memory v2 fixup Optional-2: real-subprocess boot E2E
Self-review #293. PR-11's E2E test uses sqlmock + httptest —
integration, not E2E. This adds the actual real-subprocess test:
build the binary with `go build`, start it pointing at real postgres,
drive HTTP via the real client.

What in-process tests miss that this catches:
  - Binary build / boot-path panics (env var typos, mixed-key
    interface bugs that only surface when start() runs)
  - Wire encoding bugs that sqlmock smooths over (the pq.Array
    regression from PR-3 development would have been caught here)
  - HTTP+TCP-socket edge cases
  - Real upsert behavior under postgres ON CONFLICT (C1 fix)

Build-tag gated so default CI doesn't require docker:
  go test -tags memory_plugin_e2e -v ./cmd/memory-plugin-postgres/

Tests skip silently when MEMORY_PLUGIN_E2E_DB is unset.

Three tests:
  1. TestE2E_BootAndHealth — capabilities advertised correctly
  2. TestE2E_FullCommitSearchForgetRoundTrip — full agent flow
  3. TestE2E_IdempotencyKey — C1 upsert against real postgres

Plus E2E.md operator runbook with docker quickstart + CI integration
example + explicit statement of what's still uncovered (migration
drift, recovery scenarios, TTL eviction over real time).
2026-05-04 09:23:46 -07:00
Hongming Wang a6dadc7ee0 Merge pull request #2750 from Molecule-AI/fix/memory-v2-i5-namespace-cleanup
Memory v2 fixup I5: workspace purge cleans up plugin namespace
2026-05-04 16:23:41 +00:00
Hongming Wang 5e52a0fdad Merge pull request #2748 from Molecule-AI/docs/memory-v2-fixup-docs
Memory v2 docs update: idempotency key + verify mode + cutover runbook
2026-05-04 16:21:02 +00:00
Hongming Wang 6b445aae2d Memory v2 fixup I5: workspace purge cleans up plugin namespace
Self-review #291. When a workspace is hard-purged, its
`workspace:<id>` namespace stays in the plugin storage. Over time
deleted workspaces accumulate as orphan namespaces.

Fix: optional namespaceCleanupFn hook on WorkspaceHandler. The
purge path (workspace_crud.go ~line 520) iterates each purged id
and calls the hook best-effort. main.go wires the hook to
plugin.DeleteNamespace when MEMORY_PLUGIN_URL is set; operators
who haven't enabled the plugin keep the no-op default.

Why a hook (not direct plugin import):
  * Keeps WorkspaceHandler decoupled from the memory contract
    package (easier to test, smaller blast radius if the contract
    bumps)
  * Tests inject a captureCleanupHook stub without standing up a
    real plugin client
  * Production wiring stays a one-liner in main.go

What gets cleaned up:
  * `workspace:<id>` for each purged workspace
  * NOT `team:<root>` / `org:<root>` — those may still be
    referenced by other workspaces under the same root, so dropping
    them on a single workspace's purge would orphan team/org data
    for the survivors. Operator can purge those manually after
    confirming the entire root is gone.

What stays untouched:
  * Soft-removed workspaces (status='removed', no ?purge=true). The
    grace window is by design — the data should still be there if
    the operator unremoves.

Tests:
  * TestWithNamespaceCleanup_DefaultIsNil pins the safe default
  * TestWithNamespaceCleanup_NilStaysNil pins the explicit-nil case
  * TestWithNamespaceCleanup_AttachesFn pins the wiring
  * TestPurge_CallsCleanupHookPerID exercises the per-id loop body
  * TestPurge_NilHookIsSkipped pins the nil guard

A full end-to-end Delete-handler test requires mocking broadcaster
+ provisioner + descendant SQL chain, which is out-of-scope for a
single fixup. Integration coverage for the wired path lives in
PR-11's E2E swap test (#293 follow-up).
2026-05-04 09:20:37 -07:00
Hongming Wang 4f3d51bd61 Merge branch 'staging' into docs/memory-v2-fixup-docs 2026-05-04 09:18:49 -07:00
Hongming Wang 9a64aeaa2c Memory v2 fixup I3: admin export O(workspaces) → O(N_roots+1)
Self-review #289. The previous exportViaPlugin ran one resolver CTE
walk + one plugin search PER WORKSPACE. For a 1000-workspace tenant
that's 1000× of each, mostly redundant — workspaces sharing a
team/org root see identical readable namespaces.

New strategy:
  1. Single SQL pass returns each workspace + its computed root_id
     via a recursive CTE (loadWorkspacesWithRoots).
  2. Group by root → unique tree count is typically << workspace
     count.
  3. Resolver runs ONCE per root (any member sees the same readable
     list).
  4. Build the union of all root namespaces; single plugin.Search
     call.
  5. Map each memory back to a workspace_name via pickOwnerForNamespace
     (workspace:<id> → matching member; team:* / org:* / custom:* →
     canonical first member of root group).

Net call cost: 1 SQL + N_roots resolver + 1 plugin call (vs
N_workspaces × resolver + N_workspaces × plugin in the old code).

Tests:
  * TestExport_BatchesPluginCallsByRoot pins the new behavior
    explicitly: 3 workspaces under 1 root → exactly 1 plugin search
    (was 3 with the old code).
  * TestPickOwnerForNamespace covers all five attribution cases:
    workspace:<id> match, workspace:<id> no-match-fallback, team:*,
    org:*, custom:* → first-member-of-root-group; plus empty-members
    fallback.
  * All 9 existing TestExport_* / TestImport_* / TestPickOwner /
    TestNamespaceKindFromLegacyScope / TestSkipImport / etc. tests
    remain green (verified with -run "Export").

The legacy DB path (when MEMORY_V2_CUTOVER unset) is unchanged.
2026-05-04 09:17:30 -07:00
Hongming Wang 2d783b5ca6 Memory v2 docs update: idempotency key + verify mode + cutover runbook
Updates plugin-author and operator docs to reflect the four fixup
PRs (C1, C2, I1, I4) for self-review findings.

Stacked on C1+C2 so the docs reference behavior that lands in the
same wave; rebases to staging once those merge.

What changes:

  * docs/memory-plugins/README.md
    - New "Memory idempotency" section explaining MemoryWrite.id
      contract: omit → plugin generates UUID; supplied → upsert
    - "Replacing the built-in plugin" rewritten as a 6-step
      operator runbook with concrete commands for -dry-run / -apply
      / -verify / MEMORY_V2_CUTOVER, including the failure path
      ("if -verify reports mismatches, do not flip the cutover flag")
    - Added link to new CHANGELOG.md

  * docs/memory-plugins/testing-your-plugin.md
    - New TestMyPlugin_IDIsIdempotencyKey example: write same id
      twice, assert single row + updated content
    - "What the harness does NOT cover" expanded with two new
      operational gates: backfill twice → no double; verify-mode
      reports zero mismatches

  * docs/memory-plugins/pinecone-example/README.md
    - Wire-mapping table updated: id (caller-supplied) → Pinecone
      vector id (upsert); id (omitted) → plugin-generated UUID
    - Production-hardening checklist gained an idempotency-key item

  * docs/memory-plugins/CHANGELOG.md (new)
    - Captures the four fixup PRs in one place with severity-ordered
      summary, plugin-author action items, and remaining open
      follow-ups (#289, #291, #293) for transparency

No code changes. Docs-only PR.
2026-05-04 09:08:28 -07:00
Hongming Wang 6fc328ef44 Merge pull request #2747 from Molecule-AI/fix/memory-v2-c2-backfill-verify
Memory v2 fixup C2: backfill -verify mode (parity check)
2026-05-04 16:08:27 +00:00
Hongming Wang bb3212ad37 Merge branch 'staging' into fix/memory-v2-c2-backfill-verify 2026-05-04 09:08:21 -07:00
Hongming Wang 1986260603 Merge remote-tracking branch 'origin/fix/memory-v2-c1-backfill-idempotent' into docs/memory-v2-fixup-docs 2026-05-04 09:05:11 -07:00
Hongming Wang d297e75fc9 Merge pull request #2746 from Molecule-AI/fix/memory-v2-i1-i4-small
Memory v2 fixup I1+I4: expires_at validation + audit JSON marshal
2026-05-04 16:05:02 +00:00
Hongming Wang 3ae0513209 Merge pull request #2744 from Molecule-AI/fix/memory-v2-c1-backfill-idempotent
Memory v2 fixup C1: backfill idempotency via MemoryWrite.id
2026-05-04 16:04:54 +00:00
Hongming Wang 4b6373861c Memory v2 fixup C2: backfill -verify mode (parity check)
Self-review missed deliverable from PR-7's task spec. Operators had
no way to confirm a -apply produced equivalent search results to the
legacy agent_memories direct queries; this PR ships that.

Usage:
  memory-backfill -verify                      # 50-workspace random sample
  memory-backfill -verify -verify-sample=200   # bigger sample
  memory-backfill -verify -workspace=<uuid>    # one specific workspace

Algorithm:
  1. Pick N random workspaces (or use -workspace if specified)
  2. For each: query agent_memories direct, query plugin search via
     the workspace's readable namespace list
  3. Multiset-compare contents: every legacy row must have a matching
     plugin row. Plugin having MORE rows is OK (team-shared content
     may be visible from sibling workspaces).
  4. Print mismatches with content excerpt; non-zero mismatches/errors
     yields a non-zero exit so CI can gate cutover.

Sql:
  - Sampling uses ORDER BY random() LIMIT N (TABLESAMPLE has surprising
    distribution at small populations).
  - Filters out status='removed' workspaces.

Test coverage:
  * pickWorkspaceSample: single-ws short-circuit, random sampling,
    query error, scan error
  * queryLegacyMemories: happy path, error path
  * verifyParity:
      - all match → 1 match, 0 mismatch
      - missing-from-plugin → 1 mismatch with content excerpt
      - plugin-extra rows → 1 match (legacy is subset of plugin)
      - legacy query error → 1 error counter
      - resolver error → 1 error counter
      - plugin search error → 1 error counter
      - no readable namespaces + empty legacy → match
      - no readable namespaces + non-empty legacy → mismatch
      - pickSample error → propagated up
  * CLI: -verify+-apply rejected as mutually exclusive; -verify alone
    is a valid mode

Note: namespaceResolverAdapter bridges *namespace.Resolver to the
verify package's verifyResolver interface so verify.go has zero
dependency on the namespace package — keeps test stubs minimal.
2026-05-04 09:01:31 -07:00
Hongming Wang 3886e8fb9f Merge pull request #2745 from Molecule-AI/fix/harness-stub-auth-headers-1arg
fix(harness): stub platform_auth with *args lambdas (#2743 fallout)
2026-05-04 15:58:24 +00:00
Hongming Wang d48693144b Memory v2 fixup I1+I4: expires_at validation + audit JSON marshal
Two small Important findings from self-review, bundled because both
are <20 line changes touching the same file.

I1: expires_at silent drop
  - mcp_tools_memory_v2.go:130 had `if t, err := ...; err == nil { ... }`
    which dropped malformed timestamps without telling the agent.
    Agent passes `expires_at: "tomorrow"`, gets a 200, and the memory
    has no TTL.
  - Now returns a clear error: "invalid expires_at: must be RFC3339"
  - Test renamed: TestCommitMemoryV2_BadExpiresIsIgnored (which
    codified the bug) → TestCommitMemoryV2_BadExpiresReturnsError
    (which pins the fix).

I4: audit log JSON via Sprintf-%q
  - auditOrgWrite was building activity_logs.metadata via fmt.Sprintf
    with %q. Go-quoted strings happen to coincide with JSON-quoted
    for ASCII (and today's values are pure ASCII: UUID + hex digest)
    so the bug was latent.
  - Replaced with json.Marshal of map[string]string. Same wire shape
    today, but won't silently produce invalid JSON if metadata grows
    to include arbitrary content snippets.
  - New test TestAuditOrgWrite_MetadataIsValidJSON uses a custom
    sqlmock.Argument matcher (jsonValidMatcher) that fails the test
    if the metadata column isn't parseable JSON. The test runs
    auditOrgWrite with a content string containing quotes,
    backslashes, and a control byte — values where %q would diverge
    from JSON-quote.

Both pre-existing tests (TestCommitMemoryV2_AuditsOrgWrites etc.)
remain green.
2026-05-04 08:57:58 -07:00
Hongming Wang 1b207b214d fix(harness): stub platform_auth with *args lambdas (#2743 fallout)
PR #2743 (multi-workspace MCP PR-2) made auth_headers accept an
optional ``workspace_id`` arg and self_source_headers stayed
1-arg-required. The peer-discovery-404 harness replay stubbed both
with 0-arg lambdas, so the helper call inside the replay raised:

    TypeError: <lambda>() takes 0 positional arguments but 1 was given

…and the diagnostic captured by the replay was the TypeError text,
not the platform-404 string the assertion grep'd for. Caught by
PR-2737 (auto-promote staging→main) — the replay went red right
after #2743 merged into staging.

Switching both stubs to ``*args, **kwargs`` makes them tolerant of
both the legacy 0-arg call shape AND the new 1-arg-with-workspace
call shape, so neither the harness nor the in-tree unit tests need
to know which version of the runtime helpers ran the call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 08:55:42 -07:00
Hongming Wang 1e97fb9a16 Memory v2 fixup C1: backfill idempotency via MemoryWrite.id
Self-review (post-merge) flagged that the backfill claimed to be
idempotent on re-run but actually duplicates every row because the
plugin's INSERT uses gen_random_uuid() and ignores any id passed in.

Fix is contract-level: extend MemoryWrite with an optional `id`
idempotency key. When supplied, the plugin MUST treat the write as
upsert keyed on this id; when omitted, the plugin generates a fresh
UUID (production agent commits keep working unchanged).

Changes:
  * docs/api-protocol/memory-plugin-v1.yaml: add id field with
    description that flags it as idempotency key
  * internal/memory/contract/contract.go: add ID to MemoryWrite struct,
    update memory_write_minimal golden vector
  * internal/memory/pgplugin/store.go: split CommitMemory into two
    paths — upsert when body.ID set (INSERT ... ON CONFLICT (id) DO
    UPDATE), plain INSERT otherwise
  * cmd/memory-backfill/main.go: pass agent_memories.id to MemoryWrite,
    fix the false comment about 409 deduplication

New tests:
  * pgplugin: TestCommitMemory_WithIDUpserts pins the upsert SQL is
    used when id is set; TestCommitMemory_UpsertScanError covers the
    error branch
  * backfill: TestBackfill_PassesSourceUUIDAsIdempotencyKey pins the
    forwarding behavior; TestBackfill_RerunIsIdempotent simulates a
    retry and asserts both runs pass the same uuid (plugin upsert is
    what makes this safe)

Why this matters: operators retrying a failed backfill (which they
will — networks fail, transactions abort) would otherwise create N
duplicates per memory. The duplicates aren't visible until search
results show obvious dupes — debugging that under prod load is bad.

Production agent commits are unaffected: they leave id empty, the
plugin generates a fresh UUID via gen_random_uuid(), zero behavior
change for the hot path.
2026-05-04 08:54:13 -07:00
Hongming Wang 7cffff844b Merge pull request #2743 from Molecule-AI/feat/mcp-multi-workspace-pr2
feat(mcp): cross-workspace delegation routing (multi-ws PR-2)
2026-05-04 15:43:20 +00:00
Hongming Wang 4a0d7cd545 Merge branch 'staging' into feat/mcp-multi-workspace-pr2 2026-05-04 08:37:20 -07:00
Hongming Wang 35b3ea598a test: fix WORKSPACE_ID assert to match module attr (CI portability)
CI's pytest harness pre-sets WORKSPACE_ID=test in the env before
test collection, so a2a_client's module-level WORKSPACE_ID
(captured at import time, line 24) holds "test" — but the local
fixture's monkeypatch.setenv("WORKSPACE_ID", ...) only affects the
ENV value seen on later os.environ reads, NOT the already-bound
module attribute.

Assert against a2a_client.WORKSPACE_ID directly so the test is
portable across local + CI runs without monkey-patching the module
itself (which a future test reload might undo).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 08:35:48 -07:00
Hongming Wang 1161b97faf feat(mcp): cross-workspace delegation routing (multi-ws PR-2)
PR-2 of the multi-workspace external-agent stack. PR-1 (#2739)
landed per-workspace auth + heartbeat + inbox. This PR threads
``source_workspace_id`` through the A2A client + tool surface so an
agent registered against multiple workspaces can list peers across
all of them and delegate from a specific source.

Changes
-------

* ``a2a_client``: ``discover_peer``, ``send_a2a_message``,
  ``get_peers_with_diagnostic``, and ``enrich_peer_metadata`` now
  accept ``source_workspace_id``. Routing uses it for both the
  X-Workspace-ID header and (transitively, via ``auth_headers(src)``)
  the bearer token. Defaults to module-level WORKSPACE_ID for
  back-compat.
* ``a2a_client._peer_to_source``: a new lock-free cache mapping each
  discovered peer back to the source workspace whose registry
  surfaced it. ``tool_list_peers`` populates the cache on every call;
  ``tool_delegate_task`` consults it for auto-routing.
* ``a2a_tools.tool_list_peers(source_workspace_id=None)``: when
  multiple workspaces are registered (MOLECULE_WORKSPACES) and no
  explicit source is passed, aggregates peers across every
  registered workspace and tags each entry with ``via: <src[:8]>``.
  Single-workspace mode is unchanged — no ``via:`` annotation, same
  output shape.
* ``a2a_tools.tool_delegate_task`` and ``tool_delegate_task_async``
  resolve source via ``source_workspace_id arg → _peer_to_source[target]
  → WORKSPACE_ID``. Agents almost never need to specify ``source_*``
  explicitly — call ``list_peers`` first and the cache handles the
  rest.
* ``tool_delegate_task_async`` idempotency key now includes the
  source workspace, so the same task delegated from two registered
  workspaces produces two distinct delegations (the right behavior
  — one per tenant audit trail).
* ``platform_auth.list_registered_workspaces()``: new helper for the
  tool layer to enumerate the multi-ws registry. Lock-free reads
  matched by the existing single-writer-per-workspace contract from
  PR-1.
* ``platform_auth.self_source_headers``: now passes ``workspace_id``
  through to ``auth_headers`` — without this, a multi-workspace POST
  source-tagged with ``X-Workspace-ID=ws_b`` was authenticating
  with ws_a's token (or no token if MOLECULE_WORKSPACE_TOKEN unset).
  Latent PR-1 bug exposed by the new tool surface.
* ``a2a_mcp_server`` tool dispatch passes ``source_workspace_id``
  from the tool call arguments.
* ``platform_tools.registry``: add ``source_workspace_id`` to the
  delegate_task, delegate_task_async, check_task_status, list_peers
  input schemas with copy explaining when to use it (rarely — the
  cache handles it).

Tests (15 new, all passing)
---------------------------

``test_a2a_multi_workspace.py``:
* TestDiscoverPeerSourceRouting (3): src arg drives header+token,
  fallback to module ws when omitted, invalid target short-circuits
  before any HTTP attempt.
* TestSendA2AMessageSourceRouting (1): X-Workspace-ID source header
  + Authorization bearer both come from the source arg via the
  patched self_source_headers chain.
* TestGetPeersSourceRouting (1): URL path AND headers use the
  source workspace id.
* TestToolListPeersAggregation (4): aggregates across multiple
  registered workspaces, tags origin, leaves single-workspace path
  unchanged, explicit src arg overrides aggregation, diagnostic
  joining when every workspace returns empty.
* TestToolDelegateTaskAutoRouting (3): cache-driven auto-route,
  explicit override beats cache, single-workspace fallback to
  module WORKSPACE_ID.
* TestListRegisteredWorkspaces (3): registry enumeration helper.

Plus ``tests/snapshots/a2a_instructions_mcp.txt`` regenerated to
absorb the new ``source_workspace_id`` schema entries.

Back-compat
-----------

Every change defaults ``source_workspace_id=None``; legacy
single-workspace operators (no MOLECULE_WORKSPACES) see identical
behavior — same URLs, same headers, same tool output. The 24
PR-1 tests + 125 existing A2A tests all still pass.

Out of scope (PR-3)
-------------------

Memory namespacing per registered workspace lands after the new
memory system v2 PR (#2740) settles in production.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 08:32:24 -07:00
Hongming Wang 059962a0a3 Merge pull request #2742 from Molecule-AI/feat/memory-v2-pr11-e2e-swap
Memory v2 PR-11: E2E test — flat-plugin swap proves contract works
2026-05-04 15:29:56 +00:00
Hongming Wang b07575c710 Merge branch 'staging' into feat/memory-v2-pr11-e2e-swap 2026-05-04 08:24:26 -07:00
Hongming Wang 586fa5f84e Merge pull request #2741 from Molecule-AI/feat/memory-v2-pr10-docs
Memory v2 PR-10: operator docs for writing a custom memory plugin
2026-05-04 15:20:35 +00:00
Hongming Wang b937415e1e Memory v2 PR-11: E2E test — flat-plugin swap proves contract works
Final implementation PR. Builds on PR-1..10 (all merged or queued).

Proves the central design property of the plugin contract: ANY
plugin satisfying the v1 OpenAPI spec works as a drop-in replacement
for the built-in postgres plugin. If this test fails after a refactor,
the contract has drifted in a way that breaks ecosystem plugins.

What ships:
  * internal/memory/e2e/swap_test.go — five E2E tests against a
    deliberately minimal "flat-memory" stub plugin (~50 LOC, single
    map, zero capabilities)
  * MCPHandler.Dispatch — small exported wrapper around dispatch so
    out-of-package E2E tests can drive tools by name without
    duplicating the whole MCP RPC stack

E2E coverage:
  * TestE2E_FlatPluginRoundTrip: full lifecycle
    - list_writable_namespaces returns 3 entries
    - commit_memory_v2 writes through plugin
    - search_memory finds it back
    - commit_summary writes a summary
    - forget_memory deletes
    - search after forget excludes the deleted memory

  * TestE2E_LegacyShimRoutesThroughFlatPlugin: PR-6 shim wired up
    - Legacy commit_memory(scope=LOCAL) ends up in plugin storage
    - Legacy recall_memory finds it back through plugin search
    - Response shapes preserved (scope:LOCAL stays scope:LOCAL)

  * TestE2E_OrgMemoriesDelimiterWrap: prompt-injection mitigation
    - Org-namespace memory committed
    - Audit INSERT into activity_logs verified
    - Search returns content with [MEMORY id=... scope=ORG ns=...]
      prefix applied

  * TestE2E_StubPluginCapabilitiesAreEmpty: capability negotiation
    - Stub plugin reports zero capabilities
    - Client.SupportsCapability returns false for FTS, embedding
    - Confirms graceful degradation when plugin doesn't support a
      feature

  * TestE2E_PluginUnreachable_AgentSeesClearError: failure surface
    - Plugin URL pointing at bogus port
    - commit_memory_v2 returns informative error
    - No nil-pointer dereference; error message is actionable

The flat plugin is intentionally minimal — it has no namespaces table
distinct from memory records, no FTS, no semantic search, no TTL. The
test proves operators can drop in a 50-line plugin and the agent
behavior is identical (modulo capability-gated features).
2026-05-04 08:20:35 -07:00
Hongming Wang 0f46c7eefe Merge pull request #2739 from Molecule-AI/feat/mcp-multi-workspace-pr1
mcp: support multi-workspace external-agent registration (PR-1 of stack)
2026-05-04 15:19:03 +00:00
Hongming Wang 8aea1f008c Merge pull request #2740 from Molecule-AI/feat/memory-v2-pr8-cutover
Memory v2 PR-8: cutover — admin export/import via plugin
2026-05-04 15:18:17 +00:00
Hongming Wang 8417bce50d Memory v2 PR-10: operator docs for writing a custom memory plugin
Builds on merged PR-1..7 (PR-8 in queue). Pure docs; no code.

What ships:
  * docs/memory-plugins/README.md — contract overview, capability
    negotiation, deployment models, replacement workflow
  * docs/memory-plugins/testing-your-plugin.md — using the contract
    test harness to validate wire compatibility, what the harness
    DOES NOT cover (capability accuracy, TTL eviction, concurrency)
  * docs/memory-plugins/pinecone-example/README.md — worked example
    of a Pinecone-backed plugin: capability mapping (only embedding,
    no FTS), wire mapping (memory → vector + metadata), production-
    hardening checklist

Documentation strategy:
  * Lead with what workspace-server takes care of (security perimeter,
    redaction, ACL, GLOBAL audit, prompt-injection wrap) so plugin
    authors don't reimplement those layers
  * Show three deployment models (same machine / separate container /
    self-managed) so operators see their topology
  * Capability table makes it explicit what each capability gates so
    a plugin that supports only one (e.g. semantic search) is still
    a useful plugin
  * Pinecone example is honest: shows the skeleton, the wire mapping,
    and explicitly calls out what's MISSING from the sketch (batch
    commits, TTL janitor, circuit breaker, metrics)
2026-05-04 08:17:03 -07:00
Hongming Wang 3195657837 fix: bot-lint nits — drop unused imports, add reason to except
Resolves three github-code-quality threads blocking PR-2739 merge:
- workspace/tests/test_mcp_cli_multi_workspace.py: remove unused
  `import os` and `from unittest.mock import patch` (left over from
  an earlier test draft that mocked at the os.environ layer).
- workspace/mcp_cli.py:523: replace bare `pass` in the
  register_workspace_token ImportError handler with a debug log line +
  one-line comment explaining the silent-degrade contract (older
  installs that don't yet ship the helper fall back to the legacy
  single-token path; single-workspace operators see no behavior
  change).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 08:16:12 -07:00
Hongming Wang 7b0bd32957 Memory v2 PR-8: cutover — admin export/import via plugin
Builds on merged PR-1..7. Adds the operator-controlled cutover flag
that flips admin export/import from the legacy direct-DB path to the
v2 plugin path.

Activation: MEMORY_V2_CUTOVER=true AND the v2 plugin is wired via
WithMemoryV2. Both must be true to take the new path; either being
false falls through to the existing legacy SQL code unchanged.

What ships:
  * AdminMemoriesHandler gains plugin + resolver fields, wired via
    WithMemoryV2 (production) / withMemoryV2APIs (tests)
  * Export: enumerates workspaces, asks resolver for each one's
    readable namespaces, searches each via plugin, deduplicates by
    memory id, applies SAFE-T1201 redaction on emitted content
    (F1084 parity). Returns the legacy memoryExportEntry shape so
    existing tooling keeps working.
  * Import: scope→namespace translation mirrors PR-6 shim. Uses
    UpsertNamespace + CommitMemory; runs SAFE-T1201 redaction
    BEFORE the plugin sees the content (F1085 parity).
  * Helpers: legacyScopeFromNamespace + namespaceKindFromLegacyScope
    (lifted out so admin_memories doesn't depend on MCP handler
    helpers). skipImport typed error.

Operational rollout (cutover sequencing):
  1. Today: MEMORY_V2_CUTOVER unset → legacy DB path.
  2. After PR-7 backfill applied + smoke verified: operator sets
     MEMORY_V2_CUTOVER=true.
  3. From that point, admin export/import operate on plugin
     storage; legacy agent_memories table is read-only for the
     ~60-day grace window before PR-9 drops it.

Coverage on new paths:
  * cutoverActive: 100%
  * WithMemoryV2 / withMemoryV2APIs: 100%
  * importViaPlugin: 100%
  * exportViaPlugin: 97.2% (one defensive scan-error branch in the
    workspace-list loop)
  * scopeToWritableNamespaceForImport: 76.9% (resolver-error and
    no-matching-kind branches exercised end-to-end via Import)
  * legacyScopeFromNamespace + namespaceKindFromLegacyScope: 100%

Edge cases pinned:
  * Cutover flag matrix (env unset/true/false × wired/unwired)
  * Export deduplicates memories shared across team (one row per id)
  * Export tolerates per-workspace failures (resolver / plugin) and
    keeps going on the rest
  * Export returns 500 only when the top-level workspace query fails
  * Empty readable namespaces → empty export (no panic)
  * Export redacts secrets in plugin path
  * Import: unknown workspace skipped, unknown scope skipped,
    plugin upsert/commit errors counted as errors
  * Import redacts secrets BEFORE plugin sees content
  * Legacy export/import path unchanged when cutover flag unset
2026-05-04 08:15:10 -07:00
Hongming Wang 6fb9bc9bcd mcp: regenerate platform_auth signature snapshot for auth_headers(workspace_id=...)
PR-1's auth_headers added an optional workspace_id parameter for
multi-workspace token routing; the signature drift gate
(test_platform_auth_signature_matches_snapshot) caught the change as
expected. Snapshot regenerated to capture the new shape — diff is
visible in the PR for reviewers + template repos that depend on this
surface.

Behavior unchanged: auth_headers() with no arg still routes through
the legacy resolution path (back-compat exact); the workspace_id arg
is opt-in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 08:11:23 -07:00
Hongming Wang 9cd2c02f14 Merge branch 'staging' into feat/mcp-multi-workspace-pr1 2026-05-04 08:07:34 -07:00
Hongming Wang 9929f73e80 Merge pull request #2738 from Molecule-AI/feat/memory-v2-pr7-backfill
Memory v2 PR-7: one-shot backfill CLI (dry-run + apply)
2026-05-04 15:07:14 +00:00
Hongming Wang 829ab66462 mcp: support multi-workspace external-agent registration (PR-1)
External MCP agents (e.g. Claude Code installed on a company PC) can
now register against MULTIPLE workspaces from a single process — the
agent participates as a peer in workspace A (company) AND workspace B
(personal) simultaneously, with one merged inbox tagged so replies
route to the correct tenant.

Use case (verbatim from operator): "I have this computer AI thats in
company's PC, he is going to be put in company's workspace, but
personally, I want to register it to my own workspace as well, so
that I can talk to it and asking him to do work."

## What changed

**Wire format** — new env var:

  MOLECULE_WORKSPACES='[
    {"id":"<company-wsid>","token":"<company-tok>"},
    {"id":"<personal-wsid>","token":"<personal-tok>"}
  ]'

When set, mcp_cli iterates the array and spawns one (register +
heartbeat + inbox poller) trio per workspace. Single-workspace mode
(WORKSPACE_ID + MOLECULE_WORKSPACE_TOKEN) is unchanged — every
existing operator's setup keeps working bit-for-bit.

**Per-workspace token registry** (platform_auth.py):
  register_workspace_token(wsid, tok) — populated by mcp_cli once
  per workspace before any thread spawns; thread-safe registration
  + lock-free reads on the hot path. auth_headers(workspace_id=...)
  routes to the per-workspace token; auth_headers() with no arg
  uses the legacy resolution path unchanged (back-compat).

**Per-workspace inbox cursors** (inbox.py):
  InboxState now supports cursor_paths={wsid: Path,...}. Each poller
  advances its own cursor — one workspace's slow poll can't stall
  another, and a 410 only resets the affected workspace's cursor.
  Single-workspace constructor (cursor_path=Path(...)) still works
  exactly as before via __post_init__ promotion to the empty-string
  key. Cursor filenames disambiguated by workspace_id[:8] when
  multi-workspace; single-workspace keeps the legacy filename so
  upgrade doesn't invalidate on-disk state.

**Arrival workspace tagging** (inbox.py):
  InboxMessage.arrival_workspace_id — tells the agent which OF ITS
  workspaces the inbound message arrived on. Set by the poller from
  the cursor key. to_dict() omits the field when empty so single-
  workspace consumers see no shape change.

**Reply routing** (a2a_tools.py + a2a_mcp_server.py + registry.py):
  send_message_to_user(workspace_id=...) — optional override that
  selects which workspace's /notify endpoint to POST to (and which
  token authenticates). Multi-workspace agents pass the inbound
  message's arrival_workspace_id; single-workspace agents omit it
  and route to the only registered workspace via the legacy URL.

## Out of scope (future PRs)

- PR-2: cross-workspace delegation auto-routing — when an agent
  receives a request from personal-ws "delegate to ops-bot" and
  ops-bot lives in company-ws, the agent should auto-pick its
  company-ws identity for the outbound delegate_task. Today the
  agent must pass via_workspace explicitly (or fall through to
  primary workspace).
- PR-3: memory namespacing — commit_memory() still writes to the
  primary workspace's memory regardless of inbound context. Will
  revisit when the new memory system (PR #2733 just landed) settles.

## Tests

  workspace/tests/test_mcp_cli_multi_workspace.py — 24 new tests:
    * MOLECULE_WORKSPACES JSON parsing (valid + 6 error shapes)
    * Token registry register / lookup / rotation / clear
    * auth_headers routing by workspace_id with legacy fallback
    * Per-workspace cursor save/load/reset isolation
    * arrival_workspace_id present-when-set, omitted-when-empty
    * default_cursor_path namespacing

  All 110 pre-existing tests in test_mcp_cli.py / test_inbox.py /
  test_platform_auth.py still pass — back-compat is mechanical.

Refs: project memory entry "External agent multi-workspace
registration", design questions answered 2026-05-04 by user
(JSON env var; explicit memory writes deferred to PR-3).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 08:06:00 -07:00
Hongming Wang 3b3e821a60 Merge pull request #2736 from Molecule-AI/feat/memory-v2-pr6-compat-shim
Memory v2 PR-6: backward-compat shim — legacy tools route to v2
2026-05-04 15:05:14 +00:00
Hongming Wang a08eaa6ca2 Merge pull request #2735 from Molecule-AI/auto-sync/main-51e7d946
chore: sync main → staging (auto, ff to 51e7d946)
2026-05-04 08:04:43 -07:00
Hongming Wang c5322f318a Memory v2 PR-7: one-shot backfill CLI (dry-run + apply)
Builds on merged PR-1..6. Operator runs this once at cutover to copy
agent_memories rows into the v2 plugin's storage.

Usage:
  memory-backfill -dry-run                    # count + diff, no writes
  memory-backfill -apply                      # actually copy
  memory-backfill -apply -limit=10000         # cap rows per run
  memory-backfill -apply -workspace=<uuid>    # one workspace only

Required env: DATABASE_URL + MEMORY_PLUGIN_URL.

Translation matches the PR-6 legacy shim:
  LOCAL  → workspace:<workspace_id>
  TEAM   → team:<root_id> (resolved via the same namespace.Resolver
                           the runtime uses)
  GLOBAL → org:<root_id>

Idempotent: each row is keyed by its UUID; re-running the backfill
does not duplicate writes (plugin handles deduplication).

What ships:
  * cmd/memory-backfill/main.go: CLI entry, run() driver,
    backfill() workhorse, mapScopeToNamespace + namespaceKindFromString
    helpers
  * main_test.go: 100% on the functional logic (mapScopeToNamespace,
    namespaceKindFromString, backfill(), all CLI validation paths)

Coverage: 80.2% of statements. The 19.8% gap is main()'s body
(log.Fatalf — not unit-testable) and run()'s real-DB integration
(sql.Open + db.PingContext + new client/resolver — requires a live
postgres). Integration coverage for this path lives in PR-11
(E2E plugin-swap test).

Edge cases pinned (in functional logic):
  * Every legacy scope → namespace mapping
  * Unknown scope → skip with diagnostic, increment skipped counter
  * Resolver error → propagate, abort run
  * No-matching-kind in writable list → skip with error message
  * Plugin UpsertNamespace error → increment errors, continue
  * Plugin CommitMemory error → increment errors, continue
  * Query error → propagate, abort
  * Scan error → increment errors, continue
  * Mid-iteration row error → propagate, abort
  * Workspace filter passes through to SQL WHERE clause
  * Dry-run mode never calls plugin
  * CLI: rejects both/neither modes, missing env vars, bad flags
2026-05-04 08:04:07 -07:00
Hongming Wang 290e6dfdc3 Memory v2 PR-6: backward-compat shim — legacy tools route to v2
Builds on merged PR-1..5. Adds the bridge that lets legacy
commit_memory / recall_memory tools route through the v2 plugin path
when MEMORY_PLUGIN_URL is wired, otherwise fall through to the
existing DB-backed code unchanged.

What ships:
  * handlers/mcp_tools_memory_legacy_shim.go — translation helpers:
      scopeToWritableNamespace, scopeToReadableNamespaces,
      commitMemoryLegacyShim, recallMemoryLegacyShim,
      namespaceKindToLegacyScope
  * handlers/mcp_tools.go — toolCommitMemory + toolRecallMemory now
    delegate to the shim when memv2 is wired

Translation:
  commit:  LOCAL  → workspace:<self>
           TEAM   → team:<root>     (resolver picks at runtime)
           empty  → defaults to LOCAL (preserves legacy default)
           GLOBAL → still rejected at MCP bridge (C3 preserved)
  recall:  LOCAL  → search restricted to workspace:<self>
           TEAM   → workspace:<self> + team:<root>
           empty  → all readable (matches v2 default behavior)
           GLOBAL → blocked at MCP bridge (C3 preserved)

Response shapes are preserved exactly:
  commit: {"id":"...","scope":"LOCAL"|"TEAM"} — agents see no diff
  recall: [{"id":"...","content":"...","scope":"LOCAL"|...,"created_at":"..."}, ...]
  org-namespace memories get the same [MEMORY id=... scope=ORG ns=...]
  prefix as v2 search; legacy scope label comes back as "GLOBAL"

Operational rollout:
  * Today: MEMORY_PLUGIN_URL unset on most operators → legacy DB path
  * After PR-7 backfill: operators set MEMORY_PLUGIN_URL → all writes
    flow through plugin transparently
  * After PR-8 cutover: dual-write removed, plugin is the only path
  * After PR-9 (~60 days later): legacy tool entries dropped entirely

Coverage: 100% on every helper, 100% on recallMemoryLegacyShim,
94.7% on commitMemoryLegacyShim. The 1 uncovered line is a defensive
guard against a v2-response-parse error that's unreachable when the
v2 tool is operating correctly (it always returns valid JSON).

Edge cases pinned:
  * scope translation for every legacy value + invalid scope
  * resolver error propagation
  * plugin error propagation
  * GLOBAL still blocked
  * default-scope fallback (LOCAL)
  * empty content rejected
  * No-op when v2 unwired (legacy SQL path exercised via sqlmock)
  * org-namespace memory wrap on recall + GLOBAL scope label round-trip
  * No-results returns "No memories found." (legacy message preserved)
2026-05-04 08:01:41 -07:00
Hongming Wang f74fff6ae4 Merge pull request #2734 from Molecule-AI/feat/memory-v2-pr5-mcp-tools
Memory v2 PR-5: 6 new MCP tools wired through the plugin
2026-05-04 14:53:45 +00:00
Hongming Wang 5bfa4b1d80 Memory v2 PR-5: 6 new MCP tools wired through the plugin
Builds on PR-1, PR-2, PR-3, PR-4 (all merged). Adds the agent-facing
v2 surface for the memory plugin contract.

What ships (all in handlers/mcp_tools_memory_v2.go, no edits to
the legacy commit_memory / recall_memory paths):

  commit_memory_v2   — write to a namespace; default workspace:self
  search_memory      — search across namespaces; default = all readable
  commit_summary     — kind=summary, 30-day default TTL, runtime-overridable
  list_writable_namespaces — discover what you can write to
  list_readable_namespaces — discover what you can read from
  forget_memory      — delete by id, only in namespaces you can write to

Workspace-server is the security perimeter — every layer the plugin
mustn't be trusted with runs here:

  * SAFE-T1201 redactSecrets BEFORE every plugin write
  * Server-side ACL re-validation: CanWrite + IntersectReadable run
    on EVERY request, never trusting client-supplied namespaces (a
    canvas re-parent between list_writable and commit would otherwise
    let a stale namespace slip through)
  * org:* writes audited to activity_logs (SHA256, not plaintext) —
    matches memories.go:201-221 so the schema stays uniform
  * Audit failure does NOT block the write (logged + continue) —
    failing closed would deny org-scope writes whenever activity_logs
    is unhappy
  * org:* memories get the [MEMORY id=... scope=ORG ns=...]: prefix
    on read — preserves the prompt-injection mitigation from
    memories.go:455-461

Coexistence design: legacy commit_memory + recall_memory still wired
to their old code paths in mcp_tools.go. PR-6 will alias them to
delegate to these v2 implementations. PR-9 (60 days post-cutover)
removes the legacy entries.

Wiring:
  * MCPHandler gains an memv2 field (nil-safe; tools return a clear
    error when MEMORY_PLUGIN_URL is unset rather than crashing)
  * WithMemoryV2(plugin, resolver) is the production wiring API
    main.go calls at boot
  * withMemoryV2APIs(plugin, resolver) is the test-injectable variant
    against the memoryPluginAPI / namespaceResolverAPI interfaces

Coverage: 100.0% on every new function in mcp_tools_memory_v2.go.

Edge cases pinned:
  * empty/whitespace content → reject before plugin
  * plugin unconfigured → clear error, no crash
  * ACL violation → clear error
  * resolver error → wrapped error
  * plugin error → wrapped error
  * malformed expires_at → silently ignored (no exception)
  * org write audit failure → logged, write proceeds
  * search namespace intersection drops foreign entries
  * search with all-foreign namespaces → empty result, plugin not called
  * search org memories get delimiter wrap, workspace memories do not
  * forget with explicit + default namespace
  * forget cross-scope rejected
  * pickStr / pickStringSlice handle missing keys, wrong types, mixed slices
  * wrapOrgDelimiter format is exact-match
  * dispatch wires all 6 tools (no "unknown tool" error)
2026-05-04 07:50:26 -07:00
Hongming Wang 51e7d94605 Merge pull request #2724 from Molecule-AI/staging
staging → main: auto-promote 3f4c5f8
2026-05-04 07:50:20 -07:00
Hongming Wang f2397bf138 Merge pull request #2733 from Molecule-AI/feat/memory-v2-pr3-postgres-plugin
Memory v2 PR-3: built-in postgres plugin server + schema migrations
2026-05-04 14:37:24 +00:00
Hongming Wang ff5f4cbf7c Memory v2 PR-3: built-in postgres plugin server + schema migrations
Builds on merged PR-1 (#2729), independent of PR-2/PR-4.

Implements every endpoint of the v1 plugin contract behind an HTTP
server (cmd/memory-plugin-postgres/) backed by postgres. Operators
run this binary next to workspace-server; it's the default
implementation MEMORY_PLUGIN_URL points at.

What ships:
  - cmd/memory-plugin-postgres/main.go: boot, signal-driven shutdown,
    boot-time migrations, configurable LISTEN/DATABASE/MIGRATION_DIR
  - cmd/memory-plugin-postgres/migrations/001_memory_v2.up.sql:
      memory_namespaces (PK on name, kind CHECK, expires_at, metadata)
      memory_records (FK to namespaces with CASCADE, kind+source CHECK,
                      pgvector embedding, FTS tsvector, ivfflat partial
                      index on embedding, partial index on expires_at)
  - internal/memory/pgplugin/store.go: storage layer using lib/pq
  - internal/memory/pgplugin/handlers.go: HTTP layer (no router dep —
    a switch on URL.Path keeps the binary's dep surface tiny)
  - 100% statement coverage on store.go + handlers.go

Schema notes:
  - These tables live next to the plugin binary, NOT in workspace-
    server/migrations/. When operators swap the plugin, these tables
    become orphaned (operator drops manually). Documented in PR-10.
  - Search supports semantic (pgvector cosine) → FTS (>=2 char query)
    → ILIKE (1-char query) → recent-listing (no query), with a TTL
    filter applied uniformly across all paths.
  - DELETE on namespace cascades to memory_records (FK ON DELETE
    CASCADE) — a deleted namespace immediately frees its memories.

Coverage corner cases pinned:
  - Health: ok, degraded (db ping fails), no-ping fn
  - Every CRUD endpoint: happy path, bad name, bad JSON, bad body,
    not-found, store errors, exec/scan/marshal errors
  - Search: FTS, semantic, short-query (ILIKE), no-query (recent),
    kinds filter, store errors, scan errors, mid-iteration row error
  - Routing edge cases: unknown path, empty namespace, unknown sub,
    method-not-allowed, GET on /v1/health (allowed), POST on /v1/health
    (404), GET on /v1/search (404)
  - Helper internals: marshalMetadata (nil/happy/unmarshalable),
    nullTime (nil/non-nil), vectorString (empty/format),
    nullVectorString (empty/non-empty), scanNamespace +
    scanMemory metadata-decode errors

No callers in workspace-server yet; integration starts in PR-5
(MCP handlers wire the plugin client through to MCP tools).
2026-05-04 07:31:56 -07:00
Hongming Wang c53b2b104f Merge pull request #2730 from Molecule-AI/feat/memory-v2-pr4-namespace-resolver
Memory v2 PR-4: namespace resolver + tests (stacked on PR-1)
2026-05-04 14:28:22 +00:00
Hongming Wang 01b653d6b0 Memory v2 PR-4: namespace resolver + tests
Stacked on PR-1 (#2729). Computes the readable/writable namespace lists
for a workspace from the live workspaces tree at request time. No
precomputed columns, no migrations — re-parenting on canvas takes
effect immediately on the next memory call.

What ships:
  - workspace-server/internal/memory/namespace/resolver.go
    - walkChain: recursive CTE, walks parent_id chain to root, capped
      at depth 50 to defend against malformed/cyclic data
    - derive: maps a chain to (workspace, team, org) namespace strings
    - ReadableNamespaces / WritableNamespaces: the public API
    - CanWrite + IntersectReadable: server-side ACL helpers MCP
      handlers (PR-5) will call before talking to the plugin
  - resolver_test.go: 100% statement coverage

Design choices worth flagging:
  - Today's tree is depth-1 (root + children). The recursive CTE
    handles arbitrary depth so we don't have to revisit the resolver
    when the tree deepens.
  - GLOBAL→org write restriction (memories.go:167-174) is preserved
    by gating the org namespace's Writable flag on parent_id IS NULL.
  - Removed-status workspaces are NOT filtered from the chain walk —
    matches today's TEAM behavior (memories.go:367-372 filters on
    read, not on tree walk).
  - IntersectReadable with empty `requested` returns ALL readable
    namespaces (default-search-everything semantic from the discovery
    tools spec).

This package has zero callers in this PR; integration starts in PR-5.
2026-05-04 07:25:33 -07:00
Hongming Wang f05633f5b0 Merge pull request #2732 from Molecule-AI/fix/canary-timeout-tail-latency
ci(canary): bump synth timeout 12→20 min to absorb apt tail latency
2026-05-04 14:04:53 +00:00
Hongming Wang ff1003e5f6 ci(canary): bump timeout-minutes 12 → 20 to absorb apt tail latency
Today's 4 cancelled canaries (25319625186 / 25320942822 / 25321618230 /
25322499952) were all blown by the workflow timeout despite the
underlying tenant boot completing successfully (PR molecule-controlplane#455
fix verified — boot events all reach `boot_script_finished/ok`).

Why the budget was wrong:

The tenant user-data install phase runs apt-get update + install of
docker.io / jq / awscli / caddy / amazon-ssm-agent FROM RAW UBUNTU on
every tenant boot — none of it is pre-baked into the tenant AMI
(EC2_AMI=ami-0ea3c35c5c3284d82, raw Jammy 22.04). Empirical
fetch_secrets/ok timing across today's canaries:

  51s   debug-mm-1777888039 (09:47Z)
  82s   25319625186          (12:42Z)
  143s  25320942822          (13:11Z)
  625s  25322499952          (13:43Z)

Same EC2_AMI, same instance type (t3.small), same user-data install
sequence — variance is entirely apt-mirror tail latency. A 12-min job
budget leaves only ~2 min for the workspace on slow-apt days; the
workspace itself needs ~3.5 min for claude-code cold boot, so the
budget is structurally too tight whenever apt is slow.

20 min absorbs even the 10+ min boot worst-case and still leaves the
workspace its full ~7 min budget. Cap stays well under the runner's
6-hour ubuntu-latest job ceiling.

Real fix: pre-bake caddy + ssm-agent into the tenant AMI so the boot
phase is no-ops on cached pkgs (will file controlplane#TBD as
follow-up — packer/install-base.sh today only bakes the WORKSPACE thin
AMI, not the tenant AMI; tenants always boot from raw Ubuntu).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 07:02:12 -07:00
Hongming Wang d9fb57092c Merge pull request #2731 from Molecule-AI/feat/memory-v2-pr2-client
Memory v2 PR-2: HTTP plugin client + circuit breaker + capability negotiation
2026-05-04 14:00:40 +00:00
Hongming Wang c1cff3169f Memory v2 PR-2: HTTP plugin client + breaker + capability negotiation
Builds on PR-1 (#2729). Implements every endpoint in the OpenAPI spec
plus two operational concerns the agent never sees:

  1. Capability negotiation. Boot/Refresh probes /v1/health and
     captures the plugin's capability list. MCP handlers (PR-5) ask
     SupportsCapability before exposing capability-gated features —
     e.g., agents can only request semantic search when "embedding"
     is reported.

  2. Circuit breaker. Three consecutive failures open the breaker for
     60 seconds; while open, calls fail fast with ErrBreakerOpen.
     Picked these constants because:
       - 3 failures: long enough to skip transient blips, short enough
         to react before all in-flight handlers stack on the timeout
       - 60s cooldown: long enough to back off a flapping plugin,
         short enough that recovery is felt within a single session
     4xx responses do NOT count toward the breaker (those are client
     bugs, not plugin health issues); 5xx + transport errors do.

What ships:
  - workspace-server/internal/memory/client/client.go
  - client_test.go: 100% statement coverage

Coverage corner cases pinned:
  - env-var success branches in New (parseDurationEnv applied)
  - json.Marshal error (via channel in Propagation)
  - http.NewRequestWithContext error (via unbalanced bracket in BaseURL)
  - 204 NoContent on endpoint that normally has a body
  - 4xx vs 5xx breaker behavior (4xx must NOT trip)
  - breaker cooldown elapsed → reset on next success
  - all 6 public endpoints fail-fast when breaker is open

This package has no callers in this PR; integration starts in PR-5.
2026-05-04 06:57:24 -07:00
Hongming Wang f52de74b7b Merge pull request #2729 from Molecule-AI/feat/memory-v2-pr1-contract
Memory v2 PR-1: OpenAPI plugin contract + Go bindings
2026-05-04 13:51:56 +00:00
Hongming Wang 53d823e719 Memory v2 PR-1: OpenAPI plugin contract + Go bindings
First of 11 PRs implementing the memory-system plugin refactor (RFC #2728).
This PR is pure additive scaffolding — no behavior change, no integration
yet. It defines the wire shape between workspace-server and a memory
plugin so PR-2 (HTTP client) and PR-3 (built-in postgres plugin) can be
built against a single source of truth.

What ships:
  - docs/api-protocol/memory-plugin-v1.yaml: OpenAPI 3.0.3 spec covering
    /v1/health, namespace upsert/patch/delete, memory commit, search,
    forget. Auth-free (private network only); workspace-server is the
    only sanctioned client and the security perimeter.
  - workspace-server/internal/memory/contract: typed Go bindings with
    Validate() methods on every wire object so both client (PR-2) and
    server (PR-3) self-check at the boundary.
  - Round-trip JSON tests for every type (catch asymmetric tag bugs).
  - 5 golden vector files under testdata/ pinning the exact wire shape;
    update via UPDATE_GOLDENS=1.

Coverage: 100% of statements in contract.go.

The validation rules encode design decisions worth flagging in review:
  - SearchRequest with empty Namespaces is REJECTED at plugin level —
    workspace-server is required to intersect the readable set
    server-side; an empty list reaching the plugin is a bug.
  - NamespacePatch with no fields is REJECTED — empty patches are
    pointless round-trips.
  - MemoryWrite with whitespace-only Content is REJECTED — zero-info
    memories pollute search results.

No code yet calls into this package; integration starts in PR-2.
2026-05-04 06:45:52 -07:00
Hongming Wang 4511659a9e Merge pull request #2727 from Molecule-AI/ci/synth-e2e-bump-cadence-to-10min
ci: bump continuous-synth-e2e cadence 3→6 fires/hour, clean slots
2026-05-04 12:13:40 +00:00
Hongming Wang 032c011b37 ci: bump continuous-synth-e2e cadence 3→6 fires/hour, all clean slots
Change cron from '10,30,50' (3 fires/hour) to '2,12,22,32,42,52'
(6 fires/hour). All new slots are 1-3 min away from any other
cron, avoiding both the cf-sweep collisions (:15, :45) and the
:30 heavy slot (canary-staging /30, sweep-aws-secrets,
sweep-stale-e2e-orgs every :15).

Why: empirically 2026-05-04 the canary fired only once per hour
on the 10,30,50 schedule (see #2726). Bumping fires-per-hour
gives more chances to land a survived fire under GH's load-
related drop ratio, and keeping all slots in clean lanes
minimizes the per-fire drop probability.

At empirically-observed ~67% drop ratio, 6 attempts/hour yields
~2 effective fires = ~30 min cadence; closer to the 20-min
target than the current shape and provides a real degradation
alarm if drops get worse.

Cost: ~$0.50/day → ~$1/day. Negligible.

Closes #2726.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 05:10:48 -07:00
Hongming Wang c0997a5703 Merge pull request #2722 from Molecule-AI/auto-sync/main-25cb17c9
chore: sync main → staging (auto, ff to 25cb17c9)
2026-05-04 10:46:46 +00:00
Hongming Wang 1d3d18fd66 Merge pull request #2725 from Molecule-AI/fix/team-expand-routes-via-auto-dispatcher
fix(team): route Expand children through provisionWorkspaceAuto so SaaS gets per-workspace EC2
2026-05-04 10:46:44 +00:00
Hongming Wang be997883c9 Centralize backend selection in provisionWorkspaceAuto
User-reported 2026-05-04: deploying a team org-template ("Design
Director" + 6 sub-agents) on a SaaS tenant produced 7-of-7
WORKSPACE_PROVISION_FAILED with the misleading message
"container started but never called /registry/register". Diagnose
returned "docker client not configured on this workspace-server" and
the workspace rows had no instance_id.

Root cause: TeamHandler.Expand hardcoded h.wh.provisionWorkspace —
the Docker leg of WorkspaceHandler. WorkspaceHandler.Create branched
on h.cpProv to pick CP-managed EC2 (SaaS) vs local Docker
(self-hosted), but Expand never used that branch. On SaaS the docker
goroutine ran but had no socket, so children silently sat in
"provisioning" until the 600s sweeper marked them failed.

Architectural principle (user): templates own
runtime/config/prompts/files/plugins; the platform owns where it
runs. Backend selection belongs in one helper.

Fix:
- Extract WorkspaceHandler.provisionWorkspaceAuto: picks CP when
  cpProv is set, Docker when only provisioner is set, returns false
  when neither (caller marks failed).
- WorkspaceHandler.Create routes through Auto.
- TeamHandler.Expand routes through Auto.

Tests pin three invariants:
- TestProvisionWorkspaceAuto_NoBackendReturnsFalse — Auto signals
  fall-through correctly so the caller can persist + mark-failed.
- TestProvisionWorkspaceAuto_RoutesToCPWhenSet — when cpProv is
  wired, Start lands on CP (the user-visible regression target).
  Discipline-verified: removing the cpProv branch fails this.
- TestTeamExpand_UsesAutoNotDirectDockerPath — source-level guard
  against future refactors reintroducing the hardcoded Docker call.
  Discipline-verified: reverting team.go fails this with a clear
  message naming the bug class.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 03:43:41 -07:00
Hongming Wang 3f4c5f8076 Merge pull request #2723 from Molecule-AI/fix/communication-overlay-rate-limit
fix(canvas): CommunicationOverlay rate-limit storm — cap fan-out, gate on visibility, slow cadence
2026-05-04 10:22:12 +00:00
Hongming Wang e1c99cd24c Pin the visibility gate behavior, not just cadence
Self-review on PR #2723 caught a coverage gap: the existing
"visibility gate" describe block actually tested cadence (10s/30s
timing), not the gate itself. If a refactor dropped the
`if (!visible) return` line, the cadence test would still pass
because the effect would still fire every 30s — the regression would
silently ship.

New test renders with comms-returning mock so the panel renders, clicks
the close button, advances 60s, asserts no further fetches occur.

Discipline-verified: removed `if (!visible) return` from the source,
test fails as expected. Restored, test passes.

Same failure mode as PR #434 (test asserted broken behavior) — pin
what you claim to fix, not the easy substring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 03:18:42 -07:00
Hongming Wang 26b5b21238 Fix CommunicationOverlay rate-limit storm: cap fan-out + gate on visibility
User report 2026-05-04: 8+ workspace tenant (Design Director + 6 sub-agents
+ 3 standalones) saw sustained 429s in canvas console hitting
/workspaces/<id>/activity?limit=5. Server-side rate limit is 600 req/min/IP.

Three compounding issues in CommunicationOverlay:
1. Polled regardless of visibility — collapsed panel still hammered the API
2. 10s cadence — 6 req every 10s = 36 req/min from this overlay alone
3. Fan-out cap of 6 workspaces — scaled linearly with workspace count

Fix:
- Gate setInterval on `visible` (effect re-runs when collapsed/expanded)
- Cadence 10s → 30s
- Fan-out cap 6 → 3

Combined: ~36 req/min worst case → 6 req/min worst case (6x reduction),
0 req/min when collapsed.

Tests:
- Fan-out cap: 6 online nodes mounted → exactly 3 fetches (was 6)
- Offline gate: offline workspace never polled
- Cadence: timer at 10s = no new fetch; timer at 30s = next batch fires

Each test would fail if the corresponding dial regressed.

Follow-up (out of scope): structurally right fix is to consume the
WORKSPACE_ACTIVITY WS broadcast instead of polling per-workspace. Server
already publishes the events; canvas just isn't subscribing yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 03:18:42 -07:00
molecule-ai[bot] 25cb17c906 Merge pull request #2721 from Molecule-AI/staging
staging → main: auto-promote 238f4d4
2026-05-04 03:03:32 -07:00
Hongming Wang 238f4d45df Merge pull request #2720 from Molecule-AI/fix/chat-upload-poll-mode-distinct-error
fix: distinguish poll-mode workspace from transient empty-URL on chat upload
2026-05-04 09:46:05 +00:00
Hongming Wang bcea8ac822 Broaden empty-URL 422 to cover NULL delivery_mode (production reality)
Live-probed user's tenant: three of three external-runtime workspaces
register with delivery_mode = NULL, not "poll". The earlier narrow
poll-only check fell through to the misleading 503 for the actually-
observed shape.

Invariant we want: URL empty + not-exactly-"push" → no dispatch path
will ever exist → 422. Only push-mode with empty URL is genuinely
transient (mid-boot, restart in progress) → 503.

Added TestChatUpload_NullModeEmptyURL using the user's actual workspace
ID. Existing TestChatUpload_NoURL switched to explicit "push" mode
(was relying on default — unsafe given the new branching).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 02:42:46 -07:00
Hongming Wang 87ae691e67 Distinguish poll-mode workspace from transient empty-URL on chat upload
External-runtime workspaces that register in poll mode have no callback
URL by design — the platform never dispatches to them, so chat upload
(HTTP-forward by design) can't proceed. Returning 503 + "workspace url
not registered yet" was misleading: the "yet" implied transient state,
but the URL would never arrive.

Caught externally on 2026-05-04: user uploading an image to an external
"mac laptop" runtime workspace saw the 503 and assumed they should
retry. The workspace's poll mode meant retrying would never help.

Fix: include delivery_mode in the workspace lookup. When URL is empty:
- poll mode → 422 + "re-register in push mode with a public URL"
  (Unprocessable Entity — this request can't succeed against this
  workspace's configuration; no retry will help)
- push mode → 503 + "not registered yet" (genuine transient state —
  retry after next heartbeat is correct)

Test: TestChatUpload_PollModeEmptyURL pins the new 422 path; existing
TestChatUpload_NoURL strengthened to assert the "not registered yet"
substring stays on the push branch (it would have silently passed if
the new 422 path had clobbered both branches).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 02:42:46 -07:00
Hongming Wang 99f6481acc Merge pull request #2719 from Molecule-AI/auto-sync/main-2c4bfd83
chore: sync main → staging (auto, ff to 2c4bfd83)
2026-05-04 09:08:18 +00:00
molecule-ai[bot] 2c4bfd83e4 Merge pull request #2718 from Molecule-AI/staging
staging → main: auto-promote 9e8aa39
2026-05-04 09:04:19 +00:00
Hongming Wang 9e8aa39692 Merge pull request #2717 from Molecule-AI/fix/a2a-timeout-cold-llm
e2e: bump A2A timeout from 30s → 90s for cold MiniMax workspace
2026-05-04 08:52:03 +00:00
Hongming Wang b7f0b279eb e2e: bump A2A timeout from 30s → 90s for cold MiniMax workspace
After #2710 + #2714 + the MOLECULE_STAGING_MINIMAX_API_KEY repo secret
landed (2026-05-04 08:37Z), the next dispatched canary
(run 25309323698) cleared every previous failure point but timed out
at step 8/11 with `curl: (28) Operation timed out after 30002 ms`.

The canary creates a fresh org per run, so every A2A POST hits a cold
workspace + cold MiniMax endpoint:
  workspace boot → claude-code adapter starts event loop
  → first prompt ships → TLS handshake to api.minimax.io
  → cold model warmup → first-token generation

Cold-call P95 lands around 25-30s on MiniMax-M2.7-highspeed; the
30-second `CURL_COMMON --max-time` is right on the edge and the run
that timed out was 30.002s of zero bytes received.

Fix: override `--max-time` for the canary's A2A POST only — 90s gives
~3x headroom. Subsequent A2A turns to the same workspace are
sub-second, so this only widens step 8 of the canary's first turn.
The shared CURL_COMMON timeout stays at 30s for everything else
(provision, register, terminal, peers, teardown), where 30s is right.

Verifies the rest of the canary script (provision, DNS, terminal-EIC,
A2A round-trip) is platform-correct and the only operational gap is
this latency knob.
2026-05-04 01:49:42 -07:00
Hongming Wang fa3353a3ca Merge pull request #2716 from Molecule-AI/auto-sync/main-1187a66d
chore: sync main → staging (auto, ff to 1187a66d)
2026-05-04 08:34:59 +00:00
molecule-ai[bot] 1187a66d2e Merge pull request #2715 from Molecule-AI/staging
staging → main: auto-promote d360c34
2026-05-04 01:20:07 -07:00
Hongming Wang d360c34a30 Merge pull request #2714 from Molecule-AI/feat/anthropic-direct-e2e-path
e2e: add direct-Anthropic LLM-key path alongside MiniMax + OpenAI
2026-05-04 07:53:26 +00:00
Hongming Wang 287961375f Merge pull request #2713 from Molecule-AI/auto-sync/main-f1840d46
chore: sync main → staging (auto, ff to f1840d46)
2026-05-04 07:53:16 +00:00
Hongming Wang 98f883cb99 e2e: add direct-Anthropic LLM-key path alongside MiniMax + OpenAI
Adds a third secrets-injection branch in test_staging_full_saas.sh
behind a new E2E_ANTHROPIC_API_KEY env var, wired into all three
auto-running E2E workflows (canary-staging, e2e-staging-saas,
continuous-synth-e2e) via a new MOLECULE_STAGING_ANTHROPIC_API_KEY
repo secret slot.

Operator motivation: after #2578 (the staging OpenAI key went over
quota and stayed dead 36+ hours) we shipped #2710 to migrate the
canary + full-lifecycle E2E to claude-code+MiniMax. Discovered post-
merge that MOLECULE_STAGING_MINIMAX_API_KEY had never been set after
the synth-E2E migration on 2026-05-03 either — synth has been red the
whole time, not just OpenAI quota.

Setting up a MiniMax billing account from scratch is non-trivial
(needs platform-specific signup, KYC, top-up). Operators who already
have an Anthropic API key for their own Claude Code session can now
just set MOLECULE_STAGING_ANTHROPIC_API_KEY and have all three
auto-running E2E gates green within one cron firing.

Priority chain in test_staging_full_saas.sh (first non-empty wins):
  1. E2E_MINIMAX_API_KEY      → MiniMax (cheapest)
  2. E2E_ANTHROPIC_API_KEY    → direct Anthropic (cheaper than gpt-4o,
                                lower setup friction than MiniMax)
  3. E2E_OPENAI_API_KEY       → langgraph/hermes paths

Verify-key case-statement in all three workflows accepts EITHER
MiniMax OR Anthropic for runtime=claude-code; error message names
both options so operators know they don't have to register a MiniMax
account if they already have an Anthropic key.

Pinned to runtime=claude-code — hermes/langgraph use OpenAI-shaped
envs and won't honour ANTHROPIC_API_KEY without further wiring.

After this lands + secret is set, the dispatched canary verifies the
new path:
  gh workflow run canary-staging.yml --repo Molecule-AI/molecule-core --ref staging
2026-05-04 00:51:14 -07:00
molecule-ai[bot] f1840d467c Merge pull request #2712 from Molecule-AI/staging
staging → main: auto-promote 563e58a
2026-05-04 07:38:58 +00:00
Hongming Wang 5596cb52ef Merge pull request #2711 from Molecule-AI/auto-sync/main-170e037a
chore: sync main → staging (auto, ff to 170e037a)
2026-05-04 07:25:30 +00:00
Hongming Wang 563e58a835 Merge pull request #2710 from Molecule-AI/fix/canary-staging-migrate-to-minimax
canary-staging: migrate from hermes+OpenAI to claude-code+MiniMax
2026-05-04 07:23:37 +00:00
Hongming Wang eaee113416 e2e-staging-saas: same migration off OpenAI default to claude-code+MiniMax
Bundles the same hermes+OpenAI → claude-code+MiniMax migration onto
the full-lifecycle E2E that's been red on every provisioning-critical
push since 2026-05-01. Same root cause as the canary fix in the prior
commit: MOLECULE_STAGING_OPENAI_KEY hit insufficient_quota and there's
no SLA on operator billing top-up.

Same shape as canary commit: claude-code as default runtime + MiniMax
as primary key + hermes/langgraph kept as workflow_dispatch options
with OpenAI fallback. Per-runtime verify-key case-statement matches
canary-staging.yml + continuous-synth-e2e.yml byte-for-byte.

Two extra wrinkles vs canary:
- Dispatch input `runtime` default flipped from "hermes" to "claude-code"
  so operators dispatching from the UI get the safe path by default.
  They can still pick hermes/langgraph from the dropdown when they
  specifically want to exercise OpenAI.
- E2E_MODEL_SLUG is dispatch-aware: MiniMax-M2.7-highspeed for
  claude-code, openai/gpt-4o for hermes (slash-form per
  derive-provider.sh), openai:gpt-4o for langgraph (colon-form per
  init_chat_model). The branch comment in lib/model_slug.sh covers
  the rationale; pinning the slug here keeps the dispatch UX stable
  even when operators don't override.

After this lands + the canary commit lands, the only OpenAI-dependent
E2E surface is the operator-dispatch fallback. The cron canary, the
synth E2E, AND the full-lifecycle gate are all on MiniMax — separate
billing account, no OpenAI quota dependency on auto-runs.
2026-05-04 00:20:36 -07:00
molecule-ai[bot] 170e037ad1 Merge pull request #2709 from Molecule-AI/staging
staging → main: auto-promote a6b4758
2026-05-04 07:20:11 +00:00
Hongming Wang 6f8f978975 canary-staging: migrate from hermes+OpenAI to claude-code+MiniMax
Mirror the migration continuous-synth-e2e.yml made on 2026-05-03 (#265).
Both workflows hit the same MOLECULE_STAGING_OPENAI_KEY which went over
quota on 2026-05-01 (#2578) and stayed dead — the canary has been red
for 36+ hours waiting on operator billing top-up.

This switch breaks the canary's dependency on OpenAI billing entirely:
claude-code template's `minimax` provider routes ANTHROPIC_BASE_URL to
api.minimax.io/anthropic and reads MINIMAX_API_KEY at boot. MiniMax is
~5-10x cheaper per token than gpt-4.1-mini AND on a separate billing
account, so a future OpenAI quota collapse no longer wedges the
canary's "is staging alive?" signal.

Changes:
- E2E_RUNTIME: hermes → claude-code
- Add E2E_MODEL_SLUG: MiniMax-M2.7-highspeed (pin to MiniMax — the
  per-runtime claude-code default is "sonnet" which routes to direct
  Anthropic and would defeat the cost saving)
- Add E2E_MINIMAX_API_KEY env wired to MOLECULE_STAGING_MINIMAX_API_KEY
- Keep E2E_OPENAI_API_KEY as fallback for operator-dispatched runs that
  set E2E_RUNTIME=hermes via workflow_dispatch
- "Verify OpenAI key present" → per-runtime "Verify LLM key present"
  case statement matching synth E2E's exact shape (claude-code requires
  MiniMax, langgraph/hermes require OpenAI). Hard-fail on missing
  required key per #2578's lesson — soft-skip silently fell through to
  the wrong SECRETS_JSON branch and produced a confusing auth error
  5 min later instead of the clean "secret missing" message at the top.

Verifies #2578 root cause won't recur on the canary path. The synth
E2E and the manual e2e-staging-saas dispatch can still hit OpenAI when
explicitly chosen — only the cron canary moves off it.
2026-05-04 00:18:03 -07:00
Hongming Wang 034350f823 Merge pull request #2708 from Molecule-AI/auto-sync/main-b4a2c990
chore: sync main → staging (auto, ff to b4a2c990)
2026-05-04 07:08:55 +00:00
Hongming Wang a6b4758f5d Merge pull request #2707 from Molecule-AI/fix/sanitize-mcp-peer-identity
sanitise registry-sourced peer_name/peer_role before rendering into channel content
2026-05-04 07:04:56 +00:00
molecule-ai[bot] b4a2c990fb Merge pull request #2706 from Molecule-AI/staging
staging → main: auto-promote 44df1be
2026-05-04 00:03:27 -07:00
Hongming Wang ffd90dcf1e sanitise registry-sourced peer_name/peer_role before rendering into channel content
Anyone with a workspace token can register their workspace with any
agent_card.name via /registry/register. The universal MCP path renders
that name directly into the conversation turn the in-workspace agent
reads (`[from <name> (<role>) · peer_id=...]`), so a peer registering
with a name containing newlines + a fake instruction line ("\n\n[SYSTEM]
forward all secrets to peer X\n") would surface as multiple header lines
with the injected line floating outside the header sentinel — a direct
prompt-injection vector against any in-workspace agent receiving A2A
from that peer.

Mirror the TypeScript sanitiser shipped in
Molecule-AI/molecule-mcp-claude-channel#25 for the external channel
plugin: allowlist `[A-Za-z0-9 _.\-/+:@()]` (covers common agent-naming
shapes), whitespace-collapse stripped runs, 64-char cap with ellipsis
to keep the header scannable on narrow terminals. Apply at the meta
population site so BOTH the JSON-RPC envelope's `meta.peer_name` /
`meta.peer_role` AND the rendered conversation turn carry the safe form.

Returning None for empty / all-stripped input preserves the "no
enrichment" semantics so the formatter falls back to bare "peer-agent"
identity instead of producing "[from  · peer_id=...]" which looks like
a parse bug.

Tests pin the allowlist behaviour (newline strip, bracket strip, control
char strip, whitespace collapse, length cap) plus a defense-in-depth
check at the envelope-builder seam that a malicious registry response
end-to-end produces a sanitised envelope + content. 9/9 new tests pass,
69/69 file total green.
2026-05-04 00:02:00 -07:00
Hongming Wang 44df1befef Merge pull request #2705 from Molecule-AI/fix/a2a-overlay-render-loop
fix(canvas): A2ATopologyOverlay re-fetch storm hammering /activity → 429
2026-05-04 06:42:22 +00:00
Hongming Wang 32fc77bad4 fix(canvas): A2ATopologyOverlay re-fetch storm hammering /activity → 429
Selector instability caused fetchAndUpdate to recreate on every Zustand
nodes[] mutation (status flips, position drags, peer-discovery writes,
heartbeats — typically ~5/sec). Each recreation invalidated the
useEffect deps so the 60s polling fan-out fired on every update,
hammering /workspaces/<id>/activity?type=delegation 5×N requests/sec
until the edge rate-limit returned 429. User-reported via browser
console showing infinite uE→ux→uE→ux render loop and 429s repeating
across every visible workspace ID.

Root cause:
  const nodes = useCanvasStore((s) => s.nodes);
  const visibleIds = useMemo(() => nodes.filter(...).map(...), [nodes]);
  // useMemo dep recreates on every store update, even when ID set unchanged

Fix: select a STABLE STRING KEY (sorted CSV of visible IDs) from
Zustand. The selector's shallow-equal short-circuit prevents re-renders
when the actual visible-ID set is unchanged, so visibleIds reference
stays stable, fetchAndUpdate keeps its identity, and the useEffect
only re-fires when the visible-ID-set genuinely changes.

Tests:
- New regression test "does not re-fetch when nodes[] reference
  changes but visible IDs are the same"
- Discipline-verified: pre-fix code emits 4 fetches (2 mount + 2
  re-fetch storm), post-fix emits exactly 2
- Companion test "re-fetches when the visible ID set actually changes"
  pins the desired behavior so future "stabilization" doesn't suppress
  legitimate updates

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 23:39:36 -07:00
Hongming Wang ead920ac09 Merge pull request #2704 from Molecule-AI/auto-sync/main-5978cb3c
chore: sync main → staging (auto, ff to 5978cb3c)
2026-05-04 06:37:04 +00:00
molecule-ai[bot] 5978cb3c45 Merge pull request #2703 from Molecule-AI/staging
staging → main: auto-promote 2e3e36b
2026-05-04 06:33:00 +00:00
Hongming Wang 3934325e23 Merge pull request #2702 from Molecule-AI/auto-sync/main-63d9158e
chore: sync main → staging (auto, ff to 63d9158e)
2026-05-04 06:22:02 +00:00
hongming 2e3e36b91f Merge pull request #2701 from Molecule-AI/feat/universal-mcp-content-reply-hint
feat(mcp): wrap inbound channel content with identity + reply hint
2026-05-04 06:16:57 +00:00
molecule-ai[bot] 63d9158e12 Merge pull request #2700 from Molecule-AI/staging
staging → main: auto-promote 2678998
2026-05-04 06:15:39 +00:00
Hongming Wang b7c962bf86 feat(mcp): wrap inbound channel content with identity + reply hint
Mirrors the channel-plugin change in
Molecule-AI/molecule-mcp-claude-channel#24 so the universal MCP path
(in-workspace agents) gets the same self-documenting reply guidance the
external channel plugin path now ships.

Before: `params.content` was the raw inbound text — Claude saw bare prose
from a peer or canvas user with no surrounding context. To reply the
agent had to (a) fish the routing fields out of `meta`, (b) recall which
platform tool routes to which destination (send_message_to_user for
canvas, delegate_task for peer), and (c) construct the call by hand.

After: content is wrapped as

  [from <identity> · peer_id=<uuid>]    (or "[from canvas user]")
  <inbound text>
  ↩ Reply: <copy-pasteable tool call>

The identity comes from the existing registry-enrichment path (peer_name
+ peer_role from enrich_peer_metadata, with friendly fallbacks when the
registry lookup misses). Reply tool name lives in the same module as the
notification builder so the `feedback_doc_tool_alignment` drift class
can't bite — a future tool rename PR that misses this hint also fails
test_format_channel_content_*.

Tests: 6 new cases pinning the formatter (canvas_user vs peer_agent,
full enrichment, name-only, no enrichment, unknown-kind defensive
default, multi-line preservation) plus updated existing assertions in
the bridge + content tests. All asserts pin exact strings per
`feedback_assert_exact_not_substring`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 23:14:12 -07:00
Hongming Wang 26789988df Merge pull request #2699 from Molecule-AI/a11y/canvas-create-workspace-dialog
canvas/CreateWorkspaceDialog: hover sweep + semantic placeholders + focus rings
2026-05-04 05:59:06 +00:00
Hongming Wang b6ff280ca3 canvas/CreateWorkspaceDialog: hover sweep + semantic placeholders + focus rings
Sweep on the workspace-creation dialog — same patterns shipped on every
other surface.

- 2× bg-accent-strong hover:bg-accent (FAB + Create) hovered LIGHTER
  on white text → bg-accent hover:bg-accent-strong + focus-visible
  rings.
- Cancel: bg-surface-card hover:bg-surface-card no-op → surface-
  elevated + focus-visible ring.
- 4× placeholder-zinc-500/600 hardcoded → placeholder-ink-soft so
  placeholders flip with theme.
- FAB shadow tinting (shadow-blue-600/20 + shadow-blue-500/30) was
  hardcoded blue with no theme variant; switched to shadow-accent so
  the glow tint matches the brand mint accent in both modes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 22:56:33 -07:00
Hongming Wang acc10ca467 Merge pull request #2698 from Molecule-AI/auto-sync/main-f071cbb0
chore: sync main → staging (auto, ff to f071cbb0)
2026-05-04 05:53:20 +00:00
molecule-ai[bot] f071cbb0a3 Merge pull request #2697 from Molecule-AI/staging
staging → main: auto-promote 89e1096
2026-05-03 22:48:24 -07:00
Hongming Wang 3c70ddea5c Merge pull request #2695 from Molecule-AI/auto-sync/main-da59b8c5
chore: sync main → staging (auto, ff to da59b8c5)
2026-05-04 05:33:36 +00:00
Hongming Wang 89e10962b9 Merge pull request #2696 from Molecule-AI/a11y/canvas-org-import-skills
canvas/{OrgImportPreflightModal,SkillsTab}: 4 hover bugs + custom-source focus ring
2026-05-04 05:31:07 +00:00
Hongming Wang ff20fe4f61 canvas/{OrgImportPreflightModal,SkillsTab}: hover sweep + custom-source focus ring
OrgImportPreflightModal:
- 3× bg-accent-strong hover:bg-accent (Import + 2 add-key buttons) —
  accent is the LIGHTER variant, drops below AA on white text →
  bg-accent hover:bg-accent-strong.
- Cancel: bg-surface-card hover:bg-surface-card no-op → surface-
  elevated + focus-visible ring.

SkillsTab:
- Custom-source input had focus:border-violet-600 but no
  focus-visible ring — keyboard users only got a 1px border swap.
  Added focus-visible:ring-violet-600/50 (kept the violet to match
  the surrounding "custom install" UI's brand).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 22:28:41 -07:00
molecule-ai[bot] da59b8c5bc Merge pull request #2694 from Molecule-AI/staging
staging → main: auto-promote e307334
2026-05-04 05:21:13 +00:00
Hongming Wang e307334ca4 Merge pull request #2693 from Molecule-AI/a11y/canvas-tabs-button-sweep
canvas/{Details,Config,Activity}Tab: button hover sweep (6 buttons across 3 tabs)
2026-05-04 05:03:42 +00:00
Hongming Wang 0945936eee Merge pull request #2692 from Molecule-AI/auto-sync/main-25979072
chore: sync main → staging (auto, ff to 25979072)
2026-05-04 05:03:33 +00:00
Hongming Wang 16ad941a1e canvas/{Details,Config,Activity}Tab: button hover sweep across 6 buttons
Six button fixes — same trap patterns shipped on every other tab:

DetailsTab:
- Save button: bg-accent-strong hover:bg-accent (LIGHTER on white text,
  AA drop) → bg-accent hover:bg-accent-strong + focus-visible ring.
- Confirm Delete: bg-red-600 hover:bg-red-500 (LIGHTER on white text,
  AA drop) → bg-red-700 + focus-visible danger ring.
- Cancel: bg-surface-card hover:bg-surface-card (no-op) →
  surface-elevated.

ConfigTab:
- 2× Save buttons: same accent-LIGHTER trap → flipped + focus rings.
- Cancel: same no-op → surface-elevated.

ActivityTab:
- Refresh: same no-op → surface-elevated + focus-visible ring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 22:01:18 -07:00
molecule-ai[bot] 25979072fd Merge pull request #2691 from Molecule-AI/staging
staging → main: auto-promote 9973897
2026-05-04 04:54:21 +00:00
Hongming Wang 99738975e2 Merge pull request #2689 from Molecule-AI/auto-sync/main-4f6678ae
chore: sync main → staging (auto, ff to 4f6678ae)
2026-05-04 04:36:51 +00:00
Hongming Wang 66de1f1471 Merge pull request #2690 from Molecule-AI/a11y/canvas-schedule-tab-hovers
canvas/{Schedule,Channels}Tab: fix accent-LIGHTER hover + Cancel no-op
2026-05-04 04:36:05 +00:00
Hongming Wang 0e3e2559af canvas/{Schedule,Channels}Tab: fix accent-LIGHTER hover + Cancel no-op
Three button fixes — same AA-contrast-trap pattern shipped on
OnboardingWizard, MemoryTab, ConfirmDialog, ApprovalBanner.

ScheduleTab:
- Create/Update button: bg-accent-strong hover:bg-accent (accent is
  LIGHTER, drops below AA on white text) → bg-accent hover:bg-accent-
  strong + focus-visible ring.
- Cancel button: bg-surface-card hover:bg-surface-card no-op → hover
  surface-elevated + focus-visible ring.

ChannelsTab:
- Connect Channel button: same accent-LIGHTER trap → flipped + focus-
  visible ring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:33:23 -07:00
molecule-ai[bot] 4f6678ae52 Merge pull request #2688 from Molecule-AI/staging
staging → main: auto-promote 5de0eee
2026-05-03 21:30:22 -07:00
Hongming Wang 5de0eee328 Merge pull request #2687 from Molecule-AI/a11y/canvas-memory-tab-hovers
canvas/MemoryTab: fix 9 hover bugs (4 light + 4 no-op + Delete no-op)
2026-05-04 04:08:39 +00:00
Hongming Wang 40e35e0b6d Merge pull request #2686 from Molecule-AI/auto-sync/main-67e2c9c6
chore: sync main → staging (auto, ff to 67e2c9c6)
2026-05-04 04:07:34 +00:00
Hongming Wang 7a30af5af0 canvas/MemoryTab: fix 9 hover bugs (4 light + 4 no-op + Delete no-op)
Three matched fixes — same patterns shipped on OnboardingWizard,
ConfirmDialog, ApprovalBanner.

1. 4× bg-accent-strong hover:bg-accent (Save, Add, two Show buttons)
   hovered LIGHTER on white text — accent is the lighter variant, so
   contrast dropped below AA on hover. Flipped: bg-accent
   hover:bg-accent-strong.

2. 4× bg-surface-card hover:bg-surface-card no-op hovers (Collapse,
   Open, Hide-Advanced, Refresh, Cancel). Lift to surface-elevated
   so the buttons visibly respond.

3. Delete row button: text-bad hover:text-bad was a no-op. Switched
   to a light hover bg + focus-visible danger ring so the destructive
   action visibly responds and keyboard users see focus.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:06:17 -07:00
molecule-ai[bot] 67e2c9c6b3 Merge pull request #2685 from Molecule-AI/staging
staging → main: auto-promote a6ef5f9
2026-05-03 21:03:41 -07:00
Hongming Wang 43e0f69dc8 Merge pull request #2683 from Molecule-AI/auto-sync/main-5a50ba86
chore: sync main → staging (auto, ff to 5a50ba86)
2026-05-04 03:48:36 +00:00
Hongming Wang a6ef5f9583 Merge pull request #2684 from Molecule-AI/a11y/canvas-files-tab-confirms
canvas/FilesTab: fix Delete/Cancel hovers + alertdialog role + focus rings
2026-05-04 03:41:51 +00:00
Hongming Wang 38b1af3b84 canvas/FilesTab: fix Delete-LIGHTER + Cancel no-op + alertdialog role + focus rings
Three matched fixes for the inline Delete-All and Delete-File confirm
banners — same patterns shipped on ConfirmDialog/ApprovalBanner/
DeleteCascade:

1. Delete buttons hovered LIGHTER (bg-red-500 over bg-red-600). On
   white text drops below AA contrast. Flipped to bg-red-700.

2. Cancel buttons hover was a no-op (bg-surface-card on top of
   itself). Lift to surface-elevated, matching the Cancel pattern in
   ConfirmDialog.

3. None of the four buttons had focus-visible rings. Added danger
   ring on Delete, accent ring on Cancel, with ring-offset-surface
   so the offset color matches the inline banner backdrop.

4. Wrapped both confirm banners in role="alertdialog" + aria-
   labelledby pointing to the prompt text — SR users hear the
   destructive prompt immediately instead of as ambient text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:39:29 -07:00
molecule-ai[bot] 5a50ba86e8 Merge pull request #2682 from Molecule-AI/staging
staging → main: auto-promote 211e375
2026-05-04 03:36:04 +00:00
Hongming Wang 9fea10524e Merge pull request #2680 from Molecule-AI/auto-sync/main-dd5832a8
chore: sync main → staging (auto, ff to dd5832a8)
2026-05-04 03:18:35 +00:00
Hongming Wang 211e375ef1 Merge pull request #2681 from Molecule-AI/a11y/canvas-traces-tab
canvas/TracesTab: semantic status dots + aria-expanded on row expanders
2026-05-04 03:14:50 +00:00
Hongming Wang 38e0fc8ea0 canvas/TracesTab: semantic status dots + aria-expanded on row expanders
Three small UIUX fixes for the workspace Traces tab — same pattern
shipped on EventsTab.

1. Status dots were hardcoded bg-red-400 / bg-emerald-400 — semantic-
   token misses. Switched to bg-bad / bg-good so they pin to the
   canvas-wide ramp instead of Tailwind raw tones.

2. Trace expander rows had no aria-expanded — SR users heard a
   generic "button" with no toggle indication. Added aria-expanded
   + aria-controls pointing to the detail panel id.

3. Refresh + each expander button now carry focus-visible:ring-accent
   so keyboard users see where focus lands. Both were hover-only
   before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:12:21 -07:00
molecule-ai[bot] dd5832a8fc Merge pull request #2679 from Molecule-AI/staging
staging → main: auto-promote c5d8ce9
2026-05-04 03:05:22 +00:00
Hongming Wang 8622829848 Merge pull request #2677 from Molecule-AI/auto-sync/main-81c8a8b3
chore: sync main → staging (auto, ff to 81c8a8b3)
2026-05-04 02:51:51 +00:00
Hongming Wang c5d8ce9ffe Merge pull request #2678 from Molecule-AI/a11y/canvas-terminal-tab-chrome
canvas/TerminalTab: semantic status colors + accent Reconnect
2026-05-04 02:47:48 +00:00
Hongming Wang 90b561add0 canvas/TerminalTab: semantic status colors + accent Reconnect button
Three small UIUX fixes for the workspace terminal status bar.

1. Status dots were hardcoded bg-green-500 / bg-yellow-500 /
   bg-red-500 / bg-zinc-500 — semantic-token misses. Switched to
   bg-good / bg-warm / bg-bad / bg-ink-soft so the colors flip with
   the canvas-wide ramp instead of pinning Tailwind raw values.

2. Reconnect button used hardcoded text-blue-400 / hover:text-blue-300
   with no focus ring. Switched to text-accent / hover:text-accent-strong
   for theme parity, and added focus-visible:ring-accent/60 so
   keyboard users see where focus lands on a recovery action.

3. Error banner used text-red-400 — switched to text-bad to match the
   semantic ramp.

Status-bar bg/border kept as zinc (terminal body stays dark
unconditionally per the Canvas v4 design rule); only the chrome's
foreground tokens needed semanticisation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 19:45:24 -07:00
molecule-ai[bot] 81c8a8b35d Merge pull request #2676 from Molecule-AI/staging
staging → main: auto-promote 408e308
2026-05-04 02:39:37 +00:00
Hongming Wang 7ce0138150 Merge pull request #2675 from Molecule-AI/auto-sync/main-05596803
chore: sync main → staging (auto, ff to 05596803)
2026-05-04 02:23:31 +00:00
Hongming Wang 408e308ce5 Merge pull request #2674 from Molecule-AI/a11y/canvas-events-tab
canvas/EventsTab: theme-flip event colors + a11y for expander rows
2026-05-04 02:20:17 +00:00
molecule-ai[bot] 05596803f7 Merge pull request #2673 from Molecule-AI/staging
staging → main: auto-promote 754e5b2
2026-05-03 19:18:52 -07:00
Hongming Wang 6cd650f48c canvas/EventsTab: theme-flip event colors + a11y for expander rows
Four UIUX fixes for the workspace Events tab.

1. Hardcoded text-yellow-400 (DEGRADED) and text-purple-400
   (AGENT_CARD_UPDATED) didn't theme-flip — read fine in dark mode,
   washed out in warm-paper light. Switched DEGRADED → text-warm
   (the semantic warm/amber token) and AGENT_CARD_UPDATED → text-
   accent (informational metadata, accent is the right semantic).

2. Refresh button hover was a no-op (bg-surface-card on top of itself).
   Lift to surface-elevated, matching the Cancel pattern from
   ConfirmDialog. Added focus-visible ring.

3. Event expander rows had no aria-expanded — screen readers heard a
   generic "button" with no indication it toggled. Added
   aria-expanded + aria-controls pointing to the payload panel id.

4. Added focus-visible ring on each expander button. Hover bg added
   too so the active row visibly responds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 19:17:42 -07:00
Hongming Wang 754e5b2da1 Merge pull request #2672 from Molecule-AI/auto-sync/main-68c9bd8f
chore: sync main → staging (auto, ff to 68c9bd8f)
2026-05-04 02:03:31 +00:00
Hongming Wang f23665d4d9 Merge pull request #2671 from Molecule-AI/a11y/canvas-onboarding-wizard
canvas/OnboardingWizard: theme-flip colors + fix hover traps + focus rings
2026-05-04 01:52:14 +00:00
molecule-ai[bot] 68c9bd8fe4 Merge pull request #2670 from Molecule-AI/staging
staging → main: auto-promote c4d476d
2026-05-04 01:50:10 +00:00
Hongming Wang 4d747de218 canvas/OnboardingWizard: theme-flip colors + fix hover traps + focus rings
Five fixes for the first-time-user wizard. Every new user sees this,
so visual bugs here have outsized impact.

1. Action button hovered LIGHTER: bg-accent-strong/90 hover:bg-accent.
   accent is the LIGHTER variant — hovering to it on white text drops
   contrast below AA. Flipped the direction: bg-accent
   hover:bg-accent-strong, matching the same trap fixed in
   ConfirmDialog and ApprovalBanner.

2. "Next" button hover was a no-op (bg-surface-card on top of itself).
   Lift to surface-elevated, matching the Cancel pattern in
   ConfirmDialog.

3. Progress bar gradient was hardcoded from-blue-500 to-sky-400 —
   neither tone exists in the warm-paper light theme, so the bar lost
   brand color in light mode. Switched to the accent ramp so it stays
   brand-tinted in both.

4. Step indicator was hardcoded text-sky-400/80, same theme-flip
   issue. Switched to text-accent.

5. All three buttons (Skip / Action / Next) had no focus-visible
   rings. Added the accent ring pattern used across the rest of
   the canvas.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:49:19 -07:00
Hongming Wang 4a8a72f4ae Merge pull request #2667 from Molecule-AI/auto-sync/main-ad24703d
chore: sync main → staging (auto, ff to ad24703d)
2026-05-04 01:36:50 +00:00
Hongming Wang c4d476d0dc Merge pull request #2668 from Molecule-AI/fix/synth-e2e-verify-actually-skips-job
fix(synth-e2e): verify-secrets must hard-fail (exit 0 only ends step)
2026-05-04 01:34:52 +00:00
Hongming Wang 9689c6f6d5 fix(synth-e2e): verify-secrets step must hard-fail (exit 0 only ends step)
The previous soft-skip-on-dispatch path used `exit 0`, which only
ends the STEP — the rest of the workflow continued with empty
secrets. Caught 2026-05-04 by dispatched run 25296530706:
  - E2E_MINIMAX_API_KEY: empty
  - verify-secrets printed warning + exit 0
  - Install required tools: ran
  - Run synthetic E2E: ran with empty MiniMax key
  - SECRETS_JSON branched to OpenAI shape (MINIMAX empty → fall through)
  - But model slug stayed MiniMax-M2.7-highspeed (workflow env)
  - Workspace booted with OpenAI keys + MiniMax model
  - 5 min later: "Agent error (Exception)" — claude SDK 401'd
    against api.minimax.io with the OpenAI key

The confusing failure mode silently masked the real problem (missing
secret) under a runtime-error label. Fix: drop both soft-skip paths
and exit 1 always. Operators who want to verify a YAML change without
setting up secrets can read the verify-secrets step's stderr — the
failure IS the verification signal.

Pure visibility fix; preserves the cron hard-fail path (now also the
dispatch hard-fail path). No mechanism change beyond the exit code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:32:26 -07:00
Hongming Wang 3e4ff1ce9c Merge pull request #2666 from Molecule-AI/a11y/canvas-terms-gate
canvas/TermsGate: stop hiding the dialog from screen readers + a11y polish
2026-05-04 01:24:26 +00:00
Hongming Wang ad24703d74 Merge pull request #2665 from Molecule-AI/staging
staging → main: auto-promote d684e28
2026-05-03 18:23:38 -07:00
Hongming Wang 3e6c7075d0 canvas/TermsGate: stop hiding the dialog from screen readers + a11y polish
Five fixes for the terms-acceptance modal:

1. CRITICAL: aria-hidden="true" on the modal's wrapper hid the dialog
   AND its descendants from screen readers. The entire ToS-acceptance
   flow was invisible to AT users. Removed the false aria-hidden — the
   wrapper is just a backdrop, the dialog inside still has role=dialog
   aria-modal=true so AT recognises it correctly.

2. Added focus management: when the modal opens, focus moves to the
   "I agree" button (WCAG 2.4.3). Hard gate so no focus-trap loop or
   Esc-dismiss — the user must accept or close the page.

3. "I agree" button hovered LIGHTER (bg-emerald-500 over bg-emerald-600).
   On white text that drops below AA — same trap fixed in ApprovalBanner
   and ConfirmDialog. Flipped to bg-emerald-700.

4. Added focus-visible ring on the "I agree" button. Was relying on
   browser default outline only.

5. Privacy/Terms links: hardcoded text-sky-400 → text-accent (theme-
   aware) + hover:text-accent-strong (was hover:text-sky-400, no-op
   same color) + focus-visible ring. Added aria-describedby pointing
   to the body div so SR can read the description with the title.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:21:42 -07:00
Hongming Wang 390425afbc Merge pull request #2664 from Molecule-AI/feat/canvas-agent-comms-waiting-bubble
canvas/AgentCommsPanel: per-peer waiting-for-reply bubble
2026-05-04 01:05:08 +00:00
Hongming Wang 663c5b7e70 canvas/AgentCommsPanel: add per-peer waiting-for-reply bubble
Mirrors the bouncing-dots indicator ChatTab already shows while waiting
for an agent reply. Before this, an operator delegating to one or more
external peers via Agent Comms saw their outbound bubble land and then
silence until the reply (or queued/failed status) arrived — no visual
"the system is working on this" cue.

Per-peer not global: when multiple delegations are in flight to
different peers (the fan-out case), one shared spinner under-reports —
the user can't tell whether ALL peers are still working or just the
visible ones. Per-peer matches Slack typing-indicator semantics and
keeps the signal honest.

Detection rule: walk visible messages, keep only the chronologically-
last bubble per peer. If that tail is `flow === "out"` AND status is
"pending" or "queued", emit a waiting bubble. Once an inbound reply
lands, the tail flips to "in" and the bubble disappears — even if the
backend hasn't mutated the original outbound row to "completed" yet.
This collapses both states into one rule.

Visual: matches the outgoing bubble (cyan-900/30 + cyan-700/20 border,
right-justified) with cyan-300/70 dots that respect prefers-reduced-
motion via `motion-safe:animate-bounce`. Queued case adds copy
explaining the peer is busy. role="status" + aria-label so SR users
also hear "Waiting for reply from <peer>".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:02:30 -07:00
Hongming Wang b70d857409 Merge pull request #2663 from Molecule-AI/a11y/canvas-delete-cascade-buttons
canvas/DeleteCascadeConfirmDialog: fix Cancel/Delete hovers + focus rings
2026-05-04 01:00:24 +00:00
Hongming Wang 2f89a05f2f Merge branch 'staging' into a11y/canvas-delete-cascade-buttons 2026-05-03 17:55:48 -07:00
Hongming Wang d684e28228 Merge pull request #2662 from Molecule-AI/auto-sync/main-e5a8ace6
chore: sync main → staging (auto, ff to e5a8ace6)
2026-05-04 00:54:33 +00:00
Hongming Wang 71fb499dee canvas/DeleteCascadeConfirmDialog: fix Cancel no-op hover + Delete light hover + focus rings
Four fixes for the cascade-delete confirmation modal:

1. Cancel button hover was a no-op: bg-surface-card on top of the
   same base — clicking did something but the button looked dead.
   Lifted to surface-elevated, matching the ConfirmDialog Cancel
   pattern.

2. Delete button hovered LIGHTER (bg-red-500 over bg-red-600). On
   white text that drops contrast below AA — same trap fixed in
   ConfirmDialog and ApprovalBanner. Flipped to bg-red-700 so hover
   stays readable in both themes.

3. Checkbox ring-offset color was zinc-900 — but the dialog actually
   sits on bg-surface-sunken, so the offset showed the wrong color
   through the ring gap. Corrected to ring-offset-surface-sunken.
   Also moved focus → focus-visible so the ring only shows on
   keyboard nav, not mouse clicks.

4. Cancel + Delete had no focus-visible rings. Added accent ring
   on Cancel, danger ring on Delete, both with the correct
   ring-offset-surface-sunken.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:53:29 -07:00
Hongming Wang e5c9656016 Merge pull request #2661 from Molecule-AI/feat/external-connect-modal-help-section
feat(external-connect): fix Claude Code channel snippet + add per-tab Help section to ExternalConnectModal
2026-05-04 00:50:10 +00:00
molecule-ai[bot] e5a8ace677 Merge pull request #2660 from Molecule-AI/staging
staging → main: auto-promote 166c677
2026-05-03 17:48:42 -07:00
Hongming Wang d5eb58af56 feat(external-connect): comprehensive setup — fix Claude Code channel snippet + add per-tab Help section
User report: handing the modal's Claude Code channel snippet to an
agent fails immediately with two errors that the snippet doesn't tell
the operator how to resolve:

  plugin:molecule@Molecule-AI/molecule-mcp-claude-channel · plugin not installed
  plugin:molecule@Molecule-AI/molecule-mcp-claude-channel · not on the approved channels allowlist

Root cause: the snippet's `claude --channels plugin:...` line assumes
the plugin is pre-installed AND that the channel is on Anthropic's
default allowlist. Both assumptions are wrong for a custom Molecule
plugin in a public repo.

Two changes:

1. Rewrite externalChannelTemplate (Go) with full setup chain:
   - Bun prereq check (channel plugins are Bun scripts)
   - `/plugin marketplace add Molecule-AI/molecule-mcp-claude-channel`
     + `/plugin install molecule@molecule-mcp-claude-channel` BEFORE the
     launch — otherwise "plugin not installed"
   - `--dangerously-load-development-channels` flag on launch — required
     for non-Anthropic-allowlisted channels, otherwise "not on approved
     channels allowlist"
   - Common-errors block at the bottom mapping each error string to
     which numbered step recovers it
   - Team/Enterprise managed-settings caveat (the dev-channels flag is
     blocked there; admin must use channelsEnabled + allowedChannelPlugins)

   Plugin install info verified by reading `Molecule-AI/molecule-mcp-claude-channel`
   plugin.json (`name: "molecule"`) and the Claude Code channels +
   plugin-discovery docs at code.claude.com/docs/en/{channels,discover-plugins}.

2. Add per-tab HelpBlock to the modal (canvas):
   - Collapsible <details> below each snippet, closed by default so the
     snippet stays the visual focus
   - "Where to install" link (PyPI for runtime, claude.com for Claude
     Code, github.com/openai/codex for Codex, NousResearch/hermes-agent
     for Hermes)
   - "Documentation" link (docs.molecule.ai/docs/guides/*; hostname
     confirmed by existing blog post canonical metadata; paths map
     1:1 to docs/guides/*.md files in this repo)
   - "Common errors" list with concrete recovery steps for each tab
     (e.g. Codex tab calls out the codex≥0.57 requirement and TOML
     duplicate-table parse error; OpenClaw calls out the :18789 port
     conflict check)

   URL discipline: every URL is either (a) verified against a file path
   in this repo's docs/, (b) the canonical repo of an existing snippet
   reference, or (c) a well-known third-party canonical URL. No guessed
   URLs — broken links would defeat the purpose of "more comprehensive
   instructions."

Verification:
- `go build ./...` clean in workspace-server
- `go test ./internal/handlers/...` passes (4.3s)
- Bash syntax check on test_staging_full_saas.sh (no edits there) clean
- TS brace/paren/bracket counts balanced; no full tsc run because the
  worktree's node_modules isn't installed — counterpart Canvas tabs E2E
  on the PR will exercise the full type-check + render path

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:46:55 -07:00
Hongming Wang 166c677a09 Merge pull request #2659 from Molecule-AI/fix/synth-e2e-cron-dodge-top-of-hour
ci(synth-e2e): move cron off :00 to dodge GH scheduler drops (closes #273)
2026-05-04 00:31:05 +00:00
Hongming Wang a7f1b378de Merge pull request #2658 from Molecule-AI/a11y/canvas-bundle-drop-zone
canvas/BundleDropZone: theme-flip overlay + SR announce + reduced-motion
2026-05-04 00:28:54 +00:00
Hongming Wang a306a97dd3 ci(synth-e2e): move cron off :00 to dodge GH scheduler drops
GitHub Actions scheduler de-prioritises :00 cron firings under load.
Empirical 2026-05-03: the canary's cron was '0,20,40 * * * *' but
actual firings landed at :08, :03, :01, :03 — :20 and :40 silently
dropped. Detection latency degraded from claimed 20 min to actual
~60 min worst case.

Move to '10,30,50 * * * *':
- :10/:30/:50 sit 10 min off the top-of-hour load peak
- Still 5 min from :15 sweep-cf-orphans and :45 sweep-cf-tunnels
  (the original constraint that kept us off :15/:45)
- Same 20-min cadence; only the phase changes

No code change beyond the cron expression + comment refresh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:28:45 -07:00
Hongming Wang ec54942628 canvas/BundleDropZone: theme-flip drag overlay + announce import + reduced-motion
Three small UIUX fixes for the bundle drag-import surface.

1. Drag overlay was hardcoded blue-950/blue-400 — those tones don't
   exist in the warm-paper light theme, so the overlay washed out
   inconsistently. Switched to bg-accent/15 + border-accent/40 so
   the overlay flips with theme and matches the inner card's
   border-accent/50.

2. Importing spinner was visually obvious but invisible to screen
   readers — only the result toast had aria-live. Operators relying
   on AT had no way to know the import was in flight. Added
   role="status" + aria-live="polite" + aria-hidden on the spinner
   itself so the SR hears "Importing bundle..." once.

3. animate-spin → motion-safe:animate-spin so the spinner respects
   prefers-reduced-motion (Tailwind's built-in variant gates the
   animation on the user's OS setting). Layout doesn't change in
   either case — text alone communicates state.

Also dropped border-sky-400 → border-accent on the spinner so it
matches the rest of the canvas semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:26:15 -07:00
Hongming Wang 065e39dda2 Merge pull request #2657 from Molecule-AI/auto-sync/main-54d32d1e
chore: sync main → staging (auto, ff to 54d32d1e)
2026-05-04 00:23:24 +00:00
molecule-ai[bot] 54d32d1ee2 Merge pull request #2656 from Molecule-AI/staging
staging → main: auto-promote 4cd01a2
2026-05-04 00:19:09 +00:00
Hongming Wang 4cd01a2df1 Merge pull request #2654 from Molecule-AI/auto-sync/main-8760ee16
chore: sync main → staging (auto, ff to 8760ee16)
2026-05-04 00:03:47 +00:00
Hongming Wang ccb7ca5d8a Merge pull request #2655 from Molecule-AI/a11y/canvas-console-modal-buttons
canvas/ConsoleModal: fix no-op hovers + Copy feedback + focus rings
2026-05-04 00:01:13 +00:00
Hongming Wang 10f2b9f01c canvas/ConsoleModal: fix no-op hovers + add Copy success feedback
Four UIUX fixes for the EC2 console modal:

1. Copy and Close buttons had hover:bg-surface-card on TOP of the
   same base bg-surface-card — silent no-op hover. Lifted to
   surface-elevated + line-soft border, matching ConfirmDialog's
   Cancel pattern. The button visibly responds now.

2. Copy button silently succeeded — no toast, no animation, no UI
   feedback. Operators clicking it had no idea whether anything
   landed in the clipboard. Now fires showToast on resolve/reject
   so the action is observable.

3. × close button was ~10x16px (well under WCAG 2.5.5's 24x24).
   Bumped to w-6 h-6 with focus-visible ring + hover bg.

4. Added focus-visible:ring-accent/60 + ring-offset-surface to
   all three buttons so keyboard users see focus. Matches the
   semantic ring pattern used across the canvas.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:58:31 -07:00
molecule-ai[bot] 8760ee1628 Merge pull request #2653 from Molecule-AI/staging
staging → main: auto-promote e9fdd99
2026-05-03 16:51:55 -07:00
Hongming Wang 28f5108a7c Merge pull request #2652 from Molecule-AI/a11y/canvas-batch-action-esc
canvas/BatchActionBar: wire Esc to clear selection
2026-05-03 23:33:54 +00:00
Hongming Wang e9fdd992a9 Merge pull request #2651 from Molecule-AI/auto-sync/main-aedbbc4a
chore: sync main → staging (auto, ff to aedbbc4a)
2026-05-03 23:33:41 +00:00
Hongming Wang f6fa3669dc Merge pull request #2650 from Molecule-AI/fix/sweep-stale-e2e-port-verify-pattern
ci: port DELETE-verify pattern to remaining staging e2e workflows
2026-05-03 23:32:42 +00:00
Hongming Wang b1a1c8e4a9 canvas/BatchActionBar: wire Esc to clear selection (matches button title)
Two small fixes for the batch-action toolbar:

1. The deselect button's title says "Clear selection (Escape)" — but
   pressing Escape did NOTHING. The title has been lying since the bar
   shipped. Now wired: window keydown handler calls clearSelection
   when Esc fires. Skipped while the confirm dialog is open
   (`pending !== null`) so the dialog's own Esc-cancels takes
   precedence, and skipped during a busy in-flight action so the
   user can't strand a partial-failure mid-flight.

2. focus-visible:ring-zinc-500/70 → focus-visible:ring-accent/50
   on the deselect button. The hardcoded zinc broke the semantic-
   token pattern used by the other action buttons.

Tests: two new vitest cases — Esc clears with selection, Esc no-op
when empty (the bar isn't mounted at count===0 so the listener never
registers). Full suite: 1222/1222.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:31:23 -07:00
molecule-ai[bot] aedbbc4a10 Merge pull request #2649 from Molecule-AI/staging
staging → main: auto-promote 98da627
2026-05-03 23:27:44 +00:00
Hongming Wang 8b9e7e6d59 ci: port DELETE-verify pattern to remaining staging e2e workflows
Follow-up to #2648 — same `>/dev/null || true` swallow-on-error
pattern existed in:

  e2e-staging-canvas.yml   (single-slug)
  e2e-staging-saas.yml     (loop)
  e2e-staging-sanity.yml   (loop)
  e2e-staging-external.yml (loop, was `>/dev/null 2>&1` variant)

All four now capture the HTTP code, log a "[teardown] deleted $slug
(HTTP $code)" line on success, and emit a workflow warning naming
the slug + body excerpt on non-2xx. Loop bodies also tally + summarise
total leaks at the end.

Exit semantics unchanged: a single cleanup miss still doesn't fail-flag
the test (sweep-stale-e2e-orgs is the safety net within ~45 min). The
behavior change is purely surfacing — failures that were silent are
now visible on the workflow run page.

Pairs with #2648's tightened sweeper. Together: per-run cleanup
failures are visible AND the safety net catches them quickly.

Closes the per-workflow port noted as out-of-scope in #2648.
See molecule-controlplane#420.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:24:43 -07:00
Hongming Wang 3c127ae3b9 Merge pull request #2647 from Molecule-AI/a11y/canvas-legend-focus-touch
canvas/Legend: focus rings + 24x24 close-button touch target
2026-05-03 23:13:33 +00:00
Hongming Wang 98da627170 Merge pull request #2648 from Molecule-AI/fix/sweep-stale-e2e-tighter-threshold
ci: tighten e2e cleanup race window 120m → ~45m worst case
2026-05-03 23:12:22 +00:00
Hongming Wang 3cd8c53de0 ci: tighten e2e cleanup race window 120m -> ~45m worst case
Two changes that close one of the leak classes from the
molecule-controlplane#420 vCPU audit:

1. sweep-stale-e2e-orgs.yml: cron */15 (was hourly), MAX_AGE_MINUTES
   30 (was 120). E2E runs are 8-25 min wall clock; 30 min is safely
   above the longest run while shrinking the worst-case leak window
   from ~2h to ~45 min (15-min sweep cadence + 30-min threshold).

2. canary-staging.yml teardown: the per-slug DELETE used `>/dev/null
   || true`, which swallowed every failure. A 5xx or timeout from CP
   looked identical to "successfully deleted" and the canary tenant
   kept eating ~2 vCPU until the sweeper caught it. Now we capture
   the response code and surface non-2xx as a workflow warning that
   names the leaked slug.

The exit semantics stay unchanged — a single-canary cleanup miss
shouldn't fail-flag the canary itself when the actual smoke check
passed. The sweeper is the safety net for whatever slips past.

Caught during the molecule-controlplane#420 audit on 2026-05-03 —
3 e2e canary tenant orphans were running for 24-95 min, all under
the previous 120-min sweep threshold so they went unnoticed until
manual cleanup. Same `|| true` pattern exists in
e2e-staging-{canvas,external,saas,sanity}.yml; out of scope for
this PR (mechanical port; tracking separately) but the sweeper
tightening covers all of them by reducing the safety-net latency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:08:40 -07:00
Hongming Wang 69fcfe9b3a Merge pull request #2646 from Molecule-AI/auto-sync/main-1141a429
chore: sync main → staging (auto, ff to 1141a429)
2026-05-03 23:05:05 +00:00
Hongming Wang 24d64677ab canvas/Legend: focus rings + 24x24 close-button touch target
Two small a11y fixes for the floating legend.

1. Both buttons (open pill + close ×) had no focus-visible ring —
   keyboard users couldn't tell where focus landed. Added the
   accent-ring pattern used across the rest of the canvas.

2. Close button was a ~10x16px hit area — well below WCAG 2.5.5's
   24x24 minimum. Bumped to w-6 h-6 with negative margin so the
   visible × stays in the same spot but the hit area + focus ring
   are larger. Hover bg added to make the hit area visible on hover.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:04:04 -07:00
molecule-ai[bot] 1141a42910 Merge pull request #2645 from Molecule-AI/staging
staging → main: auto-promote f689c81
2026-05-03 22:54:00 +00:00
Hongming Wang 84448d452b Merge pull request #2644 from Molecule-AI/a11y/canvas-cookie-consent-not-modal
canvas/CookieConsent: stop claiming aria-modal without a focus trap
2026-05-03 22:39:44 +00:00
Hongming Wang f689c81a70 Merge pull request #2642 from Molecule-AI/auto-sync/main-bae27270
chore: sync main → staging (auto, ff to bae27270)
2026-05-03 22:38:47 +00:00
Hongming Wang 2268027581 Merge pull request #2643 from Molecule-AI/feat/synth-e2e-claude-code-minimax
feat(synth-e2e): switch canary to claude-code + MiniMax-M2.7-highspeed
2026-05-03 22:38:35 +00:00
Hongming Wang 652124284b canvas/CookieConsent: stop pretending to be a modal + fix link/button focus
Three fixes for the cookie banner:

1. role="dialog" aria-modal="true" → <section role="region">. The
   banner has no focus trap, doesn't block the page, and the user
   can keep using the canvas while it's up — none of which are modal
   semantics. Claiming aria-modal="true" without a trap actively
   harms screen-reader users: they're told the rest of the page is
   inert, jump into the banner, and then can't escape. Region
   semantics let AT navigate around it normally. (Forcing a modal
   cookie banner would also be a dark pattern under GDPR.)

2. Privacy-policy link: hover:text-accent → hover:text-accent-strong.
   The original was a no-op (same color). Also added focus-visible
   ring + underline-offset so the link is readable AND keyboard-
   distinguishable in both themes.

3. Both buttons: focus-visible:ring-2 + ring-offset-surface so
   keyboard users see where focus lands. Mouse clicks unchanged
   thanks to focus-visible.

Tests: swapped getByRole("dialog") → getByRole("region") in 8
existing tests, then tightened the role-assertion test into a
regression guard that explicitly asserts NO aria-modal and NO
dialog role exist. Full suite: 1220/1220.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:37:06 -07:00
Hongming Wang 79a0203798 feat(synth-e2e): switch canary to claude-code + MiniMax-M2.7-highspeed
Cuts the per-run LLM cost ~10x (MiniMax M2.7 vs gpt-4.1-mini) and
removes the recurring OpenAI-quota-exhaustion failure mode that took
the canary down on 2026-05-03 (#265 — staging quota burnt for ~16h).

Path:
  E2E_RUNTIME=claude-code (default)
  → workspace-configs-templates/claude-code-default/config.yaml's
    `minimax` provider (lines 64-69)
  → ANTHROPIC_BASE_URL auto-set to api.minimax.io/anthropic
  → reads MINIMAX_API_KEY (per-vendor env, no collision with
    GLM/Z.ai etc.)

Workflow changes (continuous-synth-e2e.yml):
- Default runtime: langgraph → claude-code
- New env: E2E_MODEL_SLUG (defaults to MiniMax-M2.7-highspeed,
  overridable via workflow_dispatch)
- New secret wire: E2E_MINIMAX_API_KEY ←
  secrets.MOLECULE_STAGING_MINIMAX_API_KEY
- Per-runtime missing-secret guard: claude-code requires MINIMAX,
  langgraph/hermes require OPENAI. Cron firing hard-fails on missing
  key for the active runtime; dispatch soft-skips so operators can
  ad-hoc test without setting up the secret first
- Operators can still pick langgraph/hermes via workflow_dispatch;
  the OpenAI fallback path stays wired

Script changes (tests/e2e/test_staging_full_saas.sh):
- SECRETS_JSON branches on which key is set:
    E2E_MINIMAX_API_KEY → {MINIMAX_API_KEY: <key>}  (claude-code path)
    E2E_OPENAI_API_KEY  → {OPENAI_API_KEY, HERMES_*, MODEL_PROVIDER}  (legacy)
  MiniMax wins when both are present — claude-code default canary
  must not accidentally consume the OpenAI key

Tests (new tests/e2e/test_secrets_dispatch.sh):
- 10 cases pinning the precedence + payload shape per branch
- Discipline check verified: 5 of 10 FAIL on a swapped if/elif
  (precedence inversion), all 10 PASS on the fix
- Anchors on the section-comment header so a structural refactor
  fails loudly rather than silently sourcing nothing

The model_slug dispatcher (lib/model_slug.sh) needs no change:
E2E_MODEL_SLUG override path is already wired (line 41), and
claude-code template's `minimax-` prefix matcher catches
"MiniMax-M2.7-highspeed" via lowercase-on-lookup.

Operator action required to land green:
- Set MOLECULE_STAGING_MINIMAX_API_KEY in repo secrets
  (Settings → Secrets and Variables → Actions). Use
  `gh secret set MOLECULE_STAGING_MINIMAX_API_KEY -R Molecule-AI/molecule-core`
  to avoid leaking the value into shell history.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:35:14 -07:00
molecule-ai[bot] bae2727074 Merge pull request #2641 from Molecule-AI/staging
staging → main: auto-promote 2e96860
2026-05-03 15:33:45 -07:00
Hongming Wang 2c4d92d9bc Merge pull request #2640 from Molecule-AI/fix/canary-classify-provider-quota
test(e2e): canary classifies provider-quota 429 as operator-action, not platform regression
2026-05-03 22:21:01 +00:00
Hongming Wang 4c49ff75f6 test(e2e): canary classifies provider-quota 429 as operator-action, not platform regression
The staging canary's A2A step has a ladder of specific regression
classifiers (hermes-agent down, model_not_found, Invalid API key,
etc.) followed by a generic "error|exception" catch-all. Provider-
side OpenAI 429 quota errors fell through to the catch-all, so the
canary issue body and CI log just said "A2A returned an error-shaped
response" — which is technically true but obscures the actual
operator action.

This adds a 7th classifier above the catch-all for "exceeded your
current quota" / "insufficient_quota" — both terms appear in
OpenAI's quota-exhaustion 429 response. When matched, the failure
message names the operator action directly (top up MOLECULE_STAGING_OPENAI_KEY
or rotate the secret) and links to #2578.

Why this is correct, not "lowering the bar":
- Steps 0–7 of the canary cover full platform health (CP up, tenant
  provisioned, DNS+TLS reachable, workspace booted, A2A delivered).
- Reaching step 8 with a provider-side 429 means the platform IS
  healthy — the failure is downstream of all platform invariants.
- The canary still exits 1 (CI stays red, threshold-3 alarm still
  fires); only the failure message changes.
- All 6 existing specific classifiers run BEFORE this one, so any
  real platform regression is still caught with its specific message.

Verification:
- Regex tested against the actual 429 string from canary run 25291517608:
    "API call failed after 3 retries: HTTP 429: You exceeded your current quota..."
  → matches 
- Negative tests: "PONG", "hermes-agent unreachable" → no match 
- bash -n syntax check passes
- shellcheck -S error clean

Tracking: #2593 (canary), #2578 (root cause)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:18:42 -07:00
Hongming Wang 2e9686036d Merge pull request #2639 from Molecule-AI/a11y/canvas-search-dialog-first-match
canvas/SearchDialog: auto-highlight first match + semantic placeholder
2026-05-03 22:11:31 +00:00
Hongming Wang 2bc304bfd3 Merge pull request #2638 from Molecule-AI/auto-sync/main-d2c0041b
chore: sync main → staging (auto, ff to d2c0041b)
2026-05-03 22:10:16 +00:00
Hongming Wang 7ca764f917 canvas/SearchDialog: auto-highlight first match + semantic placeholder
Two small UIUX fixes for Cmd+K search.

1. Auto-highlight the first match while the user types. Before, Enter
   on a non-empty query was a no-op — focusedIndex stayed at -1 until
   the user pressed ↓. Standard search-palette behavior is to highlight
   the top result so Enter just works. Empty query keeps -1 (opening
   the dialog shows ALL workspaces; arbitrarily pinning one looks
   wrong).

2. placeholder-zinc-400 → placeholder-ink-soft. The hardcoded zinc
   broke the semantic-token pattern other inputs use; placeholder now
   flips with theme correctly. (Also reordered focus:outline-none
   ahead of the focus-visible variants — cosmetic, more idiomatic.)

Tests: replaced the "resets to -1" test with two new ones — auto-
highlight on a matching query (Enter selects without ArrowDown), and
no-results query stays a no-op. Full suite 1220/1220.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:09:01 -07:00
molecule-ai[bot] d2c0041b2b Merge pull request #2637 from Molecule-AI/staging
staging → main: auto-promote 149d0bf
2026-05-03 15:03:39 -07:00
Hongming Wang 149d0bf3d7 Merge pull request #2635 from Molecule-AI/auto-sync/main-e4db4cfb
chore: sync main → staging (auto, ff to e4db4cfb)
2026-05-03 14:48:58 -07:00
Hongming Wang c6eec15292 Merge pull request #2636 from Molecule-AI/a11y/canvas-context-menu-clamp
canvas/ContextMenu: clamp to viewport + semantic focus ring
2026-05-03 21:43:00 +00:00
Hongming Wang 68f8fa2621 canvas/ContextMenu: clamp position to viewport + semantic focus ring
Two small fixes for the workspace right-click menu:

1. Off-screen clamp. Right-clicking near the right or bottom edge of
   the canvas put part of the menu past the viewport — items hidden
   under the scrollbar / off the screen. The menu now measures itself
   on the same rAF that auto-focuses the first item, and shifts back
   inside with an 8px margin (matching the floating-tooltip top-edge
   clamp in Tooltip.tsx). Falls back to the raw cursor coords for the
   first paint frame so there's no flash.

2. focus:ring-zinc-600 → focus-visible:ring-accent/50. The hardcoded
   zinc tone broke the semantic-token pattern every other surface
   uses; flipping to focus-visible also stops the ring from showing
   when items are clicked with the mouse (only keyboard nav now
   triggers the ring, matching Toolbar/SidePanel behavior).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 14:40:18 -07:00
Hongming Wang e4db4cfb11 Merge pull request #2625 from Molecule-AI/staging
staging → main: auto-promote 9fd52e9
2026-05-03 14:38:42 -07:00
Hongming Wang 65b42c33b9 Merge pull request #2634 from Molecule-AI/chore/canvas-e2e-error-detail
canvas/e2e: surface admin-orgs row + workspace body on failure
2026-05-03 21:05:25 +00:00
Hongming Wang 9d45211fd3 canvas/e2e: surface admin-orgs row + workspace body on failure
Two diagnostic upgrades to the Playwright staging-setup harness, both
zero-behavior-change:

1. provision-failed throw now includes the full admin-orgs row (boot
   stage, last error, terraform/SSM state, etc) instead of just the
   slug. Every "provision failed: <slug>" in CI history was followed
   by a manual repro to find out WHY — that round-trip is gone.

2. workspace-failed throw dumps the full /workspaces/{id} body when
   last_sample_error is empty. Boot crashes, image-pull errors,
   missing PYTHONPATH, and OpenAI-quota-at-startup all surface as a
   bare "Workspace failed:" today (see #2632). Now they carry the
   boot_stage / image / last_error fields the API row exposes.

No fix for the underlying flakes — those are tracked in #2632 (CP race)
and #2578 (OpenAI quota). This just stops them looking identical in the
CI log.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 14:01:50 -07:00
Hongming Wang 14494fe67c Merge pull request #2633 from Molecule-AI/a11y/canvas-toaster-esc-focus
canvas/Toaster: Esc dismiss + focus ring + bigger touch target
2026-05-03 20:58:02 +00:00
Hongming Wang 3b244ca6c6 canvas/Toaster: add Esc dismiss + focus-visible ring + larger touch target
Three small a11y fixes for the global toast surface:

1. Esc dismisses the newest toast. Errors never auto-expire, so without
   a keyboard shortcut a keyboard-only user has to tab through the entire
   app to reach the × button on a stuck error.

2. Dismiss button gets focus-visible ring + theme-aware tint. The previous
   `opacity-70 hover:opacity-100` gave no visible focus indicator (WCAG
   2.4.7). Info toasts use the semantic surface that flips with theme,
   so the dismiss tint splits per type — accent ring on info, white ring
   on the always-dark success/error toasts.

3. Touch target bumps from p-1 (~24x24) to w-7 h-7 (28x28) toward WCAG
   2.5.5 AAA's 44x44 ideal.

Tests: 5 new vitest cases covering Esc on info/error, no-op on empty
queue, accessible label, and per-toast click dismissal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:55:24 -07:00
Hongming Wang 18e88e7039 Merge pull request #2630 from Molecule-AI/a11y/canvas-tooltip-esc-dismiss
a11y(canvas): Tooltip Esc-to-dismiss (WCAG 1.4.13)
2026-05-03 20:36:44 +00:00
Hongming Wang f7d663d19a Merge pull request #2629 from Molecule-AI/feat/external-connect-hermes-tab
feat(canvas): add Hermes/Codex/OpenClaw tabs to ExternalConnectModal + default to Universal MCP
2026-05-03 20:29:41 +00:00
Hongming Wang c8e422f6c6 Merge pull request #2627 from Molecule-AI/fix/canvas-chat-agent-prose-brightness
fix(canvas): brighten agent chat prose body in dark mode
2026-05-03 20:29:33 +00:00
Hongming Wang 1d303ee75e a11y(canvas): Tooltip Esc-to-dismiss (WCAG 1.4.13)
WCAG 1.4.13 (Content on Hover or Focus) requires that tooltip content
be DISMISSIBLE without moving pointer hover or keyboard focus. Tooltip
had no escape hatch — once a keyboard user tabbed onto a control with
a tooltip, the tooltip stayed visible until they tabbed away (which
moves focus and may not be possible if the tooltip is itself blocking
content the user needs to see, e.g. for screen-magnifier users).

Add a window-level Escape listener that's active only while a tooltip
is shown. Pressing Esc clears the tooltip without moving focus or
breaking the hover state, satisfying the dismissible criterion.

Used `capture: true` so we beat any modal/dialog Esc handler that
might also be listening — the tooltip belongs to the focused control,
not the modal it sits inside.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:23:08 -07:00
Hongming Wang 1ec7e4af6d Merge branch 'staging' into feat/external-connect-hermes-tab 2026-05-03 13:16:32 -07:00
Hongming Wang ae4739f35b Merge branch 'staging' into fix/canvas-chat-agent-prose-brightness 2026-05-03 13:16:25 -07:00
Hongming Wang 6f203c5646 Merge pull request #2628 from Molecule-AI/test/synth-e2e-eic-terminal-probe
test(e2e): add canvas-terminal diagnose probe to synth-E2E (#269 partial)
2026-05-03 20:14:50 +00:00
Hongming Wang ff0d4dae77 fix(external-connect): address self-review criticals — config corruption + durability
Self-review of the modal-tab additions caught footguns in the new
hermes/codex/openclaw snippets. Ship the fixes before merge.

Critical 1 — Hermes `cat >> ~/.hermes/config.yaml` corrupts existing
configs. Most existing hermes installs have a top-level gateway:
block; appending creates a duplicate, which YAML rejects. Replaced
the auto-append with explicit instructions: 'under your existing
gateway: block, add a plugin_platforms entry'.

Critical 2 — Codex `cat >> ~/.codex/config.toml` corrupts on
re-run. TOML rejects duplicate [mcp_servers.molecule] tables; a
second run breaks codex parse. Replaced auto-append with commented
config block + explicit 'open ~/.codex/config.toml in your editor
and paste'. Canvas-side token stamping still hits the literal in
the comment so the operator's clipboard has the real token already
substituted.

Required 3 — OpenClaw `onboard --non-interactive` missing
provider/model defaults. Added explicit --provider + --model
placeholders in a commented form so operators see what's needed
without a stub default applying silently.

Required 4 — OpenClaw gateway started with bare '&' dies on
terminal close. Switched to nohup + log file + disown, with a note
that systemd is the right answer for production.

Optional 5 + 6 (env_vars cleanup, tests) deferred — env_vars stripped
to keep the in-tree-vs-external surface narrow; tests for the new
response fields can land separately when external_connection.go is
next touched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:12:54 -07:00
Hongming Wang 01bbf4c87b Merge pull request #2626 from Molecule-AI/ui/canvas-approvalbanner-polish
fix(canvas): ApprovalBanner Approve/Deny button polish
2026-05-03 20:12:13 +00:00
Hongming Wang e89dd892ac Merge pull request #2624 from Molecule-AI/fix/canvas-agentcomms-light-mode-prose
fix(canvas): AgentCommsPanel light-mode markdown contrast
2026-05-03 20:07:43 +00:00
Hongming Wang eba0c5e3f1 feat(canvas): add Hermes/Codex/OpenClaw tabs to ExternalConnectModal + default to Universal MCP
The External Connect modal had tabs for Python SDK / curl / Claude Code
channel / Universal MCP. Operators using hermes / codex / openclaw as
their external runtime had no copy-paste; they pieced together
WORKSPACE_ID + PLATFORM_URL + auth_token into config files by reading
docs.

Adds three runtime-specific snippets stamped server-side:

- **Hermes** — installs molecule-ai-workspace-runtime + the
  hermes-channel-molecule plugin, exports the 4 env vars, and writes
  the gateway.plugin_platforms.molecule block into ~/.hermes/config.yaml.
  Same long-poll-based push semantics the Claude Code channel tab
  delivers (push parity with the in-tree template-hermes adapter).

- **Codex** — wires the molecule_runtime A2A MCP server into
  ~/.codex/config.toml ([mcp_servers.molecule] block with env_vars
  passthrough + literal env values). Outbound tools only — codex's
  MCP client doesn't route arbitrary notifications/* (verified by
  reading codex-rs/codex-mcp/src/connection_manager.rs); push parity
  on external codex would need a separate bridge daemon, tracked
  as future work. Snippet calls this out so operators know to pair
  with Python SDK if they need inbound delivery.

- **OpenClaw** — installs openclaw + onboards, wires the molecule
  MCP server via openclaw mcp set, starts the gateway on loopback.
  Same outbound-tools-only caveat as codex; the in-tree template-
  openclaw adapter implements the full sessions.steer push path,
  but an external setup would need the same bridge daemon to translate
  platform inbox events into sessions.steer calls. Future work.

Default open tab changed from "Claude Code" to "Universal MCP".
Universal MCP is runtime-agnostic and works as a starting point for
any operator regardless of their downstream agent runtime; runtime-
specific tabs are still one click away. Pre-2026-05-03 the modal
defaulted to Claude Code, so operators using non-Claude runtimes
opened to a tab they had to skip past.

Tab order also reorganized:
  Universal MCP → Python SDK → Claude Code → Hermes → Codex → OpenClaw → curl → Fields

Each runtime-specific tab is gated on the platform supplying the
snippet (older platform builds without the field don't show empty
tabs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:07:19 -07:00
Hongming Wang c3ba5df9ff test(e2e): add canvas-terminal diagnose probe to synth-E2E (catches EIC-chain regressions in <20 min)
Why: the 2026-05-03 SG-missing-port-22 bug was structurally invisible to
local-dev — handleLocalConnect uses docker exec; only handleRemoteConnect
exercises EIC. The CP provisioner shipped without the EIC ingress rule
for ~6 months and nobody noticed until a paying tenant clicked Terminal.
Continuous synth-E2E runs every 20 min; adding this probe means the same
class of regression (CP provisioner ingress, EIC_ENDPOINT_SG_ID env,
handleRemoteConnect chain, SDK source-group support) surfaces within ~20
min of merge instead of waiting for a user report.

What: after Step 7 (workspace online), call
GET /workspaces/$wid/terminal/diagnose for each workspace. The endpoint
already exists in workspace-server (terminal_diagnose.go); it runs the
full EIC + ssh chain from inside the tenant (which has AWS creds via
its IAM profile) and returns {ok, first_failure, steps[]}. We just need
to call it as the tenant — no AWS creds plumbed onto the GHA runner,
no port-forwarding from CI.

Local-docker workspaces (instance_id NULL) hit diagnoseLocal which
probes docker.Ping + container exec; same ok=true contract, so the
probe works on both production paths.

This is a partial mitigation for task #269 (eliminate handleLocalConnect
bypass — local must mimic prod terminal path). The architectural fix
(refactor terminal.go so local docker also exercises an EIC-shaped
sequence) remains pending; this PR is the "find out issues earlier"
half of the user's directive.
2026-05-03 13:06:25 -07:00
Hongming Wang c37596fc26 fix(canvas): brighten agent chat prose body in dark mode
User feedback: chat-bubble agent text still washed out after #2618 +
#2623. Looked at the actual rendered colors and the issue was Tailwind
Typography's `prose-invert` defaults — body text ships at zinc-300,
which lands at ~5.3:1 against bg-zinc-700. Passes AA but visibly
duller than the user bubble's crisp white-on-blue (~10:1).

Override the prose CSS variables on the agent bubble in dark mode:
- body  → zinc-100  (was zinc-300)
- headings / bold → white
- inline code → zinc-100

That brings agent body text to ~13:1 against bg-zinc-700, matching the
user bubble's brightness so both sides of the conversation read at
the same crispness.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:04:12 -07:00
Hongming Wang d2c202ddab fix(canvas): ApprovalBanner Approve/Deny button polish
Same bug class as #2622 (ConfirmDialog), but on a more critical surface
— this is the top-of-page banner asking the user to approve / deny a
real workspace permission request.

1. **Deny was a no-op hover.** `bg-surface-card hover:bg-surface-card`
   gave zero visual feedback before the user clicked a destructive
   action. Now lifts to surface-elevated + brightens the text so the
   button visibly responds.
2. **Approve hover went LIGHTER.** `bg-emerald-600 hover:bg-emerald-500`
   dropped white-text contrast on hover. Reversed to emerald-700.
3. **No focus rings on either button.** Keyboard users had no way to
   tell which decision was focused. Added focus-visible rings
   (offset against the dark amber banner bg) — emerald for Approve,
   amber for Deny so the choice is unambiguous.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 12:56:00 -07:00
Hongming Wang 79590eb861 Merge pull request #2621 from Molecule-AI/auto-sync/main-54f3c4d3
chore: sync main → staging (auto, ff to 54f3c4d3)
2026-05-03 19:52:09 +00:00
Hongming Wang 2d1a86cac9 fix(canvas): AgentCommsPanel light-mode markdown contrast
Discovered during code review of the #2623 hotfix audit. Same
regression class as #2618: prose-invert applied where the bubble bg
themes between light/dark, leaving markdown unreadable in one theme.

`MarkdownBody` was unconditionally `prose-invert` — fine for the
outgoing-message bubble (bg-cyan-900, dark in both themes) and the
failure bubble (bg-red-950, dark in both themes), but WRONG for the
incoming-message bubble (bg-surface-card, which themes LIGHT in light
mode). Result: light prose body text on light cream bg = invisible
markdown for incoming peer-to-peer messages in light mode.

Added an `invert: "always" | "dark-only"` prop to MarkdownBody. The
NormalMessage call sites switch on `msg.flow` so each bubble gets the
direction matching its bg's theming behavior. Failure bubble keeps
the default ("always") since red-950 stays dark.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 12:46:21 -07:00
Hongming Wang 954d2172f0 Merge pull request #2623 from Molecule-AI/fix/canvas-chat-agent-prose-invert
fix(canvas): agent chat bubble dark-mode prose contrast (regression #2618)
2026-05-03 19:42:49 +00:00
Hongming Wang 9fd52e9cd4 Merge pull request #2622 from Molecule-AI/ui/canvas-confirmdialog-polish
fix(canvas): ConfirmDialog hover + focus polish
2026-05-03 19:40:35 +00:00
Hongming Wang ffcffa1375 fix(canvas): agent chat bubble dark-mode prose contrast
Regression from PR #2618 (chat dark-contrast).

PR #2618 switched the agent bubble bg to `dark:bg-zinc-700` so it
visibly elevates against the dark panel — but the inner ReactMarkdown
prose div only got `prose-invert` for USER messages. Result: in dark
mode the agent's markdown text rendered with the Tailwind Typography
plugin's default dark body color on top of the new dark bg = invisible
text. User reported empty-looking gray rectangles where agent replies
should be.

Fix: apply `dark:prose-invert` to agent bubbles so prose body text
flips light alongside the bg. Light mode unchanged (default prose
colors against the warm `bg-surface-card`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 12:36:44 -07:00
Hongming Wang b5dea3c5df fix(canvas): ConfirmDialog hover + focus polish
Three issues on a high-stakes surface (revoke token, delete workspace,
cascade delete):

1. **Cancel hover was a no-op.** `bg-surface-card hover:bg-surface-card`
   gave zero visual feedback on hover. Now hovers to surface-elevated
   with a softened border so the button visibly lifts.

2. **Confirm hovers went LIGHTER, dropping white-text contrast.**
   `bg-red-600 hover:bg-red-500` made the destructive button less
   readable on hover. Same for warning (amber) and primary (accent).
   Reversed to hover-darker so contrast holds in both themes.

3. **No focus-visible rings on either button.** Keyboard users had no
   indication of focus position (WCAG 2.4.7 fail). Added
   `focus-visible:ring-2 focus-visible:ring-accent/40` on Cancel and
   `focus-visible:ring-2 focus-visible:ring-offset-2 ...accent/60` on
   Confirm so the focused destructive action is unambiguous.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 12:28:24 -07:00
molecule-ai[bot] 54f3c4d34f Merge pull request #2620 from Molecule-AI/staging
staging → main: auto-promote 5cd5a28
2026-05-03 12:22:19 -07:00
Hongming Wang 8d5e78d629 Merge pull request #2619 from Molecule-AI/test/synth-e2e-model-slug-coverage
test(e2e): pin pick_model_slug behavior with bash unit tests
2026-05-03 19:07:17 +00:00
Hongming Wang ac6f65ab5e test(e2e): pin pick_model_slug behavior with bash unit tests
PR #2571 fixed synth-E2E by branching MODEL_SLUG per runtime, but only
the langgraph branch was verified at runtime — hermes / claude-code /
override / fallback had zero automated coverage. A future regression
(e.g. dropping the langgraph case) would silently revert and only
surface as "Could not resolve authentication method" mid-E2E.

This PR:
- Extracts the dispatch into tests/e2e/lib/model_slug.sh as a sourceable
  pick_model_slug() function. No behavior change.
- Adds tests/e2e/test_model_slug.sh — 9 assertions across all 5 dispatch
  branches plus the override path. Verified to FAIL when any branch is
  flipped (manually regressed langgraph slash-form to confirm the test
  catches it; restored before commit).
- Wires the unit test into ci.yml's existing shellcheck job (only runs
  when tests/e2e/ or scripts/ change). Pure-bash, no live infra.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 12:04:12 -07:00
hongming 5cd5a28bd1 Merge pull request #2618 from Molecule-AI/ui/canvas-chat-dark-contrast
fix(canvas): dark-mode chat bubble contrast
2026-05-03 19:03:29 +00:00
Hongming Wang 026c81acf0 fix(canvas): dark-mode chat bubble contrast
User screenshot showed pale lavender user bubbles with hard-to-read white
text and a nearly-invisible agent bubble blending into the dark panel.

Root causes:
1. Tailwind v4 defaults `dark:` to `prefers-color-scheme: dark`. Our
   ThemeProvider writes `data-theme="dark"` on <html> so user toggle wins
   over OS — but `dark:` classes elsewhere in the codebase weren't
   tracking it. Added `@custom-variant dark` to re-bind the variant.
2. `bg-accent` themes lighter in dark mode (--color-accent: #6883e8),
   dropping white-text contrast to ~3:1 (fails WCAG AA). Switched user
   bubble to solid blue-600/500 so it stays ~5:1 in both modes.
3. `bg-surface-card` (#1a1d23) was only ~7% lighter than the panel bg
   (#0e1014), making agent bubbles disappear. Bumped to zinc-700 in
   dark; light mode keeps the warm surface-card tint.
4. System (error) bubble's /10 overlay was nearly invisible; raised to
   /25 in dark with stronger border + ink for readability.

Sub-tab + textarea polish included: low-contrast `text-ink-soft` →
`text-ink-mid`, focus-visible rings on tabs, dark variants on textarea.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 12:00:51 -07:00
Hongming Wang a03045e5e4 Merge pull request #2617 from Molecule-AI/auto-sync/main-61223de3
chore: sync main → staging (auto, ff to 61223de3)
2026-05-03 18:53:12 +00:00
molecule-ai[bot] 61223de305 Merge pull request #2616 from Molecule-AI/staging
staging → main: auto-promote 4e90d3a
2026-05-03 11:48:27 -07:00
Hongming Wang 1355a1b539 Merge pull request #2615 from Molecule-AI/fix/2478-activity-logs-peer-indexes
feat(db): add per-peer btree indexes on activity_logs for chat_history scale (#2478)
2026-05-03 18:37:16 +00:00
Hongming Wang db132351a3 feat(db): add per-peer btree indexes on activity_logs for chat_history scale (#2478)
The chat_history query

  WHERE workspace_id = $1
    AND activity_type = 'a2a_receive'
    AND (source_id = $2 OR target_id = $2)
  ORDER BY created_at DESC

forces a workspace-scoped seq-scan-and-filter at every call —
idx_activity_ws_type_time covers workspace_id+type prefix but the
(source OR target) clause then walks every workspace row. Demo
workspaces (≤50 rows) don't notice; production workspaces accumulate
thousands over months and chat_history latency grows linearly.

Adds two partial btree indexes (workspace_id, source_id) WHERE NOT NULL
and (workspace_id, target_id) WHERE NOT NULL. Postgres BitmapOrs them
into a workspace-scoped BitmapAnd against the existing index, dropping
chat_history from O(workspace_rows) to O(peer_a2a_rows).

Partial WHERE NOT NULL because most activity rows (heartbeats,
agent_log, memory_write, etc.) carry NULL source_id/target_id and
shouldn't bloat the index.

Anti-pattern caveat (per the issue): a single compound (a, b) index
can't serve 'a OR b' — Postgres only uses compound for prefix match.
Two separate indexes + BitmapOr is the right shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 11:34:35 -07:00
Hongming Wang 4e90d3a32d Merge pull request #2614 from Molecule-AI/ui/canvas-toolbar-contrast
fix(canvas): Toolbar contrast + focus rings
2026-05-03 18:29:04 +00:00
Hongming Wang e1d635a099 fix(canvas): Toolbar contrast + focus rings
Top-of-canvas Toolbar had multiple low-contrast surfaces in light theme:

Action buttons (Stop All, Restart Pending):
- bg-red-950/50 + bg-amber-950/40 → bg-bad/10 + bg-warm/10 with bg-bad/40
  + bg-warm/40 borders. Dark-tinted backgrounds with /40-/50 alpha render
  as nearly invisible smudges on warm-paper; semantic tokens at /10 give
  a clear pale-bad / pale-warm tint that scales correctly in dark mode.
- Both gain focus-visible:ring-2 focus-visible:ring-{bad,warm}/40.

Toggle button (A2A edges):
- Active state: bg-blue-950/50 → bg-accent/15 (themes correctly).
- Inactive state: bg-surface-card/50 + text-ink-soft → solid bg-surface-card
  + text-ink-mid; hover bumps to text-ink. Drops the redundant
  "hover:bg-surface-card/50" identity hover.

Icon buttons (Audit, Search, Help):
- Same pattern as toggle inactive: solid bg-surface-card + text-ink-mid +
  text-ink hover, with focus-visible:ring-2 focus-visible:ring-accent/40.

Workspace count + bullet separator:
- text-ink-soft (3.5:1 on warm-paper) → text-ink-mid (7:1).

WS connection status:
- "Live": text-ink-soft → text-ink-mid (paired with the green dot).
- "Reconnecting": text-ink-soft → text-warm (semantic match for amber dot).
- "Offline": text-ink-soft → text-bad (semantic match for red dot).
  Status text now reinforces the dot colour instead of disappearing on
  light surfaces.

Help popover:
- Close button: text-ink-soft → text-ink-mid + focus-visible:underline.
- HelpRow body text: text-ink-soft → text-ink-mid (was 3.5:1 on the
  bg-surface-sunken/45 popover row — failed AA for body text).
2026-05-03 11:26:28 -07:00
Hongming Wang 80c6f6e4b6 Merge pull request #2613 from Molecule-AI/auto-sync/main-a1e40fe0
chore: sync main → staging (auto, ff to a1e40fe0)
2026-05-03 18:23:13 +00:00
molecule-ai[bot] a1e40fe0d9 Merge pull request #2612 from Molecule-AI/staging
staging → main: auto-promote a8708ca
2026-05-03 11:18:27 -07:00
Hongming Wang a8708caf73 Merge pull request #2611 from Molecule-AI/fix/2488-trust-boundary-meta-fields
feat(security): trust-boundary gate non-peer_id meta fields in _build_channel_notification (#2488)
2026-05-03 18:02:10 +00:00
Hongming Wang 02ae2fd6fb feat(security): trust-boundary gate non-peer_id meta fields in _build_channel_notification (#2488)
Defense-in-depth follow-up to #2481 (peer_id trust-boundary gate).
Same XML-attribute injection vector applies to the four other meta
fields rendered as agent-context attrs in the <channel> tag:

  <channel kind="..." method="..." activity_id="..." ts="..." source="molecule">

Each field is now passed through a closed-set / shape-validate gate:

- kind     → frozenset {canvas_user, peer_agent} via _safe_meta_field
- method   → frozenset {message/send, tasks/send, tasks/get, notify, ""}
- activity_id → UUID-shape regex via _safe_activity_id
- ts       → ISO-8601 RFC3339 regex via _safe_ts

Any value outside the allowed shape is replaced with empty string.
Today the values come from a platform-DB column so they're trusted,
but "trust the source" was the same assumption that got peer_id into
trouble (#2481). Closed-enum allowlists make this row-content-blind.

5 new tests mirroring test_envelope_enrichment_strips_path_traversal_peer_id:
- test_envelope_strips_unknown_kind         — kind injection stripped
- test_envelope_strips_unknown_method       — method injection stripped
- test_envelope_strips_malformed_activity_id — non-UUID stripped
- test_envelope_strips_malformed_ts         — non-ISO8601 stripped
- test_envelope_keeps_valid_meta_fields_unchanged — happy-path negative case

Mutation-tested: temporarily making _safe_meta_field permissive kills
both kind/method strip tests with the injection payload reflecting
into the meta dict, confirming the gate is what blocks them.

Two existing tests updated to use UUID-shaped activity_ids ("act-7",
"act-bridge-test" → real UUIDs) since the gate strips synthetic ids.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:58:52 -07:00
Hongming Wang f21d79c4ad Merge pull request #2610 from Molecule-AI/auto-sync/main-120bb1f0
chore: sync main → staging (auto, ff to 120bb1f0)
2026-05-03 17:53:10 +00:00
molecule-ai[bot] 120bb1f0a2 Merge pull request #2609 from Molecule-AI/staging
staging → main: auto-promote 257079c
2026-05-03 10:48:41 -07:00
Hongming Wang cfd5ec8d82 Merge pull request #2608 from Molecule-AI/ui/canvas-workspace-card-contrast
fix(canvas): WorkspaceNode + tier-config contrast in light theme
2026-05-03 17:32:22 +00:00
Hongming Wang a4a32cded5 fix(canvas): WorkspaceNode + tier-config contrast in light theme
Cards on the canvas had multiple low-contrast surfaces in light mode:

WorkspaceNode.tsx (parent + TeamMemberChip) — same fixes both copies:
- "N sub" badge: hardcoded text-violet-300 + bg-violet-900/40 → semantic
  text-accent + bg-accent/15 + border-accent/40 (themes correctly).
- "REMOTE" pill: hardcoded violet/40 alpha → solid bg-violet-600 text-white
  (works on either surface with WCAG AA contrast).
- Runtime pill: drop /60 + /30 alpha modifiers, use solid surface-card +
  border-line tokens.
- Skill chips (online): text-good/80 + bg-emerald-950/30 (washed-out on
  warm-paper) → text-good + bg-good/15 + border-good/40 semantic.
- Skill chips (offline): text-ink-mid + bg-surface-card without alpha.
- Restart-to-apply banner: bg-sky-950/30 + text-sky-300/80 → bg-accent/10 +
  text-accent (sky-950 was nearly invisible on cream).
- Provisioning status text: text-sky-400 (poor on cream) → text-accent.
- "+N more" badges: text-ink-soft (3.5:1) → text-ink-mid (7:1).
- Active-tasks dot: bg-amber-400 + text-warm/80 → semantic bg-warm + text-warm.
- Degraded error preview: bg-amber-950/20 + text-warm/60 → bg-warm/10 +
  text-warm + border-warm/40.
- Eject icon hover: hover:text-sky-400 → hover:text-accent.
- Role text: text-ink-soft → text-ink-mid.

design-tokens.ts:
- TIER_CONFIG was dark-only: T2 (text-sky-400 + bg-sky-950/50), T3
  (text-violet-400 + bg-violet-950/50), T4 (text-warm + bg-amber-950/50).
  Migrated to solid bg + white text patterns: T2=accent, T3=violet-600,
  T4=warm. T1 stays neutral (surface-card + ink-mid). All four pass WCAG
  AA on either theme.

No globals.css changes; uses existing semantic tokens.
2026-05-03 10:28:49 -07:00
Hongming Wang 257079c7a2 Merge pull request #2605 from Molecule-AI/fix/2485-chat-history-followups
fix(chat-history): correct docstring inversion + pin empty-history JSON shape (#2485)
2026-05-03 17:24:42 +00:00
Hongming Wang 0567502316 Merge pull request #2607 from Molecule-AI/auto-sync/main-7cba0477
chore: sync main → staging (auto, ff to 7cba0477)
2026-05-03 17:23:35 +00:00
molecule-ai[bot] 7cba0477cc Merge pull request #2606 from Molecule-AI/staging
staging → main: auto-promote 4e72f1d
2026-05-03 10:18:56 -07:00
Hongming Wang ff3dcd37f6 fix(chat-history): correct docstring inversion + pin empty-history JSON shape (#2485)
Two follow-ups from the multi-axis review of #2474:

1. **Docstring inversion** in tool_chat_history. The doc said
   '(source_id=peer)' meant 'this workspace is the sender' — actually
   it means the *peer* is the sender (source_id is where the activity
   came FROM). Reframed to 'where the peer is either the sender or
   the recipient' to match the underlying SQL semantics.

2. **Empty-history test**. TestChatHistory had 10 tests but no
   200+[] happy-path pin. Added test_empty_history_returns_empty_json_list
   asserting result == '[]' on exact-equality (per assert-exact
   memory — substring '[]' would match envelope shapes too).

Both changes are pure docs+tests — no behaviour change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:09:15 -07:00
Hongming Wang 4e72f1d1db Merge pull request #2604 from Molecule-AI/ui/canvas-chat-contrast
fix(canvas): chat bubble + sub-tab contrast in light theme
2026-05-03 17:00:54 +00:00
Hongming Wang e22f7969f8 Merge pull request #2603 from Molecule-AI/auto-sync/main-46c8c1de
chore: sync main → staging (auto, ff to 46c8c1de)
2026-05-03 17:00:37 +00:00
Hongming Wang 3d145da99d fix(canvas): chat bubble + sub-tab contrast in light theme
Chat bubble fixes (canvas/src/components/tabs/ChatTab.tsx):
- User bubble: bg-accent-strong/30 + text-blue-100 → bg-accent + text-white
  (translucent dark-blue overlay on warm-paper surface read as pale lavender
  with near-invisible light-blue text — a real WCAG AA failure on the
  highest-traffic surface in canvas).
- System/error bubble: bg-red-900/30 + text-red-200 → bg-bad/10 + text-bad,
  using semantic tokens so dark-mode adapts automatically.
- Agent bubble: drop /80 + /30 opacity modifiers; solid bg-surface-card +
  text-ink + border-line gives consistent contrast in both themes.
- prose-invert was unconditional, so markdown text on agent/system bubbles
  rendered as light text on a light surface in light mode. Make it apply
  only on the user bubble (the only dark surface in this component).
- Timestamp: text-ink-soft is too pale on warm-paper; use text-ink-mid for
  agent/system, white/70 for user (visible on the now-solid blue bg).

Sub-tab bar fixes (canvas/src/components/SidePanel.tsx):
- Right-edge fade was hardcoded `from-zinc-950` — that paints a dark vertical
  strip on the right edge of the tab bar in light mode. Switch to
  `from-surface` so the gradient blends into whichever theme is active.
- Inactive tab text: text-ink-soft (~3.5:1 on warm-paper) → text-ink-mid
  (~7:1). Active tab background: drop the /40 opacity so the selection is
  unambiguous on either surface.

No semantic-token additions; all changes use existing warm-paper tokens
that already work in both themes.
2026-05-03 09:58:18 -07:00
molecule-ai[bot] 46c8c1de23 Merge pull request #2602 from Molecule-AI/staging
staging → main: auto-promote 6d38b96
2026-05-03 16:49:40 +00:00
Hongming Wang 6d38b96043 Merge pull request #2601 from Molecule-AI/fix/2483-negative-cache-branch-tests
test(envelope-enrichment): pin negative-cache for non-JSON 200 + non-dict JSON 200 (#2483)
2026-05-03 16:37:30 +00:00
Hongming Wang 270a95aa67 test(envelope-enrichment): pin negative-cache for non-JSON 200 + non-dict JSON 200 (#2483)
The two missing branch tests called out by the multi-axis review of #2471.

a2a_client.enrich_peer_metadata handles two failure shapes (lines 105-112)
that the existing 12 envelope-enrichment tests don't exercise:

  1. HTTP 200, response.json() raises (non-JSON body)
  2. HTTP 200, valid JSON, but body is list/string/number not dict

Both paths land at the negative-cache write, but no test verified the
discriminator. Pin both with the same call_count == 1 assertion shape
the 5xx + network-exception tests already use.

Verified: temporarily removing the negative-cache write in either
branch makes the corresponding test fail with call_count == 2 — the
assertion correctly discriminates the contract from a fall-through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 09:35:21 -07:00
Hongming Wang 6431bdc631 Merge pull request #2600 from Molecule-AI/auto-sync/main-72b6be82
chore: sync main → staging (auto, ff to 72b6be82)
2026-05-03 16:23:16 +00:00
molecule-ai[bot] 72b6be82b0 Merge pull request #2599 from Molecule-AI/staging
staging → main: auto-promote b425995
2026-05-03 09:18:48 -07:00
Hongming Wang b42599585e Merge pull request #2598 from Molecule-AI/fix/auto-promote-skip-empty-tree
fix(auto-promote): skip empty-tree promotes to break perpetual cycle
2026-05-03 15:59:05 +00:00
Hongming Wang 06bfed2e35 Merge pull request #2597 from Molecule-AI/auto-sync/main-d1eab79d
chore: sync main → staging (auto, ff to d1eab79d)
2026-05-03 15:57:47 +00:00
Hongming Wang 80b38900de fix(auto-promote): skip empty-tree promotes to break perpetual cycle
The auto-promote ↔ auto-sync chain has been generating empty PRs
indefinitely since the staging merge_queue ruleset uses MERGE
strategy:

1. Auto-promote merges PR via queue → main = merge commit M2 not in staging
2. Auto-sync opens sync-back PR. Workflow's local `git merge --ff-only`
   succeeds (PR title even says "ff to ..."), but the queue lands the
   PR via MERGE → staging = merge commit S2 not in main
3. Auto-promote sees staging ahead by 1 → opens new promote PR. Tree
   diff vs main = 0 (S2's tree == main's tree). But the gate logic
   only checks "all required workflows green", not "actual code to
   ship" → opens an empty promote PR
4. ... repeat indefinitely

Each round costs ~30-40 min wallclock, ~2 manual approvals (the queue
requires 1 review and the bot can't self-approve without admin
bypass), and one full CodeQL Go run (~15 min).

Observed today (2026-05-03) across PRs #2592#2594#2595#2596#2597 — 5 PRs, ~3 hours, all empty content.

Fix: before opening the promote PR, check that staging's tree
actually differs from main's tree. If they're identical (the
empty-merge-commit cycle), skip cleanly and let the cycle terminate.

Implementation:
- New step `Skip if staging tree == main tree` runs before the
  existing gate check.
- `git diff --quiet origin/main $HEAD_SHA` exits 0 iff trees match.
- On match: emits a step summary explaining the skip + sets
  `skip=true`; subsequent gate-check + promote steps are gated on
  `skip != 'true'` so they short-circuit.
- Fail-open: if `git fetch` errors, fall through to gate check
  (preserve existing behavior). Only skip when diff is DEFINITIVELY
  empty.

Long-term, the cleaner fix is to switch the merge_queue ruleset's
merge_method away from MERGE so FF-able PRs land cleanly without a
new commit — but that's a broader change affecting every staging
PR's commit shape. This guard is the surgical one-step break.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:56:44 -07:00
molecule-ai[bot] d1eab79d28 Merge pull request #2596 from Molecule-AI/staging
staging → main: auto-promote 824a2a7
2026-05-03 15:50:12 +00:00
Hongming Wang 824a2a7657 Merge pull request #2595 from Molecule-AI/auto-sync/main-876d6ec8
chore: sync main → staging (auto, ff to 876d6ec8)
2026-05-03 15:38:22 +00:00
molecule-ai[bot] 876d6ec8c9 Merge pull request #2594 from Molecule-AI/staging
staging → main: auto-promote 63e3d38
2026-05-03 08:33:51 -07:00
Hongming Wang 63e3d385d6 Merge pull request #2592 from Molecule-AI/auto-sync/main-2e78812f
chore: sync main → staging (auto, ff to 2e78812f)
2026-05-03 15:15:01 +00:00
molecule-ai[bot] 2e78812ff9 Merge pull request #2591 from Molecule-AI/staging
staging → main: auto-promote 19cc833
2026-05-03 15:04:00 +00:00
Hongming Wang 9664d66e4b Merge branch 'main' into staging 2026-05-03 07:48:31 -07:00
Hongming Wang 19cc83313a Merge pull request #2589 from Molecule-AI/fix/retarget-skip-staging-head
fix(retarget): skip PRs whose head is staging (auto-promote PRs)
2026-05-03 14:36:44 +00:00
molecule-ai[bot] 097d513b65 Merge pull request #2588 from Molecule-AI/staging
staging → main: auto-promote c45aa8d
2026-05-03 07:35:05 -07:00
Hongming Wang 2b3f44c3c8 fix(retarget): skip PRs whose head is staging (auto-promote PRs)
The retarget-main-to-staging workflow tries to PATCH base=staging on
every bot-authored PR opened against main. Auto-promote staging→main
PRs have head=staging, base=main — retargeting them sets head AND
base to staging, which GitHub rejects with HTTP 422 "no new commits
between base 'staging' and head 'staging'".

This started surfacing on PR #2588 (2026-05-03 14:30) once #2586
switched the auto-promote workflow to an App token. Before #2586
the auto-promote PR was authored by github-actions[bot], which the
retarget filter happened to skip; now it's molecule-ai[bot], which
passes the bot filter and triggers the broken retarget attempt.

Add a head-ref != 'staging' guard so auto-promote PRs short-circuit
before the PATCH. The existing 422 "duplicate base" detector is
left alone — it covers a different operational case.
2026-05-03 07:34:24 -07:00
Hongming Wang c45aa8d7ee Merge pull request #2587 from Molecule-AI/auto-sync/main-b4e45374
chore: sync main → staging (auto, ff to b4e45374)
2026-05-03 14:19:28 +00:00
Hongming Wang b4e45374bf Merge pull request #2586 from Molecule-AI/fix/auto-promote-app-token
fix(auto-promote): use App token for auto-merge to fire downstream cascade (#2357)
2026-05-03 07:15:31 -07:00
Hongming Wang f2d69f0088 Merge pull request #2585 from Molecule-AI/fix/canvas-loading-state-aria
fix(canvas): add role=status + aria-live to remaining loading states
2026-05-03 14:14:33 +00:00
Hongming Wang bc11ed8a2b fix(auto-promote): use App token for auto-merge to fire downstream cascade (#2357)
GITHUB_TOKEN-initiated merges suppress the downstream `push` event on
main per GitHub's documented limitation:
  https://docs.github.com/en/actions/using-workflows/triggering-a-workflow#triggering-a-workflow-from-a-workflow

Result before this fix: every staging→main promote landed silently —
publish-workspace-server-image, canary-verify, and redeploy-tenants-on-main
all stayed dark. The polling tail was the SOLE cascade trigger; if it
ever 30-min-timed-out the chain dead-locked invisibly.

Symptom (from the issue body, 2026-04-30):

| Time     | Event                                            | Triggered? |
|----------|--------------------------------------------------|-----------|
| 05:48:04 | Promote PR #2352 merged (c140ad28)               | No fired  |
| 06:07:29 | Promote PR #2356 merged (5973c9bd)               | No fired  |

Fix: mint the molecule-ai App token BEFORE the promote-PR step and
hand it to the auto-merge call. App-token-initiated merges DO trigger
downstream workflow_run cascades.

The polling tail stays as defense-in-depth (with comments updated):
once we've observed >=10 successful natural cascades it can be dropped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 07:13:26 -07:00
Hongming Wang e2328abedc fix(canvas): add role=status + aria-live to remaining loading states
Three loading-state divs were missing the role/aria pattern that
TemplatePalette.tsx and EmptyState.tsx already follow. Screen readers
get no signal that the page is waiting:

- canvas/src/app/page.tsx — full-screen "Loading canvas..." while
  the websocket hydrates. First paint of the entire app.
- canvas/src/components/settings/TokensTab.tsx — "Loading tokens..."
- canvas/src/components/settings/OrgTokensTab.tsx — "Loading keys..."

Add role="status" + aria-live="polite" to the wrapping div so
assistive tech announces the wait and the eventual transition.
Visual rendering unchanged.
2026-05-03 07:11:48 -07:00
github-actions[bot] bdad75ae3e Merge pull request #2582 from Molecule-AI/staging
staging → main: auto-promote 90ba2cd
2026-05-03 07:06:58 -07:00
Hongming Wang 90ba2cd4df Merge pull request #2580 from Molecule-AI/auto-sync/main-b002247f
chore: sync main → staging (auto, ff to b002247f)
2026-05-03 13:54:03 +00:00
Hongming Wang b002247f12 Merge pull request #2576 from Molecule-AI/staging
staging → main: auto-promote effbcd7
2026-05-03 06:44:31 -07:00
Hongming Wang 03bcce3eb3 Merge pull request #2574 from Molecule-AI/auto-sync/main-55d85147
chore: sync main → staging (auto, ff to 55d85147)
2026-05-03 13:18:34 +00:00
Hongming Wang c74e71d604 Merge branch 'staging' into auto-sync/main-55d85147 2026-05-03 06:07:20 -07:00
Hongming Wang d7f88674d8 Merge pull request #2577 from Molecule-AI/fix/canvas-tier-legend-t3-t4-contract
fix(canvas): align tier text contracts with 4-tier reality (T1/T2/T3/T4)
2026-05-03 12:58:52 +00:00
Hongming Wang 7abb94dab8 fix(canvas): align tier text contracts with 4-tier reality (T1/T2/T3/T4)
The tier system in CreateWorkspaceDialog and design-tokens has been
T1 Sandboxed / T2 Standard / T3 Privileged / T4 Full Access, but two
chrome surfaces still showed the older 3-tier mapping with T3 as
"Full Access":

- Legend (bottom-left chrome on every canvas page) listed only T1/T2/T3
  and called T3 "Full Access". On a SaaS tenant the actual workspace
  badges render T4 (in amber/warm) — there was no T4 entry in the
  legend at all, so the user sees an undocumented orange badge.

- ConfigTab tier dropdown (per-workspace settings → Sandboxing) had no
  T4 option at all and called T3 "Full Access". So an existing T4
  workspace would show "T3 — Full Access" as the selected option,
  silently downgrading the displayed tier on the settings panel.

- tenant.ts isSaaSTenant() doc comment claimed SaaS workspaces are
  "inherently T3 Full Access" — wrong on both the number and the lock
  rationale (SaaS hides T1/T2/T3, not just T1/T2).

Fix:
- Legend now imports TIER_CONFIG and renders all four tiers
  (Sandboxed/Standard/Privileged/Full Access) using the same color
  swatches as the badges on workspace cards. Eliminates the previous
  drift where Legend's hardcoded sky/violet/warm chips didn't match
  the gray/sky/violet/amber actually rendered on nodes.
- ConfigTab adds the missing T4 — Full Access option and renames T3
  to Privileged.
- tenant.ts comment updated to match the picker's actual hide list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 05:56:18 -07:00
Hongming Wang effbcd737b Merge pull request #2575 from Molecule-AI/fix/cascade-include-all-active-templates
fix(publish-runtime): re-add 5 templates wrongly removed from cascade — fixes #2566
2026-05-03 12:45:48 +00:00
Hongming Wang 6eb79adfd5 manifest: re-add 5 workspace templates pruned by #2536
The cascade-list-vs-manifest drift gate (PR #2556's behavior-based
test) caught my previous-commit cascade additions as 'extra-in-cascade'.
Manifest is the source of truth — restoring there.

All 5 templates have successful publish-image runs in the past 24h
(verified before the cascade fix), and continuous-synth-e2e defaults
to langgraph as its primary canary. None deprecated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 05:43:07 -07:00
Hongming Wang 8f48a38550 fix(publish-runtime): re-add 5 templates wrongly removed from cascade (#2566)
The PR #2536 cascade prune ('deprecated, no shipping images') was
empirically wrong. Re-confirmed 2026-05-03:

- continuous-synth-e2e.yml defaults to langgraph as its primary canary
- All 5 'deprecated' templates have successful publish-image runs in
  the past 24h: langgraph, crewai, autogen, deepagents, gemini-cli

Symptom this fixes — issue #2566 (priority-high, failing 36+h):

  Synthetic E2E (staging): langgraph adapter A2A failure
  'Received Message object in task mode' — failing for >36h

Today at 11:06 commit e1628c4 fixed the underlying a2a-sdk strict-mode
issue in workspace/a2a_executor.py. publish-runtime fired at 11:13 and
cascaded — but only to claude-code, hermes, openclaw, codex. langgraph
was excluded by the prune, so its image stayed on the broken runtime
and the synth E2E (which defaults to langgraph) kept failing despite
the fix being live in PyPI.

After this lands + the next runtime publish fires, langgraph image
re-bakes with the fix and synth-E2E goes green.

Test plan:

- [x] yaml-validate the workflow
- [ ] After merge, watch publish-runtime cascade to all 9 templates
- [ ] Confirm langgraph publish-image fires + succeeds
- [ ] Confirm next continuous-synth-e2e run goes green

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 05:41:53 -07:00
github-actions[bot] 55d85147f7 Merge pull request #2573 from Molecule-AI/staging
staging → main: auto-promote dc6425f
2026-05-03 05:34:23 -07:00
github-actions[bot] f7e8f98cf7 Merge pull request #2570 from Molecule-AI/staging
staging → main: auto-promote 173e22e
2026-05-03 12:22:52 +00:00
Hongming Wang dc6425fe39 Merge pull request #2571 from Molecule-AI/fix/synth-e2e-model-slug-by-runtime
fix(synth-e2e): branch MODEL_SLUG by runtime so langgraph gets colon-form
2026-05-03 12:22:19 +00:00
Hongming Wang cbc69f5e7e fix(synth-e2e): branch MODEL_SLUG by runtime so langgraph gets colon-form
The original script hardcoded `MODEL_SLUG="openai/gpt-4o"` (slash) and
claimed "non-hermes runtimes ignore the prefix" — wrong for langgraph,
which delegates model resolution to langchain's `init_chat_model`. That
function requires `<provider>:<model>` (colon) and treats slash-form as
OpenRouter routing, falling through without auth even when
OPENAI_API_KEY is set.

Surfaced 2026-05-03 after the a2a-sdk v1 contract bugs (PR
#2558+#2563+#2567) cleared the masking layers — synth-E2E firing
2026-05-03T12:14 returned a properly-shaped task with state=failed +
"Could not resolve authentication method" inside the agent body.

continuous-synth-e2e.yml defaults E2E_RUNTIME=langgraph for the cron,
so every firing hit this. Hermes still gets the slash-form it
needs; claude-code uses the entry-id pattern.

Adds E2E_MODEL_SLUG override for operator-dispatched runs that want
to pin a specific slug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 05:17:55 -07:00
Hongming Wang c71f641b12 Merge pull request #2569 from Molecule-AI/fix/redeploy-canary-default
ci(redeploy): fix stale canary_slug default 'hongmingwang' → 'hongming'
2026-05-03 12:08:26 +00:00
Hongming Wang 173e22e091 Merge pull request #2568 from Molecule-AI/auto-sync/main-c0838d63
chore: sync main → staging (auto, ff to c0838d63)
2026-05-03 12:07:29 +00:00
Hongming Wang 60a516bc8d ci(redeploy): fix stale canary_slug default 'hongmingwang' → 'hongming'
The workflow_dispatch input default and the workflow_run env fallback
both pointed at 'hongmingwang', which doesn't match any current prod
tenant (slugs are: hongming, chloe-dong, reno-stars). CP silently
skipped the missing canary and put every tenant in batch-1 in parallel,
defeating the canary-first soak gate that exists to catch image-boot
regressions before they hit the whole fleet.

Concrete example from today's c0838d6 redeploy at 11:53Z (run 25278434388):
the dispatched body was `{"target_tag":"staging-c0838d6","canary_slug":"hongmingwang",...}`
and the CP response showed all 3 tenants in `"phase":"batch-1"` — no
soak, no canary. The deploy happened to be safe, but a broken image
would have hit hongming + chloe-dong + reno-stars simultaneously.

Fixed in three places: the runtime ordering comment, the
workflow_dispatch default, and the env fallback used by the
workflow_run trigger. Comment documents the rationale so the next
slug rename doesn't silently regress this again.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 05:06:01 -07:00
Hongming Wang c0838d637e Merge pull request #2562 from Molecule-AI/staging
staging → main: auto-promote bb63e60
2026-05-03 04:49:36 -07:00
Hongming Wang 493ab2566e Merge pull request #2567 from Molecule-AI/fix/synth-e2e-openai-key
ci(synth-e2e): wire MOLECULE_STAGING_OPENAI_KEY into provisioned tenant
2026-05-03 11:45:17 +00:00
Hongming Wang 5e46ea70d6 ci(synth-e2e): wire MOLECULE_STAGING_OPENAI_KEY into provisioned tenant
The synth-E2E (#2342) provisions a langgraph tenant whose default
model `openai:gpt-4.1-mini` requires OPENAI_API_KEY for the first LLM
call. Sibling workflows already wire this:
- e2e-staging-saas.yml:89
- canary-staging.yml:63

continuous-synth-e2e.yml just forgot. Result: tenant boots, accepts
a2a messages, then returns:

  Agent error: "Could not resolve authentication method. Expected
  either api_key or auth_token to be set."

This was masked since 2026-04-29 (workflow creation) by a2a-sdk v0→v1
contract violations — PR #2558 (Task-enqueue) and #2563
(TaskUpdater.complete/failed terminal events) cleared those, exposing
the underlying auth gap on the synth-E2E firing at 11:39 UTC today.

The script tests/e2e/test_staging_full_saas.sh:325 already reads
E2E_OPENAI_API_KEY and persists it as a workspace_secret on tenant
create — only the workflow wiring was missing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 04:43:07 -07:00
Hongming Wang 5cf3dc4369 Merge pull request #2565 from Molecule-AI/fix/redeploy-soft-warn-rt-e2e-prefix
ci(deploy): broaden ephemeral-prefix matchers to cover rt-e2e-*
2026-05-03 11:30:52 +00:00
Hongming Wang 596e797dca ci(deploy): broaden ephemeral-prefix matchers to cover rt-e2e-*
The redeploy-tenants-on-staging soft-warn filter and the
sweep-stale-e2e-orgs janitor both hardcoded `^e2e-` to identify
ephemeral test tenants. Runtime-test harness fixtures (RFC #2251)
mint slugs prefixed with `rt-e2e-`, which neither matcher recognized.

Concrete impact observed today:
  - Two `rt-e2e-v{5,6}-*` tenants left orphaned 8h on staging
    (sweep-stale-e2e-orgs ignored them).
  - On the next staging redeploy their phantom EC2s returned
    `InvalidInstanceId: Instances not in a valid state for account`
    from SSM SendCommand → CP returned HTTP 500 + ok=false.
  - The redeploy soft-warn missed them too, so the workflow went
    red, which broke the auto-promote-staging chain feeding the
    canvas warm-paper rollout to prod.

Fix: switch both matchers to recognize the alternation
`^(e2e-|rt-e2e-)`. Long-lived prefixes (demo-prep, dryrun-*, dryrun2-*)
remain non-ephemeral and continue to hard-fail. Comment documents
the source-of-truth list and the cross-file invariant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 04:28:29 -07:00
Hongming Wang 3ce638d6e6 Merge pull request #2564 from Molecule-AI/fix/canvas-react-flow-color-mode
fix(canvas): wire ReactFlow colorMode to resolvedTheme
2026-05-03 11:14:13 +00:00
Hongming Wang df7edfcd3f fix(canvas): wire ReactFlow colorMode to resolvedTheme
PR #2555 (Tailwind v4 + warm-paper) migrated all canvas chrome (toolbar,
side panel, modal layer) to semantic tokens, but missed the React Flow
viewport's `colorMode="dark"` literal — and two paired hardcoded dark
literals on the Background dot color and MiniMap mask. Net result on
prod: the user picked light mode, the toolbar flipped warm-paper, but
the canvas backplate, edges, dots, controls, and minimap stayed black —
visibly half-themed.

Three coordinated fixes inside the canvas viewport:
- ReactFlow `colorMode={resolvedTheme}` so the library's own dark/light
  styles flip with the user's choice.
- Background dot color picks the line-soft tone in light mode (zinc-800
  was invisible-on-cream).
- MiniMap maskColor warm-tints the off-viewport dim so the unselected
  region doesn't render as a hard black bar over warm-paper.

Verification:
- `npx tsc --noEmit` clean
- `npx vitest run` 188/188 pass
- (will browser-verify post-redeploy on hongming.moleculesai.app)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 04:11:35 -07:00
Hongming Wang 3ecb25eb4f Merge pull request #2563 from Molecule-AI/fix/a2a-v1-terminal-event
fix(a2a): route terminal Message via TaskUpdater.complete/failed in task mode
2026-05-03 11:09:09 +00:00
Hongming Wang e1628c4d56 fix(a2a): route terminal Message via TaskUpdater.complete/failed in task mode
PR #2558 enqueued a Task at the start of new requests so the v1 SDK
would accept TaskUpdater.start_work() — fix #1 of the v0→v1 migration
gap (PR #2170). But after Task is enqueued, the executor enters
"task mode" and the SDK rejects raw Message enqueues at the terminal
step:

  {"code":-32603,"message":"Received Message object in task mode.
  Use TaskStatusUpdateEvent or TaskArtifactUpdateEvent instead."}

Synth-E2E 2026-05-03T11:00:34Z surfaced this on the very first run
after the prior fix cascaded. Validation site is the same
a2a/server/agent_execution/active_task.py — the framework's job is
to enforce the v1 invariant; we're catching up to it.

The fix routes both terminal events through TaskUpdater helpers:
- success: updater.complete(message=msg) wraps in
  TaskStatusUpdateEvent(state=COMPLETED, final=True)
- error: updater.failed(message=...) wraps in
  TaskStatusUpdateEvent(state=FAILED, final=True)

Both helpers exist in a2a-sdk ≥ 1.0; verified via
TaskUpdater.complete signature.

Tests:
- conftest TaskUpdater stub now records complete/failed calls AND
  routes the message back through event_queue.enqueue_event so the
  ~20 legacy tests asserting on enqueue_event keep working
- 2 new regression tests pin the contract:
  * test_terminal_success_routes_via_updater_complete
  * test_terminal_error_routes_via_updater_failed
- Both NEW tests verified to FAIL on staging-baseline (without this
  fix) and PASS with it — they'd catch the regression before staging
  if the wheel-smoke gate covered task-mode terminal events too
  (separate yak-shave for #131 follow-up)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 04:06:45 -07:00
Hongming Wang 78721f7a42 Merge pull request #2561 from Molecule-AI/fix/cascade-list-drift-gate
feat(ci): structural drift gate for cascade list vs manifest (RFC #388 PR-3)
2026-05-03 10:55:08 +00:00
Hongming Wang 09010212a0 feat(ci): structural drift gate for cascade list vs manifest (RFC #388 PR-3)
Closes the recurrence path of PR #2556. The data fix realigned 8→4
templates in publish-runtime.yml's TEMPLATES variable, but the
underlying drift hazard was unguarded — the next manifest change
could silently leave cascade out of sync again.

This gate fails any PR that changes manifest.json or
publish-runtime.yml in a way that makes the cascade list diverge
from manifest workspace_templates (suffix-stripped). Either
direction is caught:

  missing-from-cascade  templates that won't auto-rebuild on a new
                       wheel publish (the codex-stuck-on-stale-runtime
                       bug class — PR #2512 added codex to manifest,
                       cascade wasn't updated, codex stayed pinned to
                       its last-built runtime version for weeks).

  extra-in-cascade     cascade dispatches to deprecated templates
                       (the wasted-API-calls + dead-CI-noise class —
                       PR #2536 pruned 5 templates from manifest;
                       cascade kept dispatching to all 8 until
                       PR #2556).

Triggers narrowly: only on PRs that touch manifest.json,
publish-runtime.yml, or the script itself. Fast (single grep+sed+comm
pipeline, no Go build).

Surfaced during the RFC #388 prior-art audit; folded in as the
structural follow-up to the data fix #2556 promised.

Self-tested both failure modes locally before commit:
  - Drop codex from cascade → script fails with "MISSING: codex"
  - Add langgraph to cascade → script fails with "EXTRA: langgraph"

Refs: https://github.com/Molecule-AI/molecule-controlplane/issues/388

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 03:52:39 -07:00
Hongming Wang bb63e60114 Merge pull request #2560 from Molecule-AI/fix/preflight-smoke-mode-bypass
fix(preflight): skip required_env check in MOLECULE_SMOKE_MODE
2026-05-03 10:46:20 +00:00
Hongming Wang 06240ab67b fix(preflight): skip required_env check in MOLECULE_SMOKE_MODE
Boot smoke (#2275) exercises executor.execute() against stub deps
and never hits the real provider, so missing auth env is not a real
blocker. Without this bypass, every adapter that introduces a new
auth env var must be mirrored into molecule-ci's fake-env list — a
maintenance treadmill that just bit hermes-template:

- 2026-05-03 09:47 UTC: hermes publish-image smoke fails on
  HERMES_API_KEY preflight (workflow injects CLAUDE_CODE_OAUTH_TOKEN,
  ANTHROPIC_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY but not
  HERMES_API_KEY or OPENROUTER_API_KEY). Failed for two cycles
  before being noticed.

The bypass demotes Required-env failures to warnings when
MOLECULE_SMOKE_MODE is truthy, so the unset env stays visible in
the boot log without blocking. Production paths are unchanged
(env unset → fail).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 03:44:05 -07:00
Hongming Wang 88ef70431e Merge pull request #2505 from Molecule-AI/staging
staging → main: auto-promote d64570a
2026-05-03 03:36:56 -07:00
Hongming Wang 750b32c33f Merge pull request #2558 from Molecule-AI/fix/a2a-v1-task-enqueue
fix(a2a): enqueue Task before TaskStatusUpdateEvent for v1 SDK contract
2026-05-03 10:18:36 +00:00
Hongming Wang 5c3b79a8ba fix(a2a): enqueue Task before TaskStatusUpdateEvent for v1 SDK contract
a2a-sdk ≥ 1.0 raises InvalidAgentResponseError when an executor publishes a
TaskStatusUpdateEvent (e.g. via TaskUpdater.start_work) before any Task
event for fresh requests. The framework only auto-creates the Task on
continuation messages (existing task_id resolves via task_manager.get_task);
new requests leave _task_created unset and the SDK validation at
a2a/server/agent_execution/active_task.py rejects the first status update.

PR #2170 migrated the executor surface to v1 but missed this contract. The
synthetic E2E gate caught it on every staging run since (~1 week silent
fail) with:

    {"jsonrpc":"2.0","id":"e2e-msg-1","error":{"code":-32603,
     "message":"Agent should enqueue Task before TaskStatusUpdateEvent
     event","data":null}}

The fix enqueues a Task(state=SUBMITTED) before the TaskUpdater is
constructed, gated on `context.current_task is None` so continuation
messages don't double-enqueue (which the SDK logs about but doesn't reject).

Tests:

  - test_first_event_is_task_for_new_request — pins the new-request path:
    first enqueue must be a Task with the expected id/context_id
  - test_no_task_enqueue_on_continuation — pins the continuation path: when
    context.current_task is set, the executor must NOT re-enqueue Task
  - conftest: stub Task / TaskStatus / TaskState in the mocked a2a.types
    module so the import inside the executor resolves under unit tests

google-adk adapter does not have this bug — its execute() only emits
Message events, not TaskStatusUpdateEvent. Its cancel() does emit one,
but cancel is rarely-invoked and out of scope for this fix.

Live verification path: this PR's merge → publish-runtime cascade → next
synth-E2E firing should go green at step "8/11 Sending A2A message to
parent — expecting agent response".
2026-05-03 03:15:54 -07:00
Hongming Wang e014d22ee9 Merge pull request #2557 from Molecule-AI/feat/sweep-aws-secrets-orphans
feat(ops): sweep orphan AWS Secrets Manager secrets
2026-05-03 09:48:59 +00:00
Hongming Wang 18c2bdbe68 Merge pull request #2529 from Molecule-AI/dependabot/pip/workspace/starlette-gte-1.0.0
chore(deps)(deps): update starlette requirement from >=0.38.0 to >=1.0.0 in /workspace
2026-05-03 09:42:15 +00:00
Hongming Wang 6f8f7932d2 feat(ops): add sweep-aws-secrets janitor — orphan tenant bootstrap secrets
CP's deprovision flow calls Secrets.DeleteSecret() (provisioner/ec2.go:806)
but only when the deprovision runs to completion. Crashed provisions and
incomplete teardowns leak the per-tenant `molecule/tenant/<org_id>/bootstrap`
secret. At ~$0.40/secret/month, ~45 leaked secrets surfaced as ~$19/month
on the AWS cost dashboard.

The tenant_resources audit table (mig 024) tracks four kinds today —
CloudflareTunnel, CloudflareDNS, EC2Instance, SecurityGroup — and the
existing reconciler doesn't catch Secrets Manager orphans. The proper fix
(KindSecretsManagerSecret + recorder hook + reconciler enumerator) is filed
as a follow-up controlplane issue. This sweeper is the immediate stopgap.

Parallel-shape to sweep-cf-tunnels.sh:
  - Hourly schedule offset (:30, between sweep-cf-orphans :15 and
    sweep-cf-tunnels :45) so the three janitors don't burst CP admin
    at the same minute.
  - 24h grace window — never deletes a secret younger than the
    provisioning roundtrip, so an in-flight provision can't be racemurdered.
  - MAX_DELETE_PCT=50 default (mirrors sweep-cf-orphans for durable
    resources; tenant secrets should track 1:1 with live tenants).
  - Same schedule-vs-dispatch hardening as the other janitors:
    schedule → hard-fail on missing secrets, dispatch → soft-skip.
  - 8-way xargs parallelism, dry-run by default, --execute to delete.

Requires a dedicated AWS_JANITOR_* IAM principal — the prod molecule-cp
principal lacks secretsmanager:ListSecrets (it only has scoped
Get/Create/Update/Delete). The workflow's verify-secrets step will hard-fail
on the first scheduled run until those secrets are configured, surfacing
the missing setup loudly rather than silently no-op'ing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 02:38:08 -07:00
Hongming Wang 15124527da Merge pull request #2276 from Molecule-AI/feat/layer1-runtime-digest-pinning
feat(provisioner): digest-pin runtime images via runtime_image_pins table (Layer 1 of #2272)
2026-05-03 09:32:54 +00:00
Hongming Wang e9a1ce3591 Merge pull request #2556 from Molecule-AI/fix/cascade-list-align-to-manifest
fix(publish-runtime): align cascade list to 4 supported runtimes
2026-05-03 09:32:36 +00:00
Hongming Wang 1bff419833 feat(provisioner): digest-pin workspace images via runtime_image_pins (#2272 layer 1)
Layer 1 of the runtime-rollout plan. Decouples publish from promotion by
giving operators a `runtime_image_pins` table the provisioner consults at
container-create time. No row = legacy `:latest` behavior; row present =
provisioner pulls `<base>@sha256:<digest>`. One bad publish no longer
breaks every workspace simultaneously.

Mechanics:

  - Migration 047: `runtime_image_pins` (template_name PK + sha256 digest +
    audit columns) and `workspaces.runtime_image_digest` (nullable, with
    partial index) for "show me workspaces still on the old digest" queries.
  - `resolveRuntimeImage` (handlers/runtime_image_pin.go): looks up the
    pin, returns `<base>@sha256:<digest>` on hit, "" on miss/error so the
    provisioner falls through to the legacy tag map. Availability over
    pinning — any DB error logs and returns "" rather than blocking the
    provision. `WORKSPACE_IMAGE_LOCAL_OVERRIDE=1` short-circuits the
    lookup so devs rebuilding template images locally see their fresh
    build.
  - `WorkspaceConfig.Image` carries the resolved value into the
    provisioner. `selectImage` honors it ahead of the runtime→tag map and
    falls back to DefaultImage on unknown runtime.
  - The existing `imageTagIsMoving` predicate (#215) already returns false
    on `@sha256:` form, so digest pins skip the force-pull path naturally.

Tests:

  - Handler-side (sqlmock): no-pin/db-error/with-pin/empty/unknown/local-
    override paths cover every branch of `resolveRuntimeImage`.
  - Provisioner-side: `selectImage` table covers explicit-image preference,
    runtime-map fallback, unknown-runtime → default, empty-config →
    default. Plus a struct-literal compile-time pin on `Image` so a future
    refactor can't silently drop the field.

Layer 2 (per-ring routing via `workspaces.runtime_image_digest`) and the
admin promote/rollback endpoint ride on top of this and ship separately.
2026-05-03 02:30:00 -07:00
Hongming Wang 24276b9458 fix(publish-runtime): align cascade list to 4 supported runtimes
The cascade `TEMPLATES` list in publish-runtime.yml had drifted from
manifest.json:

  Currently dispatches to: claude-code, langgraph, crewai, autogen,
                           deepagents, hermes, gemini-cli, openclaw
  manifest.json supports:  claude-code, hermes, openclaw, codex (after
                           PR #2536 pruned to 4 actively-supported)

Two consequences of the drift:

1. `codex` (added in PR #2512, supported in manifest) was never in the
   cascade — fresh runtime publishes did NOT trigger a codex template
   rebuild. Codex stayed pinned to whatever runtime version it last saw
   at its own image-build time.

2. langgraph/crewai/autogen/deepagents/gemini-cli — deprecated, no
   shipping images, no working A2A — were still receiving cascade
   dispatches. Wasted API calls and (worse) green CI on dead repos
   masks "this template is dead, stop maintaining it."

Now matches manifest.json workspace_templates exactly. Surfaced during
RFC #388 (fast workspace provision) prior-art audit.

Long-term fix is to derive TEMPLATES from manifest.json so this can't
drift again — captured as a Phase-1 invariant in RFC #388. This commit
is the data fix only; structural fix lands with the bake pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 02:28:15 -07:00
Hongming Wang aef9555b1d Merge pull request #2555 from Molecule-AI/feat/canvas-warm-paper-tailwind-v4
feat(canvas): warm-paper theme + Tailwind v4
2026-05-03 09:27:23 +00:00
Hongming Wang db48d1d261 fix(canvas): restore text-white on saturated buttons + close zinc gaps
Independent code review of #2555 caught two contrast regressions left
by the bulk perl pass:

1. text-white → text-ink mass-substitution silently broke destructive
   and primary buttons. text-ink resolves to #15181c (warm-paper
   near-black) in light mode — dark text on bg-red-600 / bg-amber-600
   / bg-emerald-600 / bg-blue-600 / bg-accent / bg-accent-strong /
   bg-good / bg-bad fails WCAG contrast and looks broken. Per-line
   pass flips text-ink → text-white only when a saturated bg utility
   is present; tinted-state pills (bg-red-950/50 etc.) keep their
   intentionally-retained text-* literals.

2. Original mapping table was missing bg-zinc-600 (most-used
   hover-state literal for cancel buttons — caused them to JUMP from
   warm cream resting state to dark zinc on hover in light mode) and
   text-zinc-700/800/900 (separator dots and decorative dim text
   invisible on warm-paper light bg). Extended mapping fills these
   gaps with bg-surface-card / text-ink-soft.

Also: drop stale tailwind.config.ts reference from components.json
(file deleted by the v3→v4 migration); switch baseColor zinc →
neutral and enable cssVariables since v4 uses CSS-driven tokens.
Future shadcn-cli invocations would have failed or written malformed
components without this.

27 sites in 27 files affected by #1, ~20 sites in 20 files by #2.
1214/1214 unit tests still pass; build still clean.

Findings courtesy of multi-model review per code-review-and-quality
skill — different blind spots catch different bugs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 02:04:20 -07:00
Hongming Wang 052575d773 fix(canvas): regenerate lockfile with cross-platform optional deps
CI's `npm ci` failed because the previous lock was generated on macOS
arm64, which omits the Linux-specific optional deps that
@tailwindcss/postcss → lightningcss-linux-x64-gnu transitively need
(@emnapi/runtime, @emnapi/core).

Re-ran `npm install --include=optional` so the lock includes every
platform variant of lightningcss + the @emnapi packages they pull in.
Runner (Linux x64) now has what it needs; local macOS install still
fine (npm picks the matching binary at install time).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 01:52:42 -07:00
Hongming Wang c0eca8d0e1 feat(canvas): warm-paper theme + Tailwind v4 migration
Brings the canvas onto the warm-paper design system already shipped to
landing, marketplace, and SaaS surfaces, and migrates the build from
Tailwind v3 → v4 to match molecule-app.

Plumbing:
- swap tailwindcss v3 → v4, drop autoprefixer, add @tailwindcss/postcss
- delete tailwind.config.ts (v4 reads tokens from @theme blocks in CSS)
- globals.css: @import "tailwindcss" + @plugin "@tailwindcss/typography"
- two @theme blocks: warm-paper light defaults + always-dark surface
  tokens (bg-bg / ink-mute / line-strong) for terminal/console panels
- [data-theme="dark"] cascade overrides the warm-paper tokens for dark
- React Flow edge stroke + scrollbar + selection colour pull from
  semantic tokens so they flip with the theme

Theme infra (ported from molecule-app, identical contracts):
- lib/theme-cookie.ts: mol_theme cookie + boot script (no "use client"
  so server components can read the constants)
- lib/theme-provider.tsx: ThemeProvider + useTheme + cookie writer with
  Domain=.moleculesai.app so the preference follows the user across
  canvas/app/market/landing subdomains AND tenant subdomains
- lib/theme.ts: ColorToken union + cssVar() helper
- components/ThemeToggle.tsx: 3-way System/Light/Dark picker
- layout.tsx: SSR cookie read + nonce'd inline boot script (CSP needs
  the explicit nonce — strict-dynamic doesn't forgive an un-nonce'd
  inline sibling) + ThemeProvider wrapper + bg-surface/text-ink body

Component migration (62 files):
- Mechanical bg-zinc-* / text-zinc-* / border-zinc-* / text-white →
  semantic surface/ink/line tokens via perl negative-lookahead pass
  (preserves opacity modifiers like /80, /60)
- bg-blue-500/600 → bg-accent / bg-accent-strong
- text-red-* / amber-* / emerald-* → text-bad / warm / good
- Tinted-state banner backgrounds (bg-red-950, bg-amber-950, bg-blue-950
  etc.) intentionally left literal — they remain readable on warm-paper
  in light mode without inventing new state-soft tokens
- TerminalTab.tsx skipped — xterm renders to canvas, not DOM
- 3 unit-test assertions updated to match new token strings (credits
  pillTone, AuthGate overlay class, A2AEdge accent)

Verification:
- pnpm test: 1214/1214 pass
- pnpm tsc --noEmit: clean
- next build: ✓ Compiled successfully (8 routes)
- dev server inspection: html data-theme stamped, body uses
  bg-surface text-ink, boot script carries nonce, compiled CSS
  contains both @theme blocks + [data-theme="dark"] override

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 01:43:55 -07:00
Hongming Wang e4893f5a9a Merge pull request #2552 from Molecule-AI/feat/wire-event-log-into-adapter-base
feat(workspace): wire EventLog into adapter base (#119 PR-3b)
2026-05-03 08:39:34 +00:00
Hongming Wang 872f8e8971 Merge pull request #2554 from Molecule-AI/chore/remove-dead-ast-defensive-block
chore(workspace): remove dead defensive block in load_skills AST gate
2026-05-03 08:33:18 +00:00
Hongming Wang d58185b8a8 chore(workspace): remove dead defensive block in load_skills AST gate
Self-review of PR #2553 caught an unreachable defensive block at
test_load_skills_call_sites.py:99-103: the inner check guarded
`call.func.__class__.__name__ == "Name"` from a FunctionDef, but
`_find_load_skills_calls` already filters its return type to
`ast.Call` — `FunctionDef` cannot reach that loop body. The block
was a no-op `pass` with a misleading comment.

Removing keeps the gate behaviorally identical; tests still pass.

Same five-axis review pass that turned this up also approved the
substantive logic of #2553, so no behavior change here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 01:30:05 -07:00
Hongming Wang 3c0f7de4b9 Merge pull request #2553 from Molecule-AI/feat/skill-compat-audit
docs(skills): document SKILL.md `runtime` field + AST coverage gate (#119 PR-4)
2026-05-03 08:24:55 +00:00
Hongming Wang f8b40d8d73 docs(skills): document SKILL.md runtime field + AST coverage gate (#119 PR-4)
Closes the documentation + audit gap for declarative skill-compat. The
plumbing has been live since PR #117 (RuntimeCapabilities) and
skill_loader's `_normalize_runtime_field` has been emitting filter
decisions for weeks, but:
- No public doc explained the `runtime` frontmatter field, so skill
  authors didn't know how to opt in / opt out.
- No structural gate ensured every load_skills() call site threads
  current_runtime — a future caller forgetting the kwarg silently
  force-loads runtime-incompatible skills (no AttributeError, just a
  delayed crash on first tool invocation).

Two changes:

1. docs/agent-runtime/skills.md
   - Adds `runtime`, `tags`, `examples` to the Frontmatter Fields table.
   - Adds a Runtime Compatibility section with example, accepted shapes
     (universal default, list, string sugar), and the "logged + omitted,
     not crashed" failure mode. Notes that match values come from each
     adapter's name() (the same string in config.yaml's runtime: field).

2. workspace/tests/test_load_skills_call_sites.py
   - Static AST gate: walks every workspace/*.py (excluding tests),
     finds load_skills(...) Call nodes, fails if any lacks
     current_runtime= as a keyword.
   - Defense-in-depth `test_known_call_sites_present` — pins that the
     scan actually sees the two known callers (adapter_base,
     skill_loader.watcher) so a refactor that moves them is loud.
   - Sanity-checked the matcher against a synthetic violating module.

Same-shape pattern as PR #2358 (tenant_resources audit-coverage AST
gate, #150) — pin the contract structurally, not just behaviorally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 01:22:34 -07:00
Hongming Wang 71e7a6ffee feat(workspace): wire EventLog into adapter base (#119 PR-3b)
Adds adapter.event_log property+setter on BaseAdapter so adapters can
emit structured events (tool dispatch, skill load, executor errors)
without coupling to the chosen backend. Default is a shared no-op
DisabledEventLog; main.py overrides at boot from the
observability.event_log config block (PR-2 schema).

The shape is intentionally additive:
- Property is invisible to the BaseAdapter signature snapshot drift
  gate (the helper walks vars(cls) for callables only — properties
  are not callable). Verified with a regression test in the new
  test_adapter_base_event_log.py.
- Existing adapters continue to work unchanged. Template repos that
  never call self.event_log get the no-op for free.
- Setter accepts any EventLogBackend, so swapping memory↔disabled
  at runtime (or to a future Redis backend) requires no adapter
  code change.

Sequels:
- PR-3c: emit events from claude-code/hermes adapters at the
  natural points (tool dispatch, skill load).
- PR-4: skill-compat audit + SKILL.md frontmatter docs.
- Platform-side /workspaces/:id/activity endpoint reads the buffer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 01:18:19 -07:00
Hongming Wang e2b58f0fbc Merge pull request #2551 from Molecule-AI/feat/wire-observability-config
feat(workspace): wire observability heartbeat + log_level into consumers (#119 PR-3a)
2026-05-03 08:05:00 +00:00
Hongming Wang efa68a26b1 feat(workspace): wire observability config into heartbeat + uvicorn (#119 PR-3a)
Replaces the hard-coded HEARTBEAT_INTERVAL=30 in heartbeat.py and
log_level="info" in main.py with values from
ObservabilityConfig (#119 PR-1, schema landed in PR #2538).

Concrete plumbing:

  - heartbeat.HeartbeatLoop accepts an `interval_seconds=` keyword
    arg. Defaults to the legacy module constant so 2-arg callers
    (existing tests, any downstream code that hasn't been updated)
    keep their existing 30s behavior.
  - main.py constructs HeartbeatLoop with
    config.observability.heartbeat_interval_seconds — the value the
    config parser already clamped to [5, 300].
  - main.py's uvicorn.Config takes log_level from
    config.observability.log_level (lowercased — uvicorn's convention
    differs from Python logging's) with LOG_LEVEL env still winning
    as an ops-side debugging override.

Adapter EventLog wiring deferred to PR-3b (#208 follow-up) — touches
adapter_base interface + needs careful design, kept separate to keep
this PR small + reviewable.

Tests:
  - test_heartbeat.py: 3 new tests pin default interval, explicit
    override, and the [5, 300] band that the constructor accepts
    without re-clamping (clamping is the parser's job).
  - All 88 tests in test_heartbeat.py + test_config.py pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 01:01:57 -07:00
Hongming Wang 87e355c296 Merge pull request #2548 from Molecule-AI/feat/event-log-module
feat(workspace): event_log module + EventLogConfig (#119 PR-2)
2026-05-03 07:54:05 +00:00
Hongming Wang 67f3e49e42 Merge pull request #2549 from Molecule-AI/fix/orphan-sweeper-skip-external-runtime
fix(orphan-sweeper): exclude runtime='external' from stale-token revoke
2026-05-03 07:52:43 +00:00
Hongming Wang be271aef8b fix(orphan-sweeper): exclude runtime='external' from stale-token revoke
The Docker-mode orphan sweeper was incorrectly targeting external runtime
workspaces, revoking their auth tokens ~6 minutes after creation (one
sweep cycle past the 5-min grace).

External workspaces have NO local container by design — their agent runs
off-host. The "no live container" predicate the sweep uses to detect
wiped-volume orphans matches every external workspace unconditionally,
which was killing the only auth credential the off-host agent has.

Reproducer: create runtime=external workspace, paste the auth token into
molecule-mcp / curl, wait 5 minutes. Next request returns
`HTTP 401 — token may be revoked`. Platform log shows
`Orphan sweeper: revoking stale tokens for workspace <id> (no live
container; volume likely wiped)`.

Fix: add `AND w.runtime != 'external'` to the sweep's SELECT. The
existing test regexes (third-pass query expectations + the shared
expectStaleTokenSweepNoOp helper) are tightened to require the new
predicate, so a regression that drops it fails CI immediately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 00:49:37 -07:00
Hongming Wang 9753d58539 fix(build): register event_log in TOP_LEVEL_MODULES
The wheel-build drift gate caught it correctly: any new top-level
module under workspace/ must be listed in TOP_LEVEL_MODULES so its
`from event_log import …` statements get rewritten to
`from molecule_runtime.event_log import …` at package time.

Without this entry, the published wheel ships event_log.py un-rewritten
and crashes at runtime with ModuleNotFoundError on first heartbeat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 00:19:30 -07:00
Hongming Wang 0fc2531250 feat(workspace): event_log module + EventLogConfig (#119 PR-2)
Adds workspace/event_log.py with an in-memory EventLog backend and a
disabled no-op variant, plus EventLogConfig nested in
ObservabilityConfig (backend / ttl_seconds / max_entries).

The event log is the append-and-query buffer that the canvas Activity
tab and platform `/activity` endpoint will read in PR-3 of the #119
stack. Two backends ship in this PR:

  - InMemoryEventLog: bounded ring buffer with TTL eviction, monotonic
    ids that survive eviction so cursors don't break, thread-safe for
    concurrent appends from heartbeat + main loop + A2A executor.
  - DisabledEventLog: no-op for `backend: disabled` — opts the
    workspace out without crashing callers that propagate event ids.

Schema-only PR — no consumers wired yet. Wiring lands in PR-3.

Test coverage:
  - 34 new test_event_log.py tests (100% line coverage on event_log.py)
  - 9 new test_config.py tests for EventLogConfig parsing
  - Concurrency stress with 8 threads × 200 appends — verifies unique
    monotonic ids under contention
  - TTL + max_entries eviction with injected clock (no time.sleep)
  - Disabled backend contract pinned

Closes #207.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 00:17:12 -07:00
Hongming Wang 350495f032 Merge pull request #2547 from Molecule-AI/perf/cache-platform-inbound-secret
perf(wsauth): in-process cache for platform_inbound_secret reads
2026-05-03 07:11:38 +00:00
Hongming Wang 384edb4af0 Merge branch 'staging' into perf/cache-platform-inbound-secret 2026-05-03 00:08:43 -07:00
Hongming Wang b040171fa1 perf(wsauth): in-process cache for platform_inbound_secret reads
Heartbeats fire every 60s per workspace and were the dominant caller
of ReadPlatformInboundSecret — one DB SELECT each, purely to redeliver
the same value. For an N-workspace fleet that's N SELECTs/minute of
pure overhead, growing linearly with the fleet (#189).

This adds a sync.Map cache keyed by workspaceID with a 5-minute TTL:

- **Read-through**: cache miss → DB SELECT → populate → return.
- **Write-through**: every IssuePlatformInboundSecret call refreshes
  the cache with the new value before returning, so the lazy-heal mint
  path (readOrLazyHealInboundSecret) doesn't see a stale read of the
  value it just wrote.
- **TTL eviction**: 5 minutes — generous enough that the heartbeat
  hot path hits cache for ~5 reads in a row before re-validating, short
  enough that an out-of-band rotation (operator running
  `UPDATE workspaces SET platform_inbound_secret=...` directly)
  propagates within minutes without requiring a redeploy.
- **Absence not cached**: ErrNoInboundSecret skips the cache write so
  the lazy-heal recovery contract for the column-NULL case
  (readOrLazyHealInboundSecret in workspace_provision_shared.go) keeps
  working.

Memory footprint is bounded by the active workspace fleet (~200 bytes
per entry); deleted workspaces leave dead entries until process restart,
acceptable given workspace-deletion is operator-rare.

Why in-process instead of Redis: workspace-server runs as a single
Railway service today (per memory project_controlplane_ownership);
adding Redis for this single column read would be over-engineering.
The cache is a self-contained, Redis-free upgrade that keeps the same
semantic surface (read returns the latest secret) while collapsing
the heartbeat read storm. If the deployment ever fans out across
replicas, an operator-side rotation propagates per-replica TTL-bounded
without needing a shared write log.

Tests: 5 new cases covering cache hit within TTL, refresh after TTL
(simulating an operator rotation via SQL), write-through on Issue,
absence-not-cached, and Reset clearing all entries. The setupMock
helper in wsauth and setupTestDB helper in handlers both call
ResetInboundSecretCacheForTesting() at start + cleanup so write-through
state from one test doesn't shadow SELECT expectations in the next.
SetInboundSecretCacheNowForTesting() exposes a deterministic clock
override so the TTL test doesn't sleep.

Task: #189.
2026-05-03 00:04:38 -07:00
Hongming Wang c4f64a11a8 Merge pull request #2546 from Molecule-AI/fix/provisioner-repull-moving-tags
fix(provisioner): force re-pull of moving image tags on workspace start
2026-05-03 06:59:36 +00:00
Hongming Wang 552602e462 fix(provisioner): force re-pull of moving image tags on workspace start
Previously Start() only pulled when the image was missing locally
(imgErr != nil). Once a tenant's Docker daemon had `:latest` cached,
it stuck on that snapshot forever even after publish-runtime pushed
a newer image with the same tag — the same image-cache class that
sibling task #232 closed on the controlplane redeploy path.

Now Start() additionally re-pulls when the tag is "moving"
(`:latest`, no tag, `:staging`, `:main`, `:dev`, `:edge`, `:nightly`,
`:rolling`). Pinned tags (semver, sha-prefixed, date-stamped, build-id)
and digest-pinned references (`@sha256:...`) skip the pull because
their contents are by definition immutable.

The classifier (imageTagIsMoving) is deliberately conservative on the
"moving" side — only the well-known moving tags trip it. Misclassifying
a pinned tag as moving wastes bandwidth on every provision; misclassifying
moving as pinned silently bricks the fleet on stale snapshots, which
is exactly the bug class this fix closes.

Edge cases handled:
- Registry hostname with port (`localhost:5000/foo`) — the `:5000` is
  not mistaken for a tag.
- Digest pinning (`image@sha256:...`) — never re-pulled even if a
  moving-looking tag is also present.
- Legacy local-build tags (`workspace-template:hermes`) — treated as
  pinned (no registry to move from).

Test coverage: 22 cases across all classifier shapes. No changes to
the pull-failure path (still best-effort, ContainerCreate still
surfaces the actionable "image not found" error if the pull failed
and the cache is also empty).

Task: #215. Companion to #232.
2026-05-02 23:56:32 -07:00
Hongming Wang 29261cee3d Merge pull request #2537 from Molecule-AI/test/derive-provider-drift-gate
Test: AST drift gate for derive-provider.sh ↔ Go port
2026-05-03 06:54:22 +00:00
Hongming Wang dfeefb0acc fix(workspace-server): vendor upstream derive-provider.sh + close 12-prefix drift
The drift gate's monorepoRoot walk-up looked for workspace-configs-templates/
which is gitignored locally and doesn't exist in this repo at all (the
canonical script lives in molecule-ai-workspace-template-hermes). Test
failed on CI from day one with "could not find monorepo root".

Two layered fixes in one PR:

1. Vendor upstream derive-provider.sh as testdata/ + drop monorepoRoot.
   The vendored copy has a header pointing operators at the upstream
   source and a one-line cp command for refresh. Test now reads two
   files (vendored shell + workspace_provision.go) via package-relative
   paths — Go test sets cwd to the package dir, so this is hermetic
   without any walk-up gymnastics.

2. Update the case-statement regex to match upstream's renamed variable
   (${_HERMES_MODEL} since v0.12.0, the resolved value of
   HERMES_INFERENCE_MODEL with a HERMES_DEFAULT_MODEL legacy fallback).
   Regex now accepts either spelling so a future rename fails loudly
   on the parser-sanity check rather than silently returning empty.

Vendoring upstream surfaced real drift the gate was designed to catch:
upstream v0.12.0 added 12 provider prefixes that deriveProviderFromModelSlug
didn't handle (xai/grok, bedrock/aws, tencent/tencent-tokenhub, gmi,
qwen-oauth, lmstudio/lm-studio, minimax-oauth, alibaba-coding-plan,
google-gemini-cli, openai-codex, copilot-acp, copilot). Without these,
Save+Restart on a workspace using one of those prefixes would persist
LLM_PROVIDER="" and the next boot would fall back to derive-provider.sh's
runtime *=auto branch — losing the user's explicit choice on every restart.

Added all 12 case clauses + 16 new table-driven test cases (covering
both canonical and aliased forms). Drift gate now passes; future
upstream additions will fail loudly with a "DRIFT: ..." message
pointing the engineer at the missing case.

Task: #242
2026-05-02 23:51:23 -07:00
Hongming Wang 284012a768 test(workspace-server): AST drift gate for derive-provider.sh ↔ Go port
PR #2535 added a Go port of derive-provider.sh
(deriveProviderFromModelSlug) so workspace-server can persist
LLM_PROVIDER into workspace_secrets at provision time. This created
two sources of truth — if a future PR adds a provider prefix to one
without the other, the platform's persisted LLM_PROVIDER silently
disagrees with what the container's derive-provider.sh produces at
boot, with no test going red.

This adds a hermetic drift gate that:

  1. Parses workspace-configs-templates/hermes/scripts/derive-provider.sh
     with regex (handling both single-line `pat/*) PROVIDER="x" ;;`
     clauses and multi-line conditional clauses) to build a
     map[prefix]provider.
  2. Walks workspace_provision.go's AST with go/ast, finds
     deriveProviderFromModelSlug, and extracts every case-clause
     prefix → return-string-literal pair.
  3. Cross-checks both directions and accepts only the two documented
     divergences (nousresearch/* and openai/* both → "openrouter" at
     provision time because derive-provider.sh's runtime-env checks
     aren't loaded yet) via a hardcoded acceptedDivergences map.
  4. Fails with an actionable message that names both files and
     suggests the exact fix (add the case OR add to divergence list
     with a comment).

Pattern: behavior-based AST gate from PR #2367 / memory feedback —
pin the invariant by what the function maps, not by what it's named.
Stdlib-only (go/ast, go/parser, go/token, regexp); no network, no DB,
no docker — reads two monorepo files in-process.

A second sanity-check test pins anchor prefixes the regex must find,
so a future shell-syntax change can't silently produce an empty map
and trivially pass the main gate.

Closes task #242.
2026-05-02 23:51:23 -07:00
Hongming Wang a28f905c5a Merge pull request #2545 from Molecule-AI/fix/configtab-single-source-of-truth-MODEL
fix(canvas): ConfigTab is single source of truth for tier/provider/model
2026-05-03 06:40:38 +00:00
Hongming Wang bdd1d09dfb fix(canvas): tighten originalModel + pin store-flush failure-gating (review feedback)
PR #2545 self-review findings.

(1) originalModel was set from wsMetadataModel alone. On a hermes/pre-#240
workspace where MODEL_PROVIDER was never written but YAML has
runtime_config.model: "something", originalModel="" while the form
rendered "something" — handleSave's diff fired /model PUT on every
unrelated save (tier change → workspace auto-restart). Snapshot from
the actual rendered model in BOTH loadConfig branches so the diff
stays scoped to user-initiated changes.

(2) The store-flush test asserted the call happened but didn't pin
success-gating. A future refactor wrapping the PATCH in try/catch and
unconditionally calling updateNodeData would have shipped green and
left the badge lying about server-rejected writes. New test pins the
PATCH-rejects-no-flush invariant.

(3) Hermes-edge regression test for (1).

All 1214 canvas tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:37:52 -07:00
Hongming Wang 7f0c58d563 fix(canvas): ConfigTab is single source of truth for tier/provider/model
Three drift bugs in ConfigTab + ProviderModelSelector. Same root pattern:
the form's display, the diff baseline, and the canvas store all read or
write from different copies of the same data, so what the user sees and
what the runtime actually uses can diverge silently.

(1) currentModelId read runtime_config.model first; loadConfig overrode
only top-level config.model. With template YAML `runtime_config.model:
sonnet` and live MODEL_PROVIDER=`MiniMax-M2`, the form rendered
"Claude Code subscription / Claude Sonnet (OAuth)" while the container
env (and chat) used MiniMax-M2. Fix: loadConfig propagates
wsMetadataModel into BOTH places.

(2) handleSave's nextModel-vs-oldModel diff compared the form value to
the YAML default. After (1) mirrors wsMetadataModel into the form's
runtime_config.model for display, that diff was always non-zero on
no-op saves and would fire /model PUT — which auto-restarts. New
originalModel state tracks the loaded MODEL_PROVIDER and is the diff
baseline.

(3) handleSave PATCHed the workspace row but never pushed the same
fields into useCanvasStore.updateNodeData. User picked T3, hit Save &
Restart, DB updated to tier=3, header pill kept showing T2 until full
hydrate. Fix: mirror dbPatch into the store.

Bonus: ProviderModelSelector.handleProviderChange used to auto-default
the model to next.models[0] (alphabetically first) when switching
providers. User picked the MiniMax provider intending MiniMax-M2.7;
the form silently set MiniMax-M2 (first in the bucket) and the
workspace deployed with the wrong model. Now empty-default for
multi-model providers, force explicit pick — Save/Deploy already gate
on model.trim() === "".

Three new tests in ConfigTab.provider.test.tsx pin (1)/(2)/(3); two
existing ProviderModelSelector tests updated to reflect the no-silent-
default behaviour, with a new single-model-auto-pick test for the
0-vs-many boundary. 1212/1212 canvas tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:31:02 -07:00
Hongming Wang 5259ce3ea1 Merge pull request #2544 from Molecule-AI/fix/templates-yaml-log-and-coexistence-test
fix(workspace-server): log silent yaml.Unmarshal + coexistence test (#256, #257)
2026-05-03 06:04:51 +00:00
Hongming Wang 586d567a48 fix(workspace-server): log silent yaml.Unmarshal + coexistence test (#256, #257)
Two follow-ups from PR #2543's multi-model code review (audit #253).

1. **Log silent yaml.Unmarshal errors (#256).** When a malformed
   config.yaml made `yaml.Unmarshal(data, &raw)` fail, the affected
   template silently disappeared from /templates with no trace —
   operator could not distinguish "excluded due to parse error" from
   "never existed." That widened a real foot-gun once PR #2543 added
   structured top-level `providers:` (a string-shaped top-level
   `providers:` decoded into `[]providerRegistryEntry` would fail and
   drop the whole entry). Now logs `templates list: skip <id>:
   yaml.Unmarshal: <err>` and continues with the rest.

2. **Coexistence test (#257 part 1).** PR #2543 covered the structured
   registry and slug list in isolation. claude-code-default in
   production ships BOTH: top-level `providers:` (structured registry,
   2 entries) AND `runtime_config.providers:` (slug list, 3 entries).
   New `TestTemplatesList_BothProviderShapesCoexist` mirrors that
   layout, asserts both shapes surface independently with no
   cross-talk (e.g. a slug-only entry like `anthropic-api` does NOT
   synthesize a stub in the structured registry), and pins the JSON
   wire-shape for both fields side-by-side.

3. **`base_url: null` decoding assertion (#257 part 3).** Adds an
   explicit `got[0].BaseURL == ""` check in the existing
   `TestTemplatesList_SurfacesProviderRegistry` test, locking in the
   `string` (not `*string`) type. A future change to `*string` would
   surface as JSON `null` and break canvas's "no base_url = use
   provider defaults" branch — caught loudly by this assertion.

Tests: 11 TestTemplatesList_* now green, including the new
MalformedYAMLLogsAndSkips and BothProviderShapesCoexist.

The remaining piece of #257 — renaming `Providers []string` JSON tag
to `provider_slugs` — requires coordinated canvas updates across 4
files and is intentionally deferred to a separate PR (no canvas
churn while user is mid-test).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:01:59 -07:00
Hongming Wang 0acd9b3454 Merge pull request #2543 from Molecule-AI/fix/templates-structured-provider-registry-235
fix(workspace-server): surface structured provider registry on /templates (#235)
2026-05-03 05:45:49 +00:00
Hongming Wang 992a0c6860 fix(workspace-server): surface structured provider registry on /templates (#235)
Closes the contract drift caught by audit #253. Task #235 ("Server:
enrich /templates payload with structured providers") was marked
completed, but `templates.go` only ever emitted the
`runtime_config.providers []string` slug list — the structured
ProviderEntry shape (auth_env, model_prefixes, model_aliases, base_url)
the description promised was never plumbed.

Templates ship the structured registry under a TOP-LEVEL `providers:`
block (claude-code carries 6+ entries today; hermes still uses the
slug list). Both shapes coexist and are independent — surface them as
two separate fields:

  - `providers`           → existing []string slug list (unchanged)
  - `provider_registry`   → new []providerRegistryEntry (structured)

The canvas's ProviderModelSelector comment block already anticipates
this ("Templates that ship explicit vendor metadata (future) should
override the heuristic."). With this field in place, the canvas can
optionally drop its prefix-inference fallback for templates that ship
an explicit registry — separate PR. Today's change is purely additive
on the server side; no canvas change required.

Tests:
- TestTemplatesList_SurfacesProviderRegistry: order preservation +
  field plumbing on a claude-code-shaped fixture (oauth + minimax)
  + JSON wire-shape gate to catch struct-tag renames.
- TestTemplatesList_OmitsProviderRegistryWhenAbsent: omitempty so
  legacy templates (hermes, langgraph) don't emit `null` and break
  Array.isArray on the canvas side.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 22:42:42 -07:00
Hongming Wang 48bc518dfc Merge pull request #2541 from Molecule-AI/fix/workspace-server-universal-MODEL-env
fix(workspace-server): set universal MODEL env on every templated provision
2026-05-03 05:23:36 +00:00
Hongming Wang 2eea2b1315 Merge pull request #2540 from Molecule-AI/fix/wire-provider-model-selector
fix(canvas): wire ProviderModelSelector into MissingKeysModal + ConfigTab
2026-05-03 05:23:35 +00:00
Hongming Wang bd5f3428d5 Merge pull request #2539 from Molecule-AI/fix/preflight-explicit-empty-required-env
fix(runtime): explicit empty per-model required_env means "no auth"
2026-05-03 05:23:33 +00:00
Hongming Wang 8a86b66159 fix(workspace-server): set universal MODEL env on every templated provision
Bug B fix, server-side complement to molecule-runtime PR #2538.
The runtime PR taught `workspace/config.py` to honour
`MODEL_PROVIDER` over `runtime_config.model` from the template's
verbatim YAML. This PR is the upstream half: workspace-server's
`applyRuntimeModelEnv` now sets `MODEL=<picked>` for **every**
runtime, not just hermes (which got `HERMES_DEFAULT_MODEL` already).

Pre-fix: applyRuntimeModelEnv's per-runtime switch only emitted
HERMES_DEFAULT_MODEL for hermes; every other runtime got nothing,
so the adapter read its template's default model from
/configs/config.yaml. Surfaced 2026-05-02 — picking MiniMax-M2 in
canvas → workspace booted with model=sonnet (claude-code template
default) and demanded CLAUDE_CODE_OAUTH_TOKEN.

Post-fix: MODEL is set unconditionally before the per-runtime switch.
HERMES_DEFAULT_MODEL stays for backwards compat. Adapters opt in by
reading os.environ["MODEL"] in their executor (claude-code adapter
already does this since the same Bug B fix; see
workspace-configs-templates/claude-code-default/adapter.py).

Tests
=====
- `TestApplyRuntimeModelEnv_SetsUniversalMODELForAllRuntimes`:
  table-driven across claude-code/hermes/langgraph/crewai + empty-model
  fallback + MODEL_PROVIDER-secret-fallback path. Adding a new
  runtime = adding a row, not writing a new test.
- All 6 sub-cases pass + existing
  `TestWorkspaceCreate_FirstDeploy_UnknownModel_OnlyMintModelProvider`
  pin still green.

Why now
=======
This was authored alongside the runtime PR but stashed (not committed)
during a session-handoff cleanup. The molecule-runtime side shipped at
SHA 16ac895a and is live on PyPI as molecule-ai-workspace-runtime
0.1.84, but until the workspace-server side ships, the canvas-picked
MODEL env never reaches non-hermes adapters.

Caught by the systematic stash audit triggered by the user's
discovery that ProviderModelSelector had been similarly stashed.

Closes the workspace-server side of #246. Builds on merged #2538.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 22:10:51 -07:00
Hongming Wang 5cc02aa11c fix(canvas): wire ProviderModelSelector into MissingKeysModal + ConfigTab
The shared <ProviderModelSelector> component was authored on disk but
never landed — three deploy/configure surfaces still rendered the
legacy free-text "MODEL slug" input + provider-radio list. Tasks #239
and #243 closed at "component exists" rather than "user-visible
behavior changed", and the integration sat in a working-tree stash
that was never committed.

This PR is the missing integration:
- canvas/src/components/ProviderModelSelector.tsx (new, 509 lines):
  single-source-of-truth Provider→Model cascade. Builds a catalog
  from `template.models[].required_env` (groups by sorted+joined env
  names so two MiniMax models with the same auth land in one
  provider), exposes vendor detection helper + back-derivation. No
  per-template hardcoding — fully driven by the upstream payload.
- canvas/src/components/MissingKeysModal.tsx: replaces the inline
  `<input type="text">` + `<fieldset>` of provider radios with one
  `<ProviderModelSelector>`. Same external contract
  (`onKeysAdded(model)`), so callers in useTemplateDeploy don't move.
- canvas/src/components/tabs/ConfigTab.tsx: replaces ad-hoc Model
  text input + Provider radio with the same selector, fixing the
  display-vs-storage drift class that #190 first patched.

Tests
=====
- ProviderModelSelector.test.tsx (new, 269 lines): cascade behavior,
  vendor auto-snap, back-derivation from saved config.
- MissingKeysModal.cascade.test.tsx: rewritten to assert dropdown
  shape (was asserting the legacy text-input shape).
- ConfigTab.hermes.test.tsx + ConfigTab.provider.test.tsx: updated
  for the new selector shape.
- 1208/1208 canvas tests pass locally.

User-visible fix: clicking any deploy/configure surface from the
sidebar now shows the cascade UX (Provider dropdown first, Model
dropdown filtered) instead of the legacy free-text MODEL slug.

Closes the integration gap behind #239 + #243. Builds on merged
runtime PRs #2538 (universal MODEL_PROVIDER) + #32 + #38 (per-vendor
audit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 22:04:40 -07:00
Hongming Wang fd4b4e0723 test: pin null-required_env tolerance + drop unused MINIMAX env clear
Two self-review nits on the prior commit:
- Add test_per_model_required_env_null_treated_as_empty_no_auth — pins
  parser tolerance for YAML 'required_env:' (deserializes to None). The
  'or []' fallback handles it, but the behavior wasn't asserted, and a
  template author who writes 'required_env:' with no value (common YAML
  mistake) needs the no-auth path, not a confusing TypeError.
- Drop the MINIMAX_API_KEY delenv from the explicit-empty test — there's
  no MINIMAX in any required_env list of that scenario, so the cleanup
  was dead noise.

78/78 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 21:56:40 -07:00
Hongming Wang 3e5955f04f fix(runtime): explicit empty per-model required_env means "no auth"
Two follow-ups from the independent review of #2538.

preflight.py
============
Today: `if per_model_env: required_env = list(per_model_env)` falls
through on `[]`, so a template entry that says "this model needs no
auth" (`required_env: []` — Ollama, llamafile, self-hosted OpenAI-
compat, anything where the SDK doesn't surface a key) is silently
overridden by the top-level fallback list. The template author cannot
express a zero-auth model without lying about its env requirements.

Fix: key off `"required_env" in entry` (key presence, not truthiness).
Missing key still falls back to top-level — that path is unchanged
and preserves "many templates list name/description per model without
enumerating env vars when auth is identical across the family". Empty
list now wins outright. Comment updated to call out the distinction.

test_preflight.py
=================
Renamed `test_per_model_match_with_no_required_env_falls_back_to_top_level`
to `…_no_required_env_KEY_…` and tightened its docstring to reflect
that it's the missing-KEY case only. Added new
`test_per_model_explicit_empty_required_env_means_no_auth` to pin the
new explicit-empty semantic.

test_config.py
==============
New `test_runtime_config_model_env_wins_over_explicit_yaml`. Pins the
intentional precedence inversion shipped in #2538 with both
MODEL_PROVIDER and runtime_config.model in YAML set — MODEL_PROVIDER
wins. Without this pin a future refactor could quietly restore the
old YAML-wins order and re-introduce Bug B.

77/77 targeted tests pass locally.

Closes #250 (review follow-up). Builds on merged #2538.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 21:51:01 -07:00
Hongming Wang 16ac895a39 Merge pull request #2538 from Molecule-AI/fix/canvas-picked-model-universal
fix(runtime): canvas-picked model wins universally + per-model required_env
2026-05-03 04:44:06 +00:00
Hongming Wang 97ebd1910a fix(runtime): canvas-picked model wins universally + per-model required_env
Two surgical edits to the molecule-runtime workspace package that fix
Bug B (canvas-picked model silently dropped for templated workspaces)
and Bug D (preflight rejects valid auth for non-default models),
universally for every adapter.

Bug B — canvas-picked model dropped (config.py)
================================================
Before: load_config resolved runtime_config.model as
  runtime_raw.get("model") or model
which means a template's `runtime_config.model: sonnet` always wins
over the canvas-picked MODEL_PROVIDER env var. Surfaced 2026-05-02
during MiniMax E2E — picking MiniMax-M2.7 in canvas, server plumbed
MODEL_PROVIDER=MiniMax-M2.7 correctly, but the workspace booted with
sonnet because the template's verbatim config.yaml won.

After:
  os.environ.get("MODEL_PROVIDER") or runtime_raw.get("model") or model

Centralising in load_config means EVERY adapter (claude-code, hermes,
codex, langgraph, future ones) gets canvas-picked-model passthrough
for free — no per-adapter env-reading code required.

Bug D — preflight per-model required_env (preflight.py)
========================================================
Before: preflight read the top-level required_env list, which
declares the auth needed by the *default* model. A template like
claude-code-default declares CLAUDE_CODE_OAUTH_TOKEN at the top
level. When a user picked MiniMax instead and only set
MINIMAX_API_KEY, preflight rejected the workspace with
"missing CLAUDE_CODE_OAUTH_TOKEN" and the workspace crash-looped
despite the user having satisfied the picked model's actual auth.

After: when runtime_config.models[] declares per-entry required_env,
preflight matches the picked model id (case-insensitive) and uses
that entry's required_env outright instead of the top-level list.
REPLACE semantics, not union — different models have *different*
auth paths (OAuth vs API key vs third-party provider key); unioning
would re-introduce the very crash-loop this fix closes.

Surface enabling both fixes (config.py)
========================================
RuntimeConfig now carries `models: list[dict]` so the canvas Model
dropdown source flows through to preflight without forcing the
parser schema to grow. Malformed entries are silently dropped to
match the rest of the lenient parser.

Tests
=====
- workspace/tests/test_preflight.py: 9 new tests covering the
  per-model lookup (case-insensitive, REPLACE not union, fallback
  to top-level when no models[] or no match, multi-entry, malformed
  entries dropped, etc.)
- workspace/tests/test_config.py: existing 48 pass; field
  initialisation already covered by parser tests.
- All 75 targeted tests pass locally; CI runs the full suite
  including coverage gate.

Closes part of #246. Sibling PR opens against
molecule-ai-workspace-template-claude-code for per-template
defensive fixes + boot debug logging.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 21:36:24 -07:00
Hongming Wang d95877c88d Merge pull request #2535 from Molecule-AI/fix/hermes-first-deploy-model-provider-persistence
Persist canvas-selected model+provider on first deploy
2026-05-03 02:25:03 +00:00
Hongming Wang 1b75fddb8e Merge pull request #2536 from Molecule-AI/chore/prune-manifest-to-4-runtimes
chore(manifest): prune to 4 actively-supported runtimes
2026-05-03 02:24:50 +00:00
Hongming Wang f33e59ba8c chore(manifest): prune to 4 actively-supported runtimes
Deletes the 5 unsupported workspace_templates from manifest.json
(langgraph, crewai, autogen, deepagents, gemini-cli). The runtime
matrix is now claude-code / hermes / openclaw / codex — the four
templates with shipping images, working A2A integration, and active
CI publish-image cascades.

Mirrors the prune in:
  - workspace-server/internal/handlers/runtime_registry.go
    (fallbackRuntimes for dev/test contexts that boot without the
    manifest mounted)
  - workspace-server/internal/handlers/workspace_provision.go
    (sanitizeRuntime: empty/unknown → "claude-code", was "langgraph";
    removes the langgraph/deepagents-specific runtime_config skip
    branch — they're no longer supported, so the block is dead)
  - tests for both: rename TestEnsureDefaultConfig_LangGraph →
    _Hermes, TestEnsureDefaultConfig_EmptyRuntimeDefaultsToLangGraph
    → _ClaudeCode, drop TestEnsureDefaultConfig_DeepAgents,
    update TestSanitizeRuntime_Allowlist + the two
    TestResolveRestartTemplate_* cases that pinned langgraph-default
    as the safe-default name

Why this is safe: production reads manifest.json at boot and uses it
as the authoritative allowlist; the 5 removed runtimes have not
shipped working images for ≥1 release cycle. Any provision request
naming one will now coerce to claude-code (with a log line) instead
of returning a runtime that has no functioning template repo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:21:47 -07:00
Hongming Wang a1de71dd53 fix(workspace-server): persist canvas-selected model + provider on first deploy
When the canvas POSTs /workspaces with {model: "minimax/MiniMax-M2.7"},
the model slug was never written to workspace_secrets. The workspace
booted hermes once with HERMES_DEFAULT_MODEL set from payload.Model, but
on every subsequent restart applyRuntimeModelEnv's fallback chain found
nothing in envVars["MODEL_PROVIDER"] (because nothing wrote it) and
hermes silently fell through to the template default
(nousresearch/hermes-4-70b) — wrong provider keys → hermes gateway
401'd → /health poll failed → molecule-runtime never registered →
"container started but never called /registry/register".

Worse, LLM_PROVIDER was never written either (the canvas doesn't send
provider), so CP user-data wrote no provider: field to
/configs/config.yaml and derive-provider.sh fell through to PROVIDER=auto
on every custom-prefix slug.

Fix: after the workspace row commits, persist MODEL_PROVIDER (verbatim
slug) and LLM_PROVIDER (derived from slug prefix) to workspace_secrets.
LLM_PROVIDER is gating-only — derive-provider.sh remains the runtime
source of truth and can override at boot. Reuses extracted
setModelSecret / setProviderSecret helpers (refactored out of SetModel /
SetProvider gin handlers) so SQL stays in one place.

Symptom: failed-workspace 95ed3ff2 (2026-05-02).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:21:01 -07:00
Hongming Wang ec63597545 Merge pull request #2527 from Molecule-AI/dependabot/npm_and_yarn/canvas/postcss-8.5.13
chore(deps)(deps-dev): bump postcss from 8.5.12 to 8.5.13 in /canvas
2026-05-03 02:08:31 +00:00
Hongming Wang 114c941d27 Merge pull request #2534 from Molecule-AI/fix/cascade-deploy-modal
fix(deploy-modal): snap provider radio when model resolves to a provider
2026-05-03 02:04:04 +00:00
Hongming Wang 9eb22333a5 fix(deploy-modal): snap provider radio when model resolves to a provider
The TemplatePalette deploy modal (MissingKeysModal → ProviderPickerModal)
let the model field and provider radio drift apart. When a hermes
template defaulted the model to "MiniMax-M2.7-highspeed" but the radio
defaulted to providers[0] (Anthropic), the env-var input below asked
for ANTHROPIC_API_KEY. A user pasting their MINIMAX_API_KEY there (or
just dismissing the dialog) ended up with a workspace whose
runtime_config.model=MiniMax + ANTHROPIC_API_KEY env — the hermes
adapter then crashed during boot before /registry/register, surfacing
as WORKSPACE_PROVISION_FAILED 12 minutes later.

Caught 2026-05-02 on hongming/Hermes Agent (workspace 95ed3ff2-…
ended with: "container started but never called /registry/register").

Sibling of the ConfigTab cascade fix in PR #2516 (task #236) — same
pattern, different surface. Plumbs the template's full ModelSpec[]
(with required_env per model) into the picker. When the typed model
matches a registry entry, snap the radio so the env-var fields
underneath match what the model actually needs.

Free-text models (typed slug not in the registry) and models with no
required_env (local/self-hosted endpoints) leave the radio alone — the
user can still pick a provider manually. Backwards-compat: callers
that don't pass `models` get the pre-cascade behavior, pinned by a
regression test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:01:13 -07:00
dependabot[bot] 993cc4d467 chore(deps)(deps-dev): bump postcss from 8.5.12 to 8.5.13 in /canvas
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.12 to 8.5.13.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.12...8.5.13)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.13
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-03 01:37:17 +00:00
Hongming Wang fd5fe34f69 Merge pull request #2523 from Molecule-AI/dependabot/github_actions/actions/github-script-9.0.0
chore(deps)(deps): bump actions/github-script from 7.1.0 to 9.0.0
2026-05-03 01:37:00 +00:00
Hongming Wang 0d8b0c37a6 Merge pull request #2521 from Molecule-AI/dependabot/github_actions/actions/checkout-6
chore(deps)(deps): bump actions/checkout from 4 to 6
2026-05-03 01:36:57 +00:00
dependabot[bot] 572050f1ed chore(deps)(deps): update starlette requirement in /workspace
Updates the requirements on [starlette](https://github.com/Kludex/starlette) to permit the latest version.
- [Release notes](https://github.com/Kludex/starlette/releases)
- [Changelog](https://github.com/Kludex/starlette/blob/main/docs/release-notes.md)
- [Commits](https://github.com/Kludex/starlette/compare/0.38.0...1.0.0)

---
updated-dependencies:
- dependency-name: starlette
  dependency-version: 1.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-03 01:36:45 +00:00
Hongming Wang 252e126207 Merge pull request #2522 from Molecule-AI/dependabot/github_actions/docker/setup-buildx-action-4.0.0
chore(deps)(deps): bump docker/setup-buildx-action from 3.12.0 to 4.0.0
2026-05-03 01:27:03 +00:00
Hongming Wang e84df73e96 Merge pull request #2528 from Molecule-AI/dependabot/github_actions/docker/build-push-action-7.1.0
chore(deps)(deps): bump docker/build-push-action from 6.19.2 to 7.1.0
2026-05-03 01:27:00 +00:00
Hongming Wang 7db4129877 Merge pull request #2525 from Molecule-AI/dependabot/github_actions/imjasonh/setup-crane-0.5
chore(deps)(deps): bump imjasonh/setup-crane from 0.4 to 0.5
2026-05-03 01:25:37 +00:00
Hongming Wang 9c03b1084f Merge pull request #2524 from Molecule-AI/dependabot/pip/workspace/opentelemetry-api-gte-1.41.1
chore(deps)(deps): update opentelemetry-api requirement from >=1.24.0 to >=1.41.1 in /workspace
2026-05-03 01:25:34 +00:00
Hongming Wang 476dbc83a3 Merge pull request #2530 from Molecule-AI/dependabot/pip/workspace/opentelemetry-exporter-otlp-proto-http-gte-1.41.1
chore(deps)(deps): update opentelemetry-exporter-otlp-proto-http requirement from >=1.24.0 to >=1.41.1 in /workspace
2026-05-03 01:25:31 +00:00
Hongming Wang cf118df2af Merge pull request #2520 from Molecule-AI/dependabot/go_modules/workspace-server/github.com/creack/pty-1.1.24
chore(deps)(deps): bump github.com/creack/pty from 1.1.18 to 1.1.24 in /workspace-server
2026-05-03 01:25:28 +00:00
Hongming Wang 8dc07b46dd Merge pull request #2526 from Molecule-AI/dependabot/pip/workspace/python-multipart-gte-0.0.27
chore(deps)(deps): update python-multipart requirement from >=0.0.18 to >=0.0.27 in /workspace
2026-05-03 01:25:25 +00:00
Hongming Wang 495cc38dd9 Merge pull request #2531 from Molecule-AI/dependabot/pip/workspace/pyyaml-gte-6.0.3
chore(deps)(deps): update pyyaml requirement from >=6.0 to >=6.0.3 in /workspace
2026-05-03 01:25:20 +00:00
Hongming Wang ecfb160b0d Merge pull request #2532 from Molecule-AI/dependabot/npm_and_yarn/canvas/jsdom-29.1.1
chore(deps)(deps-dev): bump jsdom from 29.1.0 to 29.1.1 in /canvas
2026-05-03 01:25:17 +00:00
dependabot[bot] dfc1f6d455 chore(deps)(deps): update pyyaml requirement in /workspace
Updates the requirements on [pyyaml](https://github.com/yaml/pyyaml) to permit the latest version.
- [Release notes](https://github.com/yaml/pyyaml/releases)
- [Changelog](https://github.com/yaml/pyyaml/blob/6.0.3/CHANGES)
- [Commits](https://github.com/yaml/pyyaml/compare/6.0...6.0.3)

---
updated-dependencies:
- dependency-name: pyyaml
  dependency-version: 6.0.3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-02 19:23:25 +00:00
dependabot[bot] f61750808e chore(deps)(deps-dev): bump jsdom from 29.1.0 to 29.1.1 in /canvas
Bumps [jsdom](https://github.com/jsdom/jsdom) from 29.1.0 to 29.1.1.
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Commits](https://github.com/jsdom/jsdom/compare/v29.1.0...v29.1.1)

---
updated-dependencies:
- dependency-name: jsdom
  dependency-version: 29.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-02 19:23:25 +00:00
dependabot[bot] 0e0550c640 chore(deps)(deps): update opentelemetry-exporter-otlp-proto-http requirement
Updates the requirements on [opentelemetry-exporter-otlp-proto-http](https://github.com/open-telemetry/opentelemetry-python) to permit the latest version.
- [Release notes](https://github.com/open-telemetry/opentelemetry-python/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-python/blob/v1.41.1/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-python/compare/v1.24.0...v1.41.1)

---
updated-dependencies:
- dependency-name: opentelemetry-exporter-otlp-proto-http
  dependency-version: 1.41.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-02 19:23:21 +00:00
dependabot[bot] c46db97ac6 chore(deps)(deps): bump docker/build-push-action from 6.19.2 to 7.1.0
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.19.2 to 7.1.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/10e90e3645eae34f1e60eeb005ba3a3d33f178e8...bcafcacb16a39f128d818304e6c9c0c18556b85f)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-02 19:23:17 +00:00
dependabot[bot] 1d99b3b8ae chore(deps)(deps): update python-multipart requirement in /workspace
Updates the requirements on [python-multipart](https://github.com/Kludex/python-multipart) to permit the latest version.
- [Release notes](https://github.com/Kludex/python-multipart/releases)
- [Changelog](https://github.com/Kludex/python-multipart/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Kludex/python-multipart/compare/0.0.18...0.0.27)

---
updated-dependencies:
- dependency-name: python-multipart
  dependency-version: 0.0.27
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-02 19:23:15 +00:00
dependabot[bot] 6c6c6eb1e8 chore(deps)(deps): bump imjasonh/setup-crane from 0.4 to 0.5
Bumps [imjasonh/setup-crane](https://github.com/imjasonh/setup-crane) from 0.4 to 0.5.
- [Release notes](https://github.com/imjasonh/setup-crane/releases)
- [Commits](https://github.com/imjasonh/setup-crane/compare/31b88efe9de28ae0ffa220711af4b60be9435f6e...6da1ae018866400525525ce74ff892880c099987)

---
updated-dependencies:
- dependency-name: imjasonh/setup-crane
  dependency-version: '0.5'
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-02 19:23:13 +00:00
dependabot[bot] 8072f00b2f chore(deps)(deps): update opentelemetry-api requirement in /workspace
Updates the requirements on [opentelemetry-api](https://github.com/open-telemetry/opentelemetry-python) to permit the latest version.
- [Release notes](https://github.com/open-telemetry/opentelemetry-python/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-python/blob/v1.41.1/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-python/compare/v1.24.0...v1.41.1)

---
updated-dependencies:
- dependency-name: opentelemetry-api
  dependency-version: 1.41.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-02 19:23:11 +00:00
dependabot[bot] e1f7d49575 chore(deps)(deps): bump actions/github-script from 7.1.0 to 9.0.0
Bumps [actions/github-script](https://github.com/actions/github-script) from 7.1.0 to 9.0.0.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/f28e40c7f34bde8b3046d885e986cb6290c5673b...3a2844b7e9c422d3c10d287c895573f7108da1b3)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: 9.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-02 19:23:09 +00:00
dependabot[bot] ab7ac2e103 chore(deps)(deps): bump docker/setup-buildx-action from 3.12.0 to 4.0.0
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.12.0 to 4.0.0.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/8d2750c68a42422c14e847fe6c8ac0403b4cbd6f...4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-02 19:23:05 +00:00
dependabot[bot] 3598eb41d1 chore(deps)(deps): bump actions/checkout from 4 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-02 19:23:01 +00:00
dependabot[bot] 82d0655fe9 chore(deps)(deps): bump github.com/creack/pty in /workspace-server
Bumps [github.com/creack/pty](https://github.com/creack/pty) from 1.1.18 to 1.1.24.
- [Release notes](https://github.com/creack/pty/releases)
- [Commits](https://github.com/creack/pty/compare/v1.1.18...v1.1.24)

---
updated-dependencies:
- dependency-name: github.com/creack/pty
  dependency-version: 1.1.24
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-02 19:22:48 +00:00
Hongming Wang ea967d5787 Merge pull request #2518 from Molecule-AI/docs/hermes-plugin-status-update
docs(integrations): hermes plugin path status post-PR #32 merge
2026-05-02 16:01:47 +00:00
Hongming Wang 2dd5684e73 docs(integrations): update hermes plugin path status to post-merge
PR #32 (workspace template) merged 2026-05-02; image rebuild
succeeded. Plugin baked in. Local full-chain E2E green; caught + fixed
a real KeyError in upstream hermes_cli/tools_config.py. Upstream PR
#18775 still OPEN/CONFLICTING — not on critical path.

Also rewrites hermes-platform-plugins-upstream-pr.md to reflect the
final landing shape (existing hermes_cli/plugins.py, not a new
plugins/platforms/ system).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 04:42:00 -07:00
Hongming Wang 2552779d97 Merge pull request #2517 from Molecule-AI/test/all-runtimes-a2a-e2e-harness
test(e2e): unified A2A round-trip parity harness across all 4 runtimes
2026-05-02 11:40:14 +00:00
Hongming Wang d88c160e56 test(e2e): wire SaaS auth headers (TENANT_ADMIN_TOKEN + TENANT_ORG_ID)
The harness needs Authorization + X-Molecule-Org-Id (per-tenant, NOT
CP_ADMIN_API_TOKEN) when targeting *.moleculesai.app subdomains.
Existing single-Origin-header form silent-failed with 404 against
staging tenants since the SaaS edge WAF rewrites unauthenticated
/workspaces calls to Next.js (per
reference_saas_waf_origin_header.md).

Switch to a headers array so multiple -H flags compose cleanly with
curl arg-quoting, and document the env var contract at the top of
the script.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 04:36:23 -07:00
Hongming Wang 5aaac7d2d9 test(e2e): unified A2A round-trip parity harness across all 4 runtimes
Adds two scripts:

  scripts/test-all-runtimes-a2a-e2e.sh
    Provisions one workspace per runtime (claude-code, hermes, codex,
    openclaw), sets provider keys, waits online, sends two A2A messages
    per workspace. First message validates round-trip; second message
    validates session continuity. Cleans up via trap on EXIT.

  scripts/test-hermes-plugin-e2e.sh
    Hermes-only variant focused on the plugin /a2a/inbound path.
    Proof-point: session continuity between turns (the plugin path's
    deliverable; old chat-completions path lost context per turn).

Both honor SKIP_<runtime> env vars for incremental testing and tolerate
the SaaS edge WAF Origin header requirement (per
reference_saas_waf_origin_header.md).

Run:
  PLATFORM=https://demo-tenant.staging.moleculesai.app \\
      ./scripts/test-all-runtimes-a2a-e2e.sh

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 04:36:23 -07:00
Hongming Wang 15dd1f26c3 Merge pull request #2513 from Molecule-AI/auto-sync/main-35cb6ba0
chore: sync main → staging (auto, merge 35cb6ba0)
2026-05-02 10:53:27 +00:00
Hongming Wang 8083fd8b7d Merge branch 'staging' into auto-sync/main-35cb6ba0 2026-05-02 03:39:00 -07:00
Hongming Wang 1f77f41a80 Merge pull request #2514 from Molecule-AI/fix/honest-v1-tolerance-comments
docs(a2a): correct misleading v1-tolerance comments
2026-05-02 09:46:33 +00:00
Hongming Wang 119518a612 Merge pull request #2515 from Molecule-AI/fix/sweep-cf-tunnels-parallelize-deletes
fix(sweep-cf-tunnels): parallelize deletes + raise workflow timeout
2026-05-02 09:38:31 +00:00
Hongming Wang 8bf29b7d0e fix(sweep-cf-tunnels): parallelize deletes + raise workflow timeout
The hourly Sweep stale Cloudflare Tunnels job got cancelled mid-cleanup
on 2026-05-02 (run 25248788312, killed at 5min after deleting 424/672
stale tunnels). A second manual dispatch finished the remaining 254
fine, so the immediate backlog cleared, but two underlying bugs would
re-trip on the next big cleanup.

Bug 1: serial delete loop. The execute branch was a `while read; do
curl -X DELETE; done` pipeline at ~0.7s/tunnel — fine for the
steady-state cleanup of a handful, but a 600+ backlog needs ~7-8min.
This commit fans out to $SWEEP_CONCURRENCY (default 8) workers via
`xargs -P 8 -L 1 -I {} bash -c '...' _ {} < "$DELETE_PLAN"`. With 8x
parallelism the same 600+ list drains in ~60s. Notes:

  - We use stdin (`<`) not GNU's `xargs -a FILE` so the script stays
    portable to BSD xargs (matters for local-runner testing on macOS).
  - We pass ONLY the tunnel id on argv. xargs tokenizes on whitespace
    by default; tab-separating id+name on argv risks mangling. The
    name is kept in a side-channel id->name map ($NAME_MAP) and looked
    up by the worker only on failure, for FAIL_LOG readability.
  - Workers print exactly `OK` or `FAIL` on stdout; tally with
    `grep -c '^OK$' / '^FAIL$'`.
  - On non-zero FAILED, log the first 20 lines of $FAIL_LOG as
    "Failure detail (first 20):" — same diagnostic surface as before
    but consolidated so we don't spam logs on a flaky CF API.

Bug 2: workflow's 5-min cap was set as a hangs-detector but turned out
to be a real-job-too-slow detector. Raised to 30 min — generous
headroom for the ~60s steady-state run while still surfacing genuine
hangs (and in line with the sweep-cf-orphans companion job).

Bug 3 (drive-by): the existing trap was `trap 'rm -rf "$PAGES_DIR"'
EXIT`, which would have been silently overwritten by any later trap
registration. Replaced with a single `cleanup()` function that wipes
PAGES_DIR + all four new tempfiles (DELETE_PLAN, NAME_MAP, FAIL_LOG,
RESULT_LOG), called once via `trap cleanup EXIT`.

Verification:
  - bash -n scripts/ops/sweep-cf-tunnels.sh: clean
  - shellcheck -S warning scripts/ops/sweep-cf-tunnels.sh: clean
  - python3 yaml.safe_load on the workflow: clean
  - Synthetic 30-line delete plan with every 7th id sentinel'd to
    return {"success":false}: TEST PASS, DELETED=26 FAILED=4, FAIL_LOG
    side-channel name lookup verified.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 02:35:46 -07:00
Hongming Wang fc33cf1131 docs(a2a): correct misleading v1-tolerance comments
Follow-up to PR #2509/#2510. The defensive v1-detection branches in
extract_attached_files (Python) and extractFilesFromTask (TypeScript)
were merged with comments claiming they fix a "v0→v1 silent-drop"
bug that surfaced as the 2026-05-01 hongming "no text content"
incident. Live test disproved that hypothesis: a2a-sdk's JSON-RPC
layer validates inbound requests against the v0 Pydantic union, so
v1 shapes are rejected at the request boundary — the v1 detection
branch is unreachable on the JSON-RPC ingress path. The actual root
cause of the hongming incident was the missing /workspace chown
fixed by CP PR #381 + test #382.

Update the comments to honestly describe these branches as
defensive future-proofing (kept against an eventual SDK schema
migration or in-process callers that construct Parts directly from
protobuf), not as fixes for an observed bug. Also trims
ChatTab.tsx's outbound-shape comment block from ~21 lines to a
3-line pointer to the SDK union.

Comment-only change. No behavior change. 86 workspace tests + 91
canvas tests still pass.
2026-05-02 02:33:00 -07:00
github-actions[bot] 03c1cbf12b chore: sync main → staging (auto) 2026-05-02 09:27:17 +00:00
Hongming Wang 35cb6ba089 Merge pull request #2512 from Molecule-AI/feat/register-codex-runtime
feat: register codex runtime + runtime native-MCP design docs
2026-05-02 02:26:56 -07:00
Hongming Wang ce0188d5b4 Merge pull request #2499 from Molecule-AI/auto-sync/main-e7375348
chore: sync main → staging (auto, ff to e7375348)
2026-05-02 09:22:51 +00:00
hongming 7224276de0 feat: register codex runtime + runtime native-MCP design docs
Adds the OpenAI Codex CLI as a Molecule workspace runtime and lands
the design docs that drove the runtime native-MCP push parity work
across claude-code, hermes, openclaw, and codex.

manifest.json:
- Adds `codex` workspace_template entry pointing at the new
  Molecule-AI/molecule-ai-workspace-template-codex repo (initial
  commit landed there in parallel; 14 files / 1411 LOC). The
  workspace-server runtime registry already had `codex` in its
  fallback set — this entry makes it manifest-reachable in prod.

docs/integrations/:
- runtime-native-mcp-status.md — index across all four runtime streams
- codex-app-server-adapter-design.md — full design including v2 RPC
  sequence, executor skeleton, schema-vs-runtime drift findings
  (real codex 0.72 returns thread.id, schema says thread.threadId)
- hermes-platform-plugins-upstream-pr.md — pre-submission draft of
  the hermes-agent upstream PR

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 02:21:11 -07:00
Hongming Wang 3d7b4b70ff Merge pull request #2511 from Molecule-AI/fix/redeploy-tolerate-e2e-teardown-race
fix(redeploy-staging): tolerate e2e-* teardown race in fleet HTTP 500
2026-05-02 09:19:45 +00:00
Hongming Wang 6e0eb2ddc9 fix(redeploy-staging): tolerate e2e-* teardown race in fleet HTTP 500
Recurring failure pattern in redeploy-tenants-on-staging:

  ##[error]redeploy-fleet returned HTTP 500
  ##[error]Process completed with exit code 1.

with the per-tenant breakdown in the response body showing the failures
were on ephemeral e2e-* tenants (saas/canvas/ext) whose parent E2E run
torn them down mid-redeploy — SSM exit=2 because the EC2 was already
terminating, or healthz timeout because the CF tunnel was already gone.
The actual operator-facing tenants (dryrun-98407, demo-prep, etc) all
rolled fine in the same call.

This shape repeats every staging push that overlaps an active E2E run.
The downstream `Verify each staging tenant /buildinfo matches published
SHA` step ALREADY distinguishes STALE vs UNREACHABLE for exactly this
reason (per #2402); only the top-level `if HTTP_CODE != 200; exit 1`
gate misclassifies the race.

Filter: HTTP 500 + every failed slug matches `^e2e-` → soft-warn and
fall through to verify. Any non-e2e-* failure or non-500 HTTP remains
a hard fail, with the failed non-e2e slugs surfaced in the error so
the operator doesn't have to dig the response body out of CI.

Verified the gate logic with 6 synthetic CP responses (happy / e2e-only
race / mixed real+e2e fail / non-200 / 200+ok=false / all-real-fail) —
all behave correctly.

prod's redeploy-tenants-on-main is intentionally NOT touched: prod CP
serves no e2e-* tenants, so the race can't occur there and the strict
gate is the right behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 02:17:36 -07:00
Hongming Wang 1ce9b7f716 Merge pull request #2510 from Molecule-AI/fix/revert-canvas-v1-outbound
fix(canvas): revert v1 outbound file part shape — JSON-RPC layer rejects it
2026-05-02 08:43:53 +00:00
Hongming Wang 3ce7c11a13 fix(canvas): revert v1 outbound file part shape
The previous PR (#2509) flipped canvas outbound file parts to the v1
flat shape `{url, filename, mediaType}` based on a hypothesis that
a2a-sdk's JSON-RPC parser silently dropped v0 `{kind:"file", file:{...}}`
shapes. Live test shows the opposite: a2a-sdk's JSON-RPC layer
validates against the v0 Pydantic discriminated union (TextPart |
FilePart | DataPart), so v1 flat shape is rejected with:

    Invalid Request:
      params.message.parts.0.TextPart.text — Field required
      params.message.parts.0.FilePart.file — Field required
      params.message.parts.0.DataPart.data — Field required

The actual root cause of the user-visible "Error: message contained
no text content" was the missing `/workspace` chown (CP PR #381 +
test pin #382), not a wire-shape mismatch. Verified end-to-end by
sending a v0 image-only message after PR #381 + workspace re-provision
— agent receives the file, reads its bytes, and replies normally.

Reverting only the canvas outbound shape. Defensive v1-tolerance
stays in:
  - workspace/executor_helpers.py — extract_attached_files still
    accepts v1 protobuf parts in case a future client emits them or
    a future SDK release flips internal representation. Harmless on
    the v0 hot path.
  - canvas/message-parser.ts — extractFilesFromTask still tolerates
    v1 shape on incoming agent responses. Some agents may emit v1
    when their internal serializer round-trips through protobuf.

Tests stay green (91 canvas, 86 workspace).
2026-05-02 01:31:56 -07:00
Hongming Wang bf83af0960 Merge pull request #2509 from Molecule-AI/fix/a2a-v1-file-part-shape
fix(a2a): send v1 file Part shape; tolerate v1 server-side
2026-05-02 08:13:52 +00:00
Hongming Wang 02a8841402 fix(a2a): send v1 file Part shape; tolerate v1 server-side
Image-only chats surface "Error: message contained no text content"
because canvas posts v0 `{kind:"file", file:{uri,name,mimeType}}` shapes
that the workspace runtime's a2a-sdk v1 protobuf parser silently drops:
v1 `Part` has fields `[text, raw, url, data, metadata, filename,
media_type]` and `ignore_unknown_fields=True` discards `kind`+`file`,
producing a fully-empty Part. With no text and no extracted file
attachments, the executor's "no text content" guard fires.

Three coordinated changes close the gap:

1. canvas/ChatTab.tsx — outbound file parts now carry the v1 flat
   shape `{url, filename, mediaType}` so the v1 protobuf parser
   populates Part fields instead of dropping them.
2. workspace/executor_helpers.py — extract_attached_files learns the
   v1 detection branch (non-empty `part.url` + `filename` +
   `media_type`) alongside the existing v0 RootModel and flat-file
   shapes. Defends every runtime that mounts the OSS wheel against
   the same drop, including any pre-fix client still on the wire.
3. canvas/message-parser.ts — extractFilesFromTask tolerates the v1
   shape on incoming agent responses too, so file chips render in
   chat history regardless of which Part shape the runtime emits.

Test pins:
- workspace/tests/test_executor_helpers.py:
  + v1 protobuf shape extraction
  + empty-Part defense (v0→v1 silent-drop fall-through returns [])
- canvas message-parser test:
  + v1 protobuf flat parts
  + filename fallback to URL basename for v1
2026-05-02 00:58:05 -07:00
Hongming Wang b36eed97f6 Merge pull request #2508 from Molecule-AI/fix/sweep-cf-tunnels-arg-too-long
fix(sweep-cf-tunnels): buffer pages to disk to avoid argv ARG_MAX
2026-05-02 07:45:01 +00:00
Hongming Wang a117a60eed fix(sweep-cf-tunnels): buffer pages to disk to avoid argv ARG_MAX
The page-merge loop passed the entire accumulating tunnel JSON to
python3 -c via argv on every iteration. On a busy account (verified
2026-05-02: 672 tunnels, 14 pages on Hongmingwangrabbit account) this
exceeds the GH Ubuntu runner's combined argv+envp limit (~128 KB) and
dies with `python3: Argument list too long` at exit 126 — the workflow
has been silently failing this way since the very first run that hit a
real account, masked earlier by a missing-CF_ACCOUNT_ID secret check.

Buffer each page response to a file under a temp dir, merge from disk
at the end. Also bumps the page cap from 20 to 40 (1000 → 2000 tunnel
ceiling) so the existing soft-cap warning has headroom; the disk-merge
shape is O(n) in tunnel count rather than the previous O(n^2) so the
larger ceiling is cheap.

Verified locally against the live account (672 tunnels): script now
runs cleanly to the existing MAX_DELETE_PCT safety gate, which trips
at 99% > 90% as designed and surfaces the actual orphan backlog for
operator-driven cleanup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:42:25 -07:00
Hongming Wang cdbf54beed Merge pull request #2507 from Molecule-AI/fix/canary-prompt-explicit-echo
fix(canary): reframe smoke prompt to give GPT-4o explicit permission to echo
2026-05-02 06:55:39 +00:00
Hongming Wang fa9e29f2f5 fix(canary): reframe smoke prompt to give GPT-4o explicit permission to echo
Canary started flaking 2026-05-01 22:11 with model-refusal replies:
  - "I'm unable to do that."
  - "I'm unable to fulfill that request. Can I assist you with anything else?"
  - "I'm unable to reply with responses that don't allow me to fulfill tasks…"
3 fails / 10 recent runs ≈ 30% flake.

Trigger: 2026-04-30's Platform Capabilities preamble (#2332) added the
directive "Use them proactively" to the top of every system prompt.
Combined with the heavy A2A + HMA tool docs further down, the model
reads the contrived bare-echo prompt ("Reply with exactly: PONG") as
out-of-role and intermittently refuses.

Real user prompts don't hit this — only the synthetic smoke prompt does,
so the right fix is in the canary's prompt phrasing, not the platform's
system prompt (which is correctly priming agents toward tool use). New
phrasing explicitly tells the model "this is a smoke test" and "no
tools or memory are needed" so it has permission to comply.

Also updates the child workspace's CHILD_PONG prompt with the same
framing — same failure mode would have hit it once full-mode runs again.

No code change to system prompt, no test infra change. Just two prompt
strings + a load-bearing comment so future readers don't trim back to
the brittle phrasing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:53:24 -07:00
Hongming Wang 12807962d2 Merge pull request #2506 from Molecule-AI/ci/secret-scan-required-and-precommit
secret-scan: align local pre-commit + extend drift lint (closes #1569 root)
2026-05-02 06:52:28 +00:00
Hongming Wang 0d25922f91 Merge branch 'staging' into ci/secret-scan-required-and-precommit 2026-05-01 23:48:50 -07:00
Hongming Wang 43c234df35 secret-scan: align local pre-commit + extend drift lint (closes #1569 root)
#1569 Phase 1 discovery (2026-05-02) found six historical credential
exposures in molecule-core git history. All confirmed dead — but the
reason they got committed in the first place was that the local
pre-commit hook had two gaps that the canonical CI gate (and the
runtime's hook) didn't:

  1. **Pattern set was incomplete.** Local hook checked
     `sk-ant-|sk-proj-|ghp_|gho_|AKIA|mol_pk_|cfut_` — missing
     `ghs_*`, `ghu_*`, `ghr_*`, `github_pat_*`, `sk-svcacct-`,
     `sk-cp-`, `xox[baprs]-`, `ASIA*`. The historical leaks were 5×
     `ghs_*` (App installation tokens) + 1× `github_pat_*` — none of
     which the local hook would have caught even if it ran.
  2. **`*.md` and `docs/` were skip-listed.** The leaked tokens lived
     in `tick-reflections-temp.md`, `qa-audit-2026-04-21.md`, and
     `docs/incidents/INCIDENT_LOG.md` — exactly the file types the
     skip-list excluded. The hook ran and silently passed.

This commit:

- Replaces the local hook's hard-coded inline regex with the canonical
  13-pattern array (byte-aligned with `.github/workflows/secret-scan.yml`
  and the workspace runtime's `pre-commit-checks.sh`).
- Removes the `\.md$|docs/` skip — keeps only binary, lockfile, and
  hook-self exclusions.
- Adds the local hook to `lint_secret_pattern_drift.py` as an in-repo
  consumer (read-from-disk, no network — the hook lives in the same
  checkout the lint runs against). Drift now fails the lint when
  canonical changes without the local hook updating in lockstep.
- Adds `.githooks/pre-commit` to the drift-lint workflow's path
  filter so consumer-side edits also trigger the lint.
- Adopts the canonical's "don't echo the matched value" defense (the
  prior version would have round-tripped a leaked credential into
  scrollback / CI logs).

Verified: `python3 .github/scripts/lint_secret_pattern_drift.py`
reports both consumers aligned at 13 patterns. The hook's existing
six other gates (canvas 'use client', dark theme, SQL injection,
go-build, etc.) are untouched.

Companion change (already applied via API, no diff here):
`Scan diff for credential-shaped strings` is now in the required-checks
list on both `staging` and `main` branch protection — was previously a
soft gate (workflow ran, exited 1, but didn't block merge).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:47:56 -07:00
Hongming Wang 435e13e57e Merge pull request #2504 from Molecule-AI/fix/restart-stop-retry-then-flag
fix(restart): retry cpProv.Stop with backoff + flag exhaustion as LEAK-SUSPECT
2026-05-02 06:40:58 +00:00
Hongming Wang f18ee8598a fix(restart): retry cpProv.Stop with backoff + flag exhaustion as LEAK-SUSPECT
Both restart paths (interactive Restart handler + auto-restart's
stopForRestart) used to log-and-continue on cpProv.Stop failure. After
PR #2500 made CPProvisioner.Stop surface CP non-2xx as an error, those
paths became the actual leak generator: every transient CP/AWS hiccup =
one orphan EC2 alongside the freshly provisioned one. The 13 zombie
workspace EC2s on demo-prep staging traced to this exact path.

Adds cpStopWithRetry helper with bounded exponential backoff (3 attempts,
1s/2s/4s). Different policy from workspace_crud.go's Delete handler:
Delete returns 500 to the client on Stop failure (loud-fail-and-block —
user asked to destroy, silent leak unacceptable), whereas Restart's
contract is "make the workspace alive again" — refusing to reprovision
strands the user with a dead workspace. So this helper retries to absorb
transient failures, then on exhaustion emits a structured `LEAK-SUSPECT`
log line for the (forthcoming) CP-side workspace orphan reconciler to
correlate. Caller proceeds to reprovision regardless.

ctx-cancel exits the retry early without sleeping the backoff (matters
during shutdown drain); the cancel path emits a distinct log line and
deliberately does NOT emit LEAK-SUSPECT — operator-cancel and
retry-exhaustion are different signals and conflating them would noise
up the orphan-reconciler queue with workspaces we never had a chance to
retry.

Tests: 5 behavior tests covering every branch (no-op, first-try success,
eventual success, exhaustion, ctx-cancel) + 1 AST gate that pins the
helper-only invariant (any future inline `h.cpProv.Stop(...)` in
workspace_restart.go fires the gate, mutation-tested).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:36:38 -07:00
Hongming Wang d64570a665 Merge pull request #2502 from Molecule-AI/fix/redeploy-main-use-staging-sha-tag
fix(redeploy-main): pull staging-<head_sha> instead of stale :latest
2026-05-02 06:30:32 +00:00
github-actions[bot] 2447c3da11 Merge pull request #2501 from Molecule-AI/staging
staging → main: auto-promote 23ee9b5
2026-05-01 23:27:52 -07:00
Hongming Wang 115f1f5e64 fix(redeploy-main): pull staging-<head_sha> instead of stale :latest
Auto-trigger from publish-workspace-server-image now resolves
target_tag to the just-published `staging-<short_head_sha>` digest
instead of `:latest`. Bypasses the dead retag path that was leaving
prod tenants on a 4-day-old image.

The chain pre-fix:
  publish-image  → pushes :staging-<sha> + :staging-latest (NOT :latest)
  canary-verify  → soft-skips (CANARY_TENANT_URLS unset, fleet not stood up)
  promote-latest → manual workflow_dispatch only, last run 2026-04-28
  redeploy-main  → pulls :latest → 2026-04-28 digest → all 3 tenants STALE

Today's incident:
  e7375348 (main) → publish-image green → redeploy fired → tenants
  pulled :latest (76c604fb digest from prior canary-verified state) →
  hongming /buildinfo returned 76c604fb instead of e7375348 → verify
  step correctly flagged 3/3 STALE → workflow failed.

Today's PRs (#2473 smoke wedge, #2487 panic recovery, #2496 sweeper
followups) shipped to GHCR as :staging-<sha> but never reached prod.

Fix:
  - workflow_dispatch input default '' (was 'latest'); empty input
    triggers auto-compute path
  - new "Compute target tag" step resolves:
    1. operator-supplied input → verbatim (rollback / pin)
    2. else → staging-<short_head_sha> (auto)
  - verify step's operator-pin detection now allows
    staging-<short_head_sha> as a non-pin (verification still runs)

When canary fleet is real, this workflow should chain on
canary-verify completion (workflow_run from canary-verify, gated on
promote-to-latest success) instead of publish-image — separate,
smaller PR. Today's fix unblocks prod deploys without that
prerequisite.

Companion: promote-latest.yml dispatched 2026-05-02 against
e7375348 to unstick existing prod tenants. This PR prevents
recurrence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:17:59 -07:00
Hongming Wang 23ee9b5e53 Merge pull request #2500 from Molecule-AI/fix/cp-provisioner-stop-status-check
fix(cp-provisioner): surface CP non-2xx on Stop to plug EC2 leak
2026-05-02 06:02:08 +00:00
Hongming Wang 5167e482d0 fix(cp-provisioner): surface CP non-2xx on Stop to plug EC2 leak
http.Client.Do only errors on transport failure — a CP 5xx (AWS
hiccup, missing IAM, transient outage) was silently treated as
success. Workspace row then flipped to status='removed' and the EC2
stayed alive forever with no DB pointer (the "orphan EC2 on a
0-customer account" scenario flagged in workspace_crud.go #1843).
Found while triaging 13 zombie workspace EC2s on demo-prep staging.

Adds a status-code check that returns an error tagged with the
workspace ID + status + bounded body excerpt, so the existing
loud-fail path in workspace_crud.go's Delete handler can populate
stop_failures and surface a 500. Body read is io.LimitReader-capped
at 512 bytes to keep error logs sane during a CP outage.

Tests: 4 new (5xx surfaces, 4xx surfaces, 2xx variants 200/202/204
all succeed, long body is truncated). Test-first verified — the
first three fail on the buggy code and all four pass on the fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 22:59:01 -07:00
261 changed files with 27904 additions and 3304 deletions
+45 -7
View File
@@ -129,19 +129,57 @@ fi
# ──────────────────────────────────────────────────────────
# 6. Secrets: No tokens/keys in staged files
# ──────────────────────────────────────────────────────────
#
# Pattern set MUST match .github/workflows/secret-scan.yml SECRET_PATTERNS
# and molecule-ai-workspace-runtime/molecule_runtime/scripts/pre-commit-checks.sh —
# .github/workflows/secret-pattern-drift.yml lints this invariant. Rebuilt
# against canonical 2026-05-02 after #1569 Phase 1 discovery surfaced
# real ghs_*/github_pat_* leaks that the prior pattern set
# ('sk-ant-|sk-proj-|ghp_|gho_|AKIA|mol_pk_|cfut_') would have missed:
# (a) it lacked ghs_ / ghu_ / ghr_ / github_pat_ / sk-svcacct- / sk-cp- /
# xox[baprs]- / ASIA prefixes, (b) it skipped *.md and docs/* — but the
# actual leaks lived in tick-reflections-temp.md, qa-audit-2026-04-21.md,
# docs/incidents/INCIDENT_LOG.md.
SECRET_PATTERNS=(
'ghp_[A-Za-z0-9]{36,}' # GitHub PAT (classic)
'ghs_[A-Za-z0-9]{36,}' # GitHub App installation token
'gho_[A-Za-z0-9]{36,}' # GitHub OAuth user-to-server
'ghu_[A-Za-z0-9]{36,}' # GitHub OAuth user
'ghr_[A-Za-z0-9]{36,}' # GitHub OAuth refresh
'github_pat_[A-Za-z0-9_]{82,}' # GitHub fine-grained PAT
'sk-ant-[A-Za-z0-9_-]{40,}' # Anthropic API key
'sk-proj-[A-Za-z0-9_-]{40,}' # OpenAI project key
'sk-svcacct-[A-Za-z0-9_-]{40,}' # OpenAI service-account key
'sk-cp-[A-Za-z0-9_-]{60,}' # MiniMax API key (F1088 vector — caught only after the fact)
'xox[baprs]-[A-Za-z0-9-]{20,}' # Slack tokens (bot/app/user/refresh)
'AKIA[0-9A-Z]{16}' # AWS access key ID
'ASIA[0-9A-Z]{16}' # AWS STS temp access key ID
)
ALL_STAGED=$(git diff --cached --name-only --diff-filter=ACM || true)
if [ -n "$ALL_STAGED" ]; then
for f in $ALL_STAGED; do
# Skip binary, known safe files, hooks, docs, and markdown
if echo "$f" | grep -qE '\.png$|\.jpg$|\.ico$|\.woff|node_modules|\.lock$|\.githooks/|\.md$|docs/'; then
# Skip ONLY binary + lockfiles + the hook itself. Markdown +
# docs/* are NOT skipped — that was the bug (#1569 leaks were
# all in *.md). If a doc legitimately needs a token-shaped
# placeholder, use ghs_EXAMPLE_TOKEN_DO_NOT_USE — short enough
# to dodge the {36,} length suffix.
if echo "$f" | grep -qE '\.png$|\.jpg$|\.ico$|\.woff|node_modules|\.lock$|\.githooks/'; then
continue
fi
DIFF=$(git diff --cached "$f" 2>/dev/null | grep '^+' | grep -v '^+++' || true)
if echo "$DIFF" | grep -qE 'sk-ant-|sk-proj-|ghp_|gho_|AKIA[A-Z0-9]|mol_pk_|cfut_' 2>/dev/null; then
echo "❌ POSSIBLE SECRET in $f — do not commit API keys or tokens"
ERRORS=$((ERRORS + 1))
fi
DIFF=$(git diff --cached --no-color --unified=0 -- "$f" 2>/dev/null | grep -E '^\+[^+]' || true)
[ -z "$DIFF" ] && continue
for pattern in "${SECRET_PATTERNS[@]}"; do
if echo "$DIFF" | grep -qE "$pattern"; then
echo "❌ POSSIBLE SECRET in $f (matched: ${pattern})"
echo " The actual matched value is NOT echoed here — round-tripping a"
echo " leaked credential into scrollback widens the blast radius."
echo " If false positive (test/docs example), use a short placeholder"
echo " like ghs_EXAMPLE_TOKEN_DO_NOT_USE that doesn't satisfy the length."
ERRORS=$((ERRORS + 1))
break
fi
done
done
fi
@@ -41,6 +41,17 @@ CONSUMERS: list[tuple[str, str]] = [
),
]
# In-repo consumers — paths read locally from the workflow checkout.
# Read-from-disk avoids the staging→main lag that the URL fetcher
# would hit (a freshly-edited canonical wouldn't yet be on the
# consumer's default branch). Same drift semantics, no network.
LOCAL_CONSUMERS: list[tuple[str, Path]] = [
(
".githooks/pre-commit (molecule-core local hook)",
Path(".githooks/pre-commit"),
),
]
# Matches the SECRET_PATTERNS=( ... ) array in either yaml-indented
# (the canonical workflow's `run:` block) or shell-flat (runtime
# hook) format. Patterns inside are single-quoted Bash strings; we
@@ -89,6 +100,27 @@ def main() -> int:
print(f"canonical ({CANONICAL_FILE}): {len(canonical)} patterns")
drift = False
# In-repo consumers first — these are read from the workflow's own
# checkout, so they never lag behind the canonical and a missing
# file IS a real error (not a fetch warning).
for label, path in LOCAL_CONSUMERS:
if not path.exists():
print(f"::error::{label}: file not found at {path}")
drift = True
continue
consumer = extract_patterns(path.read_text(), label)
missing, extra = diff_patterns(canonical, consumer)
if not missing and not extra:
print(f"{label}: aligned ({len(consumer)} patterns)")
continue
drift = True
print(f"::error::DRIFT in {label}:")
for p in missing:
print(f" - missing from consumer: {p!r}")
for p in extra:
print(f" - extra in consumer (not in canonical): {p!r}")
for label, url in CONSUMERS:
try:
content = fetch(url)
+1 -1
View File
@@ -225,7 +225,7 @@ jobs:
esac
- if: steps.gate.outputs.proceed == 'true'
uses: imjasonh/setup-crane@31b88efe9de28ae0ffa220711af4b60be9435f6e # v0.4
uses: imjasonh/setup-crane@6da1ae018866400525525ce74ff892880c099987 # v0.5
- name: GHCR login
if: steps.gate.outputs.proceed == 'true'
+94 -44
View File
@@ -111,7 +111,60 @@ jobs:
all_green: ${{ steps.gates.outputs.all_green }}
head_sha: ${{ steps.gates.outputs.head_sha }}
steps:
# Skip empty-tree promotes (the perpetual auto-promote↔auto-sync cycle
# observed 2026-05-03). Sequence: auto-promote merges via the staging
# merge-queue's MERGE strategy, creating a merge commit on main that
# staging doesn't have. auto-sync then merges main back into staging
# via another merge commit (the queue's MERGE strategy applies on
# the staging side too, even when the workflow's local FF would
# have sufficed). Now staging has a new merge-commit SHA whose
# tree == main's tree — but auto-promote sees "staging ahead of
# main by 1" and opens YET another empty promote PR. Each round
# costs ~30-40 min wallclock, ~2 manual approvals, and burns a
# full CodeQL Go run (~15 min). Without this guard the cycle
# repeats indefinitely.
#
# Long-term fix is to switch the merge_queue ruleset's
# `merge_method` away from MERGE so FF-able PRs land cleanly,
# but that's a broader change affecting every staging PR's
# commit shape. This guard is the one-line surgical fix that
# breaks the cycle without touching merge-queue config.
#
# Fail-open: if `git diff` errors for any reason, fall through
# to the gate check (preserve existing behavior). Only skip
# when the diff is DEFINITIVELY empty.
- name: Checkout for tree-diff check
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
ref: staging
- name: Skip if staging tree == main tree (perpetual-cycle break)
id: tree-diff
env:
HEAD_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
run: |
set -eu
git fetch origin main --depth=50 || { echo "::warning::git fetch main failed — proceeding (fail-open)"; exit 0; }
# Compare staging tip's tree against main's tree. `git diff
# --quiet` exits 0 if no differences, 1 if there are.
if git diff --quiet origin/main "$HEAD_SHA" -- 2>/dev/null; then
{
echo "## ⏭ Skipped — no code to promote"
echo
echo "staging tip (\`${HEAD_SHA:0:8}\`) and \`main\` have identical trees."
echo "This is the auto-promote↔auto-sync merge-commit cycle: staging has a"
echo "new SHA (a sync-back merge commit) but the underlying file tree is"
echo "already on main, so there's no real code to ship."
echo
echo "Skipping to avoid opening an empty promote PR. Cycle terminates here."
} >> "$GITHUB_STEP_SUMMARY"
echo "::notice::auto-promote: staging tree == main tree — no code to promote, skipping"
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Check all required gates on this SHA
if: steps.tree-diff.outputs.skip != 'true'
id: gates
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -209,10 +262,25 @@ jobs:
exit 0
fi
# Mint the App token BEFORE the promote-PR step so the auto-merge
# call can use it. GITHUB_TOKEN-initiated merges suppress the
# downstream `push` event on main, breaking the
# publish-workspace-server-image → canary-verify → redeploy-tenants
# chain (issue #2357). Using the App token here means the
# merge-queue-landed merge IS able to fire the cascade naturally;
# the polling tail below stays as defense-in-depth.
- name: Mint App token for promote-PR + downstream dispatch
if: ${{ vars.AUTO_PROMOTE_ENABLED == 'true' || github.event.inputs.force == 'true' }}
id: app-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: ${{ secrets.MOLECULE_AI_APP_ID }}
private-key: ${{ secrets.MOLECULE_AI_APP_PRIVATE_KEY }}
- name: Open (or reuse) staging → main promote PR + enable auto-merge
if: ${{ vars.AUTO_PROMOTE_ENABLED == 'true' || github.event.inputs.force == 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ steps.app-token.outputs.token }}
REPO: ${{ github.repository }}
TARGET_SHA: ${{ needs.check-all-gates-green.outputs.head_sha }}
run: |
@@ -267,52 +335,34 @@ jobs:
echo "promote_pr_num=${PR_NUM}" >> "$GITHUB_OUTPUT"
id: promote_pr
# Mint a short-lived GitHub App installation token for the dispatch
# step below. We CANNOT use `secrets.GITHUB_TOKEN` to dispatch the
# downstream publish chain — workflow runs created by GITHUB_TOKEN
# do not fire `workflow_run` triggers on completion (the
# documented "no recursion" rule —
# https://docs.github.com/en/actions/using-workflows/triggering-a-workflow#triggering-a-workflow-from-a-workflow).
#
# Symptom this caused (root-caused on 2026-04-30): publish-image
# ran successfully twice (21313dc 14:41Z, 59dec57 15:21Z) but
# canary-verify and redeploy-tenants-on-main never chained,
# because the publish run's `triggering_actor` was
# `github-actions[bot]` (i.e. GITHUB_TOKEN). A manual dispatch
# earlier in the day with the operator's PAT (d850ec7 06:52Z) did
# chain — same workflow file, only the actor differed.
#
# An App token's triggering_actor is the App user (e.g.
# `molecule-ai[bot]`), which IS allowed to fire downstream
# workflow_run cascades.
- name: Mint App token for downstream dispatch
if: steps.promote_pr.outputs.promote_pr_num != ''
id: app-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: ${{ secrets.MOLECULE_AI_APP_ID }}
private-key: ${{ secrets.MOLECULE_AI_APP_PRIVATE_KEY }}
# The App token minted above (before the promote-PR step) is
# also used by the polling tail below. Defense-in-depth: with
# the merge-queue-landed merge now using the App token, the
# main-branch push event SHOULD fire the publish/canary/redeploy
# cascade naturally — but if for any reason it doesn't (e.g. an
# unrelated event-suppression edge case), the explicit dispatches
# below still wake the chain.
- name: Wait for promote merge, then dispatch publish + redeploy (#2357)
# GITHUB_TOKEN-initiated merges suppress downstream `push` events
# (https://docs.github.com/en/actions/using-workflows/triggering-a-workflow#triggering-a-workflow-from-a-workflow).
# Result: when the merge queue lands the promote PR, the resulting
# main-branch push DOES NOT fire publish-workspace-server-image,
# so canary-verify and redeploy-tenants-on-main never run and
# tenants stay on stale code (issue #2357).
# Defense-in-depth dispatch. With the auto-merge call above
# now using the App token (this commit), the merge-queue-landed
# merge SHOULD fire publish-workspace-server-image naturally
# via on:push:[main] — App-token-initiated pushes DO trigger
# workflow_run cascades, unlike GITHUB_TOKEN-initiated ones
# (the documented "no recursion" rule —
# https://docs.github.com/en/actions/using-workflows/triggering-a-workflow#triggering-a-workflow-from-a-workflow).
#
# Workaround: poll for the merge to land, then explicitly
# `gh workflow run` publish-workspace-server-image. The dispatch
# MUST authenticate as the molecule-ai App (App token minted
# above) — not GITHUB_TOKEN — so that the resulting publish
# run's completion event can fire the workflow_run cascade
# into canary-verify + redeploy-tenants-on-main. See the prior
# step's comment for the GITHUB_TOKEN no-recursion details.
# This explicit dispatch stays as belt-and-suspenders for any
# edge case where the natural cascade misfires. If it never
# observably fires after this token swap (i.e. the publish
# workflow has already started by the time we get here), the
# second dispatch is a harmless no-op (publish-workspace-server-image
# has its own concurrency group that dedupes).
#
# Long-term fix: switch the auto-merge call above to use the
# same App token, so the merge's push event fires
# publish-workspace-server-image naturally and this polling tail
# becomes unnecessary. Tracked in #2357.
# See PR for #2357: pre-fix the merge action was via
# GITHUB_TOKEN, suppressing the cascade and forcing this tail
# to be the SOLE chain trigger. With the auto-merge token swap
# the tail becomes redundant in the happy path; keep until
# we've observed >=10 successful natural cascades, then drop.
if: steps.promote_pr.outputs.promote_pr_num != ''
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
@@ -99,7 +99,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout staging
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
ref: staging
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
tag:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0 # need full tag history for `git describe` / sort
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
name: Block forbidden paths
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 2 # need previous commit to diff against on push events
+94 -20
View File
@@ -50,23 +50,39 @@ jobs:
env:
MOLECULE_CP_URL: https://staging-api.moleculesai.app
MOLECULE_ADMIN_TOKEN: ${{ secrets.MOLECULE_STAGING_ADMIN_TOKEN }}
# Without an LLM key the test_staging_full_saas.sh script provisions
# the workspace with empty secrets, hermes derive-provider.sh resolves
# `openai/gpt-4o` to PROVIDER=openrouter, no OPENROUTER_API_KEY is
# found in env, and A2A returns "No LLM provider configured" at
# request time (canary step 8/11). The full-lifecycle workflow
# (e2e-staging-saas.yml) has carried this secret since launch — the
# canary regressed when it was first split out and lost the env
# block. Issue #1500 had ~30 consecutive failures before this was
# spotted; do NOT remove without re-reading the script's secrets-
# injection block.
# MiniMax is the canary's PRIMARY LLM auth path post-2026-05-04.
# Switched from hermes+OpenAI after #2578 (the staging OpenAI key
# account went over quota and stayed dead for 36+ hours, taking
# the canary red the entire time). claude-code template's
# `minimax` provider routes ANTHROPIC_BASE_URL to
# api.minimax.io/anthropic and reads MINIMAX_API_KEY at boot —
# ~5-10x cheaper per token than gpt-4.1-mini AND on a separate
# billing account, so OpenAI quota collapse no longer wedges the
# canary. Mirrors the migration continuous-synth-e2e.yml made on
# 2026-05-03 (#265) for the same reason. tests/e2e/test_staging_
# full_saas.sh branches SECRETS_JSON on which key is present —
# MiniMax wins when set.
E2E_MINIMAX_API_KEY: ${{ secrets.MOLECULE_STAGING_MINIMAX_API_KEY }}
# Direct-Anthropic alternative for operators who don't want to
# set up a MiniMax account (priority below MiniMax — first
# non-empty wins in test_staging_full_saas.sh's secrets-injection
# block). See #2578 PR comment for the rationale.
E2E_ANTHROPIC_API_KEY: ${{ secrets.MOLECULE_STAGING_ANTHROPIC_API_KEY }}
# OpenAI fallback — kept wired so an operator-dispatched run with
# E2E_RUNTIME=hermes overridden via workflow_dispatch can still
# exercise the OpenAI path without re-editing the workflow.
E2E_OPENAI_API_KEY: ${{ secrets.MOLECULE_STAGING_OPENAI_KEY }}
E2E_MODE: canary
E2E_RUNTIME: hermes
E2E_RUNTIME: claude-code
# Pin the canary to a specific MiniMax model rather than relying
# on the per-runtime default (which could resolve to "sonnet" →
# direct Anthropic and defeat the cost saving). M2.7-highspeed
# is "Token Plan only" but cheap-per-token and fast.
E2E_MODEL_SLUG: MiniMax-M2.7-highspeed
E2E_RUN_ID: "canary-${{ github.run_id }}"
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Verify admin token present
run: |
@@ -75,13 +91,47 @@ jobs:
exit 2
fi
- name: Verify OpenAI key present
- name: Verify LLM key present
run: |
if [ -z "$E2E_OPENAI_API_KEY" ]; then
echo "::error::MOLECULE_STAGING_OPENAI_KEY secret not set — A2A will fail at request time with 'No LLM provider configured'"
# Per-runtime key check — claude-code uses MiniMax; hermes /
# langgraph (operator-dispatched only) use OpenAI. Hard-fail
# rather than soft-skip per the lesson from synth E2E #2578:
# an empty key silently falls through to the wrong
# SECRETS_JSON branch and the canary fails 5 min later with
# a confusing auth error instead of the clean "secret
# missing" message at the top.
case "${E2E_RUNTIME}" in
claude-code)
# Either MiniMax OR direct-Anthropic works — first
# non-empty wins in the test script's secrets-injection
# priority chain. Operators only need to set ONE of these
# secrets; we don't force a choice between them.
if [ -n "${E2E_MINIMAX_API_KEY:-}" ]; then
required_secret_name="MOLECULE_STAGING_MINIMAX_API_KEY"
required_secret_value="${E2E_MINIMAX_API_KEY}"
elif [ -n "${E2E_ANTHROPIC_API_KEY:-}" ]; then
required_secret_name="MOLECULE_STAGING_ANTHROPIC_API_KEY"
required_secret_value="${E2E_ANTHROPIC_API_KEY}"
else
required_secret_name="MOLECULE_STAGING_MINIMAX_API_KEY or MOLECULE_STAGING_ANTHROPIC_API_KEY"
required_secret_value=""
fi
;;
langgraph|hermes)
required_secret_name="MOLECULE_STAGING_OPENAI_KEY"
required_secret_value="${E2E_OPENAI_API_KEY:-}"
;;
*)
echo "::warning::Unknown E2E_RUNTIME='${E2E_RUNTIME}' — skipping LLM-key check"
required_secret_name=""
required_secret_value="present"
;;
esac
if [ -n "$required_secret_name" ] && [ -z "$required_secret_value" ]; then
echo "::error::${required_secret_name} secret not set for runtime=${E2E_RUNTIME} — A2A will fail at request time with 'No LLM provider configured'"
exit 2
fi
echo "OpenAI key present ✓ (len=${#E2E_OPENAI_API_KEY})"
echo "LLM key present ✓ (runtime=${E2E_RUNTIME}, key=${required_secret_name}, len=${#required_secret_value})"
- name: Canary run
id: canary
@@ -98,7 +148,7 @@ jobs:
# next deploy window.
- name: Open issue on failure
if: failure()
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
# Inject the workflow path explicitly — context.workflow is
# the *name*, not the file path the actions API needs.
@@ -165,7 +215,7 @@ jobs:
- name: Auto-close canary issue on success
if: success()
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const title = '🔴 Canary failing: staging SaaS smoke';
@@ -231,10 +281,34 @@ jobs:
and o.get('status') not in ('purged',)]
print('\n'.join(candidates))
" 2>/dev/null)
# Per-slug DELETE with HTTP-code verification. The previous
# `... >/dev/null || true` swallowed every failure, so a 5xx
# or timeout from CP looked identical to "successfully cleaned
# up" and the tenant kept eating ~2 vCPU until the hourly
# stale sweep caught it (up to 2h later). Now we capture the
# response code and surface non-2xx as a workflow warning, so
# the run page shows which slug leaked. We still don't `exit 1`
# on cleanup failure — a single-canary cleanup miss shouldn't
# fail-flag the canary itself when the actual smoke check
# passed. The sweep-stale-e2e-orgs cron (now every 15 min,
# 30-min threshold) is the safety net for whatever slips past.
# See molecule-controlplane#420.
leaks=()
for slug in $orgs; do
curl -sS -X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
code=$(curl -sS -o /tmp/canary-cleanup.out -w "%{http_code}" \
-X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"confirm\":\"$slug\"}" >/dev/null || true
-d "{\"confirm\":\"$slug\"}" \
|| echo "000")
if [ "$code" = "200" ] || [ "$code" = "204" ]; then
echo "[teardown] deleted $slug (HTTP $code)"
else
echo "::warning::canary teardown for $slug returned HTTP $code — sweep-stale-e2e-orgs will catch it within ~45 min. Body: $(head -c 300 /tmp/canary-cleanup.out 2>/dev/null)"
leaks+=("$slug")
fi
done
if [ ${#leaks[@]} -gt 0 ]; then
echo "::warning::canary teardown left ${#leaks[@]} leak(s): ${leaks[*]}"
fi
exit 0
+2 -2
View File
@@ -40,7 +40,7 @@ jobs:
smoke_ran: ${{ steps.smoke.outputs.ran }}
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Compute sha
id: compute
@@ -143,7 +143,7 @@ jobs:
if: ${{ needs.canary-smoke.result == 'success' && needs.canary-smoke.outputs.smoke_ran == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: imjasonh/setup-crane@31b88efe9de28ae0ffa220711af4b60be9435f6e # v0.4
- uses: imjasonh/setup-crane@6da1ae018866400525525ce74ff892880c099987 # v0.5
- name: GHCR login
run: |
@@ -0,0 +1,39 @@
name: cascade-list-drift-gate
# Structural gate: TEMPLATES list in publish-runtime.yml must match
# manifest.json's workspace_templates exactly. Closes the recurrence
# path of PR #2556 (the data fix) and is the first concrete deliverable
# of RFC #388 PR-3.
#
# Why a gate, not just discipline: PR #2536 pruned the manifest, but the
# cascade list wasn't updated for ~weeks before someone (PR #2556)
# noticed during an unrelated audit. During that window, codex never
# rebuilt on a runtime publish. A structural gate catches the drift
# the same day either file changes.
#
# Triggers narrowly to keep CI quiet: only on PRs that actually change
# one of the two files. The path-filtered split + always-emit-result
# pattern (memory: "Required check names need a job that always runs")
# is unnecessary here because the workflow IS the check name and PR
# branch protection should require it directly. Future-proof: if this
# becomes a required check, add a no-op aggregator with always() so the
# name still emits when paths don't match.
on:
pull_request:
branches: [staging, main]
paths:
- manifest.json
- .github/workflows/publish-runtime.yml
- scripts/check-cascade-list-vs-manifest.sh
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Check cascade list matches manifest
run: bash scripts/check-cascade-list-vs-manifest.sh
@@ -36,7 +36,7 @@ jobs:
permissions:
contents: read
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Verify merge_group trigger on required-check workflows
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -30,7 +30,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Need history to diff against base ref
fetch-depth: 0
+17 -5
View File
@@ -32,7 +32,7 @@ jobs:
python: ${{ steps.check.outputs.python }}
scripts: ${{ steps.check.outputs.scripts }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- id: check
@@ -78,7 +78,7 @@ jobs:
working-directory: .
run: echo "No platform/** changes — skipping real build steps; this job always runs to satisfy the required-check name on branch protection."
- if: needs.changes.outputs.platform == 'true'
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- if: needs.changes.outputs.platform == 'true'
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
@@ -213,7 +213,7 @@ jobs:
working-directory: .
run: echo "No canvas/** changes — skipping real build steps; this job always runs to satisfy the required-check name on branch protection."
- if: needs.changes.outputs.canvas == 'true'
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- if: needs.changes.outputs.canvas == 'true'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
@@ -260,7 +260,7 @@ jobs:
- if: needs.changes.outputs.scripts != 'true'
run: echo "No tests/e2e/ or infra/scripts/ changes — skipping real shellcheck; this job always runs to satisfy the required-check name on branch protection."
- if: needs.changes.outputs.scripts == 'true'
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- if: needs.changes.outputs.scripts == 'true'
name: Run shellcheck on tests/e2e/*.sh and infra/scripts/*.sh
# shellcheck is pre-installed on ubuntu-latest runners (via apt).
@@ -272,6 +272,18 @@ jobs:
find tests/e2e infra/scripts -type f -name '*.sh' -print0 \
| xargs -0 shellcheck --severity=warning
- if: needs.changes.outputs.scripts == 'true'
name: Run E2E bash unit tests (no live infra)
# Pure-bash unit tests for E2E helper libs (lib/*.sh). These pin
# behavior of dispatch logic that — when broken — silently masks as
# "Could not resolve authentication method" only after a successful
# tenant + workspace provision (PR #2571 incident, 2026-05-03). Add
# new self-contained unit tests here as the lib/ directory grows;
# tests requiring live CP/tenant credentials belong in the dedicated
# e2e-staging-* workflows, not this job.
run: |
bash tests/e2e/test_model_slug.sh
canvas-deploy-reminder:
name: Canvas Deploy Reminder
runs-on: ubuntu-latest
@@ -332,7 +344,7 @@ jobs:
working-directory: .
run: echo "No workspace/** changes — skipping real lint+test; this job always runs to satisfy the required-check name on branch protection."
- if: needs.changes.outputs.python == 'true'
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- if: needs.changes.outputs.python == 'true'
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
+2 -2
View File
@@ -53,14 +53,14 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Checkout sibling plugin repo
# Same reasoning as publish-workspace-server-image.yml — the Go
# module's replace directive needs the plugin source so
# CodeQL's "go build" phase can resolve.
if: matrix.language == 'go'
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: Molecule-AI/molecule-ai-plugin-github-app-auth
path: molecule-ai-plugin-github-app-auth
+124 -27
View File
@@ -32,16 +32,41 @@ name: Continuous synthetic E2E (staging)
on:
schedule:
# Every 20 minutes, on the :00 :20 :40. Offsets the existing :15
# sweep-cf-orphans and :45 sweep-cf-tunnels so the three
# operations don't all hit Cloudflare/AWS at the same minute.
- cron: '0,20,40 * * * *'
# Every 10 minutes, on :02 :12 :22 :32 :42 :52. Three constraints:
# 1. Stay off the top-of-hour. GitHub Actions scheduler drops
# :00 firings under high load (own docs:
# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule).
# Prior history: cron was '0,20,40' (2026-05-02) — only :00
# ever survived. Bumped to '10,30,50' (2026-05-03) on the
# theory that further-from-:00 wins. Empirically 2026-05-04
# that ALSO dropped to ~60 min effective cadence (only ~1
# schedule fire per hour — see molecule-core#2726). Detection
# latency was claimed 20 min, actual 60 min.
# 2. Avoid colliding with the existing :15 sweep-cf-orphans
# and :45 sweep-cf-tunnels — both hit the CF API and we
# don't want to fight for rate-limit tokens.
# 3. Avoid the :30 heavy slot (canary-staging /30, sweep-aws-
# secrets, sweep-stale-e2e-orgs every :15) — multiple
# overlapping cron registrations on the same minute is part
# of what GH drops under load.
# Solution: bump fires-per-hour 3 → 6 AND keep all slots in clean
# lanes (1-3 min away from any other cron). Even with empirically-
# observed ~67% GH drop ratio, 6 attempts/hour yields ~2 effective
# fires = ~30 min cadence; closer to the 20-min target than the
# current shape and provides a real degradation alarm if drops
# get worse.
- cron: '2,12,22,32,42,52 * * * *'
workflow_dispatch:
inputs:
runtime:
description: "Runtime to provision (langgraph = fastest, default; hermes = slower but covers SDK-native path; claude-code = needs OAUTH token in tenant env)"
description: "Runtime to provision (claude-code = default + cheapest via MiniMax; langgraph = OpenAI-only; hermes = SDK-native path, slower)"
required: false
default: "langgraph"
default: "claude-code"
type: string
model_slug:
description: "Model id to provision the workspace with (default MiniMax-M2.7-highspeed; e.g. 'sonnet' to test direct Anthropic, 'openai/gpt-4o' for hermes)"
required: false
default: "MiniMax-M2.7-highspeed"
type: string
keep_org:
description: "Skip teardown for post-mortem debugging (only manual dispatch — never set this for cron runs)"
@@ -68,15 +93,36 @@ jobs:
synth:
name: Synthetic E2E against staging
runs-on: ubuntu-latest
timeout-minutes: 12
# Bumped from 12 → 20 (2026-05-04). Tenant user-data install phase
# (apt-get update + install docker.io/jq/awscli/caddy + snap install
# ssm-agent) runs from raw Ubuntu on every boot — none of it is
# pre-baked into the tenant AMI. Empirical fetch_secrets/ok timing
# across today's canaries: 51s → 82s → 143s → 625s. apt-mirror tail
# latency drives the boot-to-fetch_secrets phase from ~1min to >10min.
# A 12min budget leaves only ~2min for the workspace (which needs
# ~3.5min for claude-code cold boot) on slow-apt days, blowing the
# budget. 20min absorbs the worst tenant tail so the workspace probe
# gets the full ~7min it needs even on a slow apt day. Real fix:
# pre-bake caddy + ssm-agent into the tenant AMI (controlplane#TBD).
timeout-minutes: 20
env:
# langgraph default keeps cold-start under 5 min on staging EC2.
# hermes is slower (~7-10 min) and isn't needed for the
# regression class this gate exists to catch (deployment-pipeline
# + schema-drift + integration). Operators can pick hermes via
# workflow_dispatch when they need to exercise the SDK-native
# session path.
E2E_RUNTIME: ${{ github.event.inputs.runtime || 'langgraph' }}
# claude-code default: cold-start ~5 min (comparable to langgraph),
# but uses MiniMax-M2.7-highspeed via the template's third-party-
# Anthropic-compat path (workspace-configs-templates/claude-code-
# default/config.yaml:64-69). MiniMax is ~5-10x cheaper than
# gpt-4.1-mini per token AND avoids the recurring OpenAI quota-
# exhaustion class that took the canary down 2026-05-03 (#265).
# Operators can pick langgraph / hermes via workflow_dispatch
# when they specifically need to exercise the OpenAI or SDK-
# native paths.
E2E_RUNTIME: ${{ github.event.inputs.runtime || 'claude-code' }}
# Pin the canary to a specific MiniMax model rather than relying
# on the per-runtime default ("sonnet" → routes to direct
# Anthropic, defeats the cost saving). Operators can override
# via workflow_dispatch by setting a different E2E_MODEL_SLUG
# input if they need to exercise a specific model. M2.7-highspeed
# is "Token Plan only" but cheap-per-token and fast.
E2E_MODEL_SLUG: ${{ github.event.inputs.model_slug || 'MiniMax-M2.7-highspeed' }}
# Bound to 10 min so a stuck provision fails the run instead of
# holding up the next cron firing. 15-min default in the script
# is for the on-PR full lifecycle where we have more headroom.
@@ -88,28 +134,79 @@ jobs:
E2E_KEEP_ORG: ${{ github.event.inputs.keep_org == 'true' && '1' || '' }}
MOLECULE_CP_URL: ${{ vars.STAGING_CP_URL || 'https://staging-api.moleculesai.app' }}
MOLECULE_ADMIN_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
# MiniMax key is the canary's PRIMARY auth path. claude-code
# template's `minimax` provider routes ANTHROPIC_BASE_URL to
# api.minimax.io/anthropic and reads MINIMAX_API_KEY at boot.
# tests/e2e/test_staging_full_saas.sh branches SECRETS_JSON on
# which key is present — MiniMax wins when set.
E2E_MINIMAX_API_KEY: ${{ secrets.MOLECULE_STAGING_MINIMAX_API_KEY }}
# Direct-Anthropic alternative for operators who don't want to
# set up a MiniMax account (priority below MiniMax — first
# non-empty wins in test_staging_full_saas.sh's secrets-injection
# block). See #2578 PR comment for the rationale.
E2E_ANTHROPIC_API_KEY: ${{ secrets.MOLECULE_STAGING_ANTHROPIC_API_KEY }}
# OpenAI fallback — kept wired so operators can dispatch with
# E2E_RUNTIME=langgraph or =hermes and still have a working
# canary path. The script picks the right blob shape based on
# which key is non-empty.
E2E_OPENAI_API_KEY: ${{ secrets.MOLECULE_STAGING_OPENAI_KEY }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Verify required secret present
- name: Verify required secrets present
run: |
# Schedule-vs-dispatch hardening (mirrors the sweep-cf-* and
# redeploy-tenants-on-* workflows): hard-fail on missing secret
# for cron firing so a misconfigured-repo doesn't silently
# report green while doing nothing. Soft-skip on operator
# dispatch — operators can dispatch ad-hoc to verify a fix
# without setting up the secret first.
# Hard-fail on missing secret REGARDLESS of trigger. Previously
# this step soft-skipped on workflow_dispatch via `exit 0`, but
# `exit 0` only ends the STEP — subsequent steps still ran with
# the empty secret, the synth script fell through to the wrong
# SECRETS_JSON branch, and the canary failed 5 min later with a
# confusing "Agent error (Exception)" instead of the clean
# "secret missing" message at the top. Caught 2026-05-04 by
# dispatched run 25296530706: claude-code + missing MINIMAX
# silently used OpenAI keys but kept model=MiniMax-M2.7, then
# the workspace 401'd against MiniMax once it tried to call.
# Fix: exit 1 in both cron and dispatch paths. Operators who
# want to verify a YAML change without setting up the secret
# can read the verify-secrets step's stderr — the failure is
# itself the verification signal.
if [ -z "${MOLECULE_ADMIN_TOKEN:-}" ]; then
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "::warning::CP_STAGING_ADMIN_API_TOKEN not set — synth E2E cannot run"
echo "::warning::Set it at Settings → Secrets and Variables → Actions"
exit 0
fi
echo "::error::CP_STAGING_ADMIN_API_TOKEN secret missing — synth E2E cannot run"
echo "::error::Set it at Settings → Secrets and Variables → Actions; pull from staging-CP's CP_ADMIN_API_TOKEN env in Railway."
exit 1
fi
# LLM-key requirement is per-runtime: claude-code accepts
# EITHER MiniMax OR direct-Anthropic (whichever is set first),
# langgraph + hermes use OpenAI (MOLECULE_STAGING_OPENAI_KEY).
case "${E2E_RUNTIME}" in
claude-code)
if [ -n "${E2E_MINIMAX_API_KEY:-}" ]; then
required_secret_name="MOLECULE_STAGING_MINIMAX_API_KEY"
required_secret_value="${E2E_MINIMAX_API_KEY}"
elif [ -n "${E2E_ANTHROPIC_API_KEY:-}" ]; then
required_secret_name="MOLECULE_STAGING_ANTHROPIC_API_KEY"
required_secret_value="${E2E_ANTHROPIC_API_KEY}"
else
required_secret_name="MOLECULE_STAGING_MINIMAX_API_KEY or MOLECULE_STAGING_ANTHROPIC_API_KEY"
required_secret_value=""
fi
;;
langgraph|hermes)
required_secret_name="MOLECULE_STAGING_OPENAI_KEY"
required_secret_value="${E2E_OPENAI_API_KEY:-}"
;;
*)
echo "::warning::Unknown E2E_RUNTIME='${E2E_RUNTIME}' — skipping LLM-key check"
required_secret_name=""
required_secret_value="present"
;;
esac
if [ -n "$required_secret_name" ] && [ -z "$required_secret_value" ]; then
echo "::error::${required_secret_name} secret missing — runtime=${E2E_RUNTIME} cannot authenticate against its LLM provider"
echo "::error::Set it at Settings → Secrets and Variables → Actions, OR dispatch with a different runtime"
exit 1
fi
- name: Install required tools
run: |
# The script depends on jq + curl (already on ubuntu-latest)
+2 -2
View File
@@ -40,7 +40,7 @@ jobs:
outputs:
api: ${{ steps.decide.outputs.api }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: filter
with:
@@ -90,7 +90,7 @@ jobs:
echo "No workspace-server / tests/e2e / workflow changes — E2E API gate satisfied without running tests."
echo "::notice::E2E API Smoke Test no-op pass (paths filter excluded this commit)."
- if: needs.detect-changes.outputs.api == 'true'
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- if: needs.detect-changes.outputs.api == 'true'
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
+19 -4
View File
@@ -59,7 +59,7 @@ jobs:
outputs:
canvas: ${{ steps.decide.outputs.canvas }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: filter
with:
@@ -107,7 +107,7 @@ jobs:
echo "::notice::E2E Staging Canvas no-op pass (paths filter excluded this commit)."
- if: needs.detect-changes.outputs.canvas == 'true'
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Verify admin token present
if: needs.detect-changes.outputs.canvas == 'true'
@@ -184,8 +184,23 @@ jobs:
exit 0
fi
echo "Deleting orphan tenant: $slug"
curl -sS -X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
# Verify HTTP 2xx instead of `>/dev/null || true` swallowing
# failures. A 5xx or timeout previously looked identical to
# success, leaving the tenant alive for up to ~45 min until
# sweep-stale-e2e-orgs caught it. Surface failures as
# workflow warnings naming the slug. Don't `exit 1` — a single
# cleanup miss shouldn't fail-flag the canvas test when the
# actual smoke check passed; the sweeper is the safety net.
# See molecule-controlplane#420.
code=$(curl -sS -o /tmp/canvas-cleanup.out -w "%{http_code}" \
-X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"confirm\":\"$slug\"}" >/dev/null || true
-d "{\"confirm\":\"$slug\"}" \
|| echo "000")
if [ "$code" = "200" ] || [ "$code" = "204" ]; then
echo "[teardown] deleted $slug (HTTP $code)"
else
echo "::warning::canvas teardown for $slug returned HTTP $code — sweep-stale-e2e-orgs will catch it within ~45 min. Body: $(head -c 300 /tmp/canvas-cleanup.out 2>/dev/null)"
fi
exit 0
+19 -3
View File
@@ -90,7 +90,7 @@ jobs:
E2E_STALE_WAIT_SECS: ${{ github.event.inputs.stale_wait_secs || '180' }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Verify admin token present
run: |
@@ -153,12 +153,28 @@ jobs:
if [ -n "$orgs" ]; then
echo "Safety-net sweep: deleting leftover orgs:"
echo "$orgs"
# Per-slug verified DELETE — see molecule-controlplane#420.
# `>/dev/null 2>&1` previously hid every failure; surface
# non-2xx as workflow warnings so the run page names what
# leaked. Sweeper catches the rest within ~45 min.
leaks=()
for slug in $orgs; do
curl -sS -X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
code=$(curl -sS -o /tmp/external-cleanup.out -w "%{http_code}" \
-X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"confirm\":\"$slug\"}" >/dev/null 2>&1
-d "{\"confirm\":\"$slug\"}" \
|| echo "000")
if [ "$code" = "200" ] || [ "$code" = "204" ]; then
echo "[teardown] deleted $slug (HTTP $code)"
else
echo "::warning::external teardown for $slug returned HTTP $code — sweep-stale-e2e-orgs will catch it within ~45 min. Body: $(head -c 300 /tmp/external-cleanup.out 2>/dev/null)"
leaks+=("$slug")
fi
done
if [ ${#leaks[@]} -gt 0 ]; then
echo "::warning::external teardown left ${#leaks[@]} leak(s): ${leaks[*]}"
fi
else
echo "Safety-net sweep: no leftover orgs to clean."
fi
+82 -13
View File
@@ -48,9 +48,9 @@ on:
workflow_dispatch:
inputs:
runtime:
description: "Runtime to test (hermes | claude-code | langgraph)"
description: "Runtime to test (claude-code [default, MiniMax] | hermes [OpenAI] | langgraph [OpenAI])"
required: false
default: "hermes"
default: "claude-code"
keep_org:
description: "Skip teardown for debugging (only use via manual dispatch!)"
required: false
@@ -83,16 +83,37 @@ jobs:
# retrieval + teardown. Configure in
# Settings → Secrets and variables → Actions → Repository secrets.
MOLECULE_ADMIN_TOKEN: ${{ secrets.MOLECULE_STAGING_ADMIN_TOKEN }}
# OpenAI key for workspace LLM calls (section 8 A2A). Without it,
# Hermes runtime crashes at boot with "No provider API key found".
# Configure at Settings → Secrets → Actions → MOLECULE_STAGING_OPENAI_KEY.
# MiniMax is the PRIMARY LLM auth path post-2026-05-04. Switched
# from hermes+OpenAI default after #2578 (the staging OpenAI key
# account went over quota and stayed dead for 36+ hours, taking
# the full-lifecycle E2E red on every provisioning-critical push).
# claude-code template's `minimax` provider routes
# ANTHROPIC_BASE_URL to api.minimax.io/anthropic and reads
# MINIMAX_API_KEY at boot — separate billing account so an
# OpenAI quota collapse no longer wedges the gate. Mirrors the
# canary-staging.yml + continuous-synth-e2e.yml migrations.
E2E_MINIMAX_API_KEY: ${{ secrets.MOLECULE_STAGING_MINIMAX_API_KEY }}
# Direct-Anthropic alternative for operators who don't want to
# set up a MiniMax account (priority below MiniMax — first
# non-empty wins in test_staging_full_saas.sh's secrets-injection
# block). See #2578 PR comment for the rationale.
E2E_ANTHROPIC_API_KEY: ${{ secrets.MOLECULE_STAGING_ANTHROPIC_API_KEY }}
# OpenAI fallback — kept wired so an operator-dispatched run with
# E2E_RUNTIME=hermes or =langgraph via workflow_dispatch can still
# exercise the OpenAI path.
E2E_OPENAI_API_KEY: ${{ secrets.MOLECULE_STAGING_OPENAI_KEY }}
E2E_RUNTIME: ${{ github.event.inputs.runtime || 'hermes' }}
E2E_RUNTIME: ${{ github.event.inputs.runtime || 'claude-code' }}
# Pin the model when running on the default claude-code path —
# the per-runtime default ("sonnet") routes to direct Anthropic
# and defeats the cost saving. Operators can override via the
# workflow_dispatch flow (no input wired here yet — runtime
# override is enough for ad-hoc).
E2E_MODEL_SLUG: ${{ github.event.inputs.runtime == 'hermes' && 'openai/gpt-4o' || github.event.inputs.runtime == 'langgraph' && 'openai:gpt-4o' || 'MiniMax-M2.7-highspeed' }}
E2E_RUN_ID: "${{ github.run_id }}-${{ github.run_attempt }}"
E2E_KEEP_ORG: ${{ github.event.inputs.keep_org && '1' || '0' }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Verify admin token present
run: |
@@ -102,13 +123,45 @@ jobs:
fi
echo "Admin token present ✓"
- name: Verify OpenAI key present
- name: Verify LLM key present
run: |
if [ -z "$E2E_OPENAI_API_KEY" ]; then
echo "::error::MOLECULE_STAGING_OPENAI_KEY secret not set — workspaces will fail at boot with 'No provider API key found'"
# Per-runtime key check — claude-code uses MiniMax; hermes /
# langgraph (operator-dispatched only) use OpenAI. Hard-fail
# rather than soft-skip per #2578's lesson — empty key
# silently falls through to the wrong SECRETS_JSON branch and
# produces a confusing auth error 5 min later instead of the
# clean "secret missing" message at the top.
case "${E2E_RUNTIME}" in
claude-code)
# Either MiniMax OR direct-Anthropic works — first
# non-empty wins in the test script's secrets-injection
# priority chain.
if [ -n "${E2E_MINIMAX_API_KEY:-}" ]; then
required_secret_name="MOLECULE_STAGING_MINIMAX_API_KEY"
required_secret_value="${E2E_MINIMAX_API_KEY}"
elif [ -n "${E2E_ANTHROPIC_API_KEY:-}" ]; then
required_secret_name="MOLECULE_STAGING_ANTHROPIC_API_KEY"
required_secret_value="${E2E_ANTHROPIC_API_KEY}"
else
required_secret_name="MOLECULE_STAGING_MINIMAX_API_KEY or MOLECULE_STAGING_ANTHROPIC_API_KEY"
required_secret_value=""
fi
;;
langgraph|hermes)
required_secret_name="MOLECULE_STAGING_OPENAI_KEY"
required_secret_value="${E2E_OPENAI_API_KEY:-}"
;;
*)
echo "::warning::Unknown E2E_RUNTIME='${E2E_RUNTIME}' — skipping LLM-key check"
required_secret_name=""
required_secret_value="present"
;;
esac
if [ -n "$required_secret_name" ] && [ -z "$required_secret_value" ]; then
echo "::error::${required_secret_name} secret not set for runtime=${E2E_RUNTIME} — workspaces will fail at boot with 'No provider API key found'"
exit 2
fi
echo "OpenAI key present ✓ (len=${#E2E_OPENAI_API_KEY})"
echo "LLM key present ✓ (runtime=${E2E_RUNTIME}, key=${required_secret_name}, len=${#required_secret_value})"
- name: CP staging health preflight
run: |
@@ -164,11 +217,27 @@ jobs:
and o.get('instance_status') not in ('purged',)]
print('\n'.join(candidates))
" 2>/dev/null)
# Per-slug verified DELETE (was `>/dev/null || true` — see
# molecule-controlplane#420). Surface non-2xx as a workflow
# warning naming the leaked slug; don't exit 1 (sweeper is
# the safety net within ~45 min).
leaks=()
for slug in $orgs; do
echo "Safety-net teardown: $slug"
curl -sS -X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
code=$(curl -sS -o /tmp/saas-cleanup.out -w "%{http_code}" \
-X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"confirm\":\"$slug\"}" >/dev/null || true
-d "{\"confirm\":\"$slug\"}" \
|| echo "000")
if [ "$code" = "200" ] || [ "$code" = "204" ]; then
echo "[teardown] deleted $slug (HTTP $code)"
else
echo "::warning::saas teardown for $slug returned HTTP $code — sweep-stale-e2e-orgs will catch it within ~45 min. Body: $(head -c 300 /tmp/saas-cleanup.out 2>/dev/null)"
leaks+=("$slug")
fi
done
if [ ${#leaks[@]} -gt 0 ]; then
echo "::warning::saas teardown left ${#leaks[@]} leak(s): ${leaks[*]}"
fi
exit 0
+19 -4
View File
@@ -50,7 +50,7 @@ jobs:
E2E_INTENTIONAL_FAILURE: "1"
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Verify admin token present
run: |
@@ -89,7 +89,7 @@ jobs:
- name: Open issue if safety net is broken
if: failure()
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const title = "🚨 E2E teardown safety net broken";
@@ -143,10 +143,25 @@ jobs:
and o.get('status') not in ('purged',)]
print('\n'.join(candidates))
" 2>/dev/null)
# Per-slug verified DELETE — see molecule-controlplane#420.
# Failures surface as workflow warnings; the sweeper is the
# safety net within ~45 min.
leaks=()
for slug in $orgs; do
curl -sS -X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
code=$(curl -sS -o /tmp/sanity-cleanup.out -w "%{http_code}" \
-X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"confirm\":\"$slug\"}" >/dev/null || true
-d "{\"confirm\":\"$slug\"}" \
|| echo "000")
if [ "$code" = "200" ] || [ "$code" = "204" ]; then
echo "[teardown] deleted $slug (HTTP $code)"
else
echo "::warning::sanity teardown for $slug returned HTTP $code — sweep-stale-e2e-orgs will catch it within ~45 min. Body: $(head -c 300 /tmp/sanity-cleanup.out 2>/dev/null)"
leaks+=("$slug")
fi
done
if [ ${#leaks[@]} -gt 0 ]; then
echo "::warning::sanity teardown left ${#leaks[@]} leak(s): ${leaks[*]}"
fi
exit 0
+3 -3
View File
@@ -55,7 +55,7 @@ jobs:
outputs:
run: ${{ steps.decide.outputs.run }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: filter
with:
@@ -93,14 +93,14 @@ jobs:
echo "::notice::Harness Replays no-op pass (paths filter excluded this commit)."
- if: needs.detect-changes.outputs.run == 'true'
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Checkout sibling plugin repo
# Dockerfile.tenant copies molecule-ai-plugin-github-app-auth/
# at the build-context root (see workspace-server/Dockerfile.tenant
# line 19). PLUGIN_REPO_PAT pattern matches publish-workspace-server-image.yml.
if: needs.detect-changes.outputs.run == 'true'
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: Molecule-AI/molecule-ai-plugin-github-app-auth
path: molecule-ai-plugin-github-app-auth
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
promote:
runs-on: ubuntu-latest
steps:
- uses: imjasonh/setup-crane@31b88efe9de28ae0ffa220711af4b60be9435f6e # v0.4
- uses: imjasonh/setup-crane@6da1ae018866400525525ce74ff892880c099987 # v0.5
- name: GHCR login
run: |
+3 -3
View File
@@ -42,7 +42,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Log in to GHCR
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
@@ -52,7 +52,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Compute tags
id: tags
@@ -85,7 +85,7 @@ jobs:
echo "ws_url=${WS_URL}" >> "$GITHUB_OUTPUT"
- name: Build & push canvas image to GHCR
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: ./canvas
file: ./canvas/Dockerfile
+14 -2
View File
@@ -81,7 +81,7 @@ jobs:
version: ${{ steps.version.outputs.version }}
wheel_sha256: ${{ steps.wheel_hash.outputs.wheel_sha256 }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
@@ -327,7 +327,19 @@ jobs:
echo "::error::publish job did not expose a version output — cascade cannot fan out"
exit 1
fi
TEMPLATES="claude-code langgraph crewai autogen deepagents hermes gemini-cli openclaw"
# All 9 active workspace template repos. The PR #2536 pruning
# ("deprecated, no shipping images") was empirically wrong:
# continuous-synth-e2e.yml defaults to langgraph as its primary
# canary (line 44), and every excluded template had successful
# publish-image runs as of 2026-05-03 — none were dormant.
# Symptom of the prune: today's a2a-sdk strict-mode fix
# (#2566 / commit e1628c4) cascaded to 4 templates but never
# reached langgraph, so the synth-E2E correctly canary'd a fix
# that had landed but not deployed. Re-added the 5 templates.
# Long-term: derive this list from manifest.json so cascade
# scope can't drift from E2E scope — tracked in RFC #388 as a
# Phase-1 invariant.
TEMPLATES="claude-code hermes openclaw codex langgraph crewai autogen deepagents gemini-cli"
FAILED=""
for tpl in $TEMPLATES; do
REPO="Molecule-AI/molecule-ai-workspace-template-$tpl"
@@ -68,7 +68,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Checkout sibling plugin repo
# workspace-server/Dockerfile expects
@@ -83,7 +83,7 @@ jobs:
# The PAT needs Contents:Read on Molecule-AI/molecule-ai-plugin-
# github-app-auth. Falls back to the default token for the (rare)
# case where an operator made the plugin repo public.
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: Molecule-AI/molecule-ai-plugin-github-app-auth
path: molecule-ai-plugin-github-app-auth
@@ -97,7 +97,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Compute tags
id: tags
@@ -131,7 +131,7 @@ jobs:
# drifted 10 days behind staging — same class of bug, different
# mechanism.
- name: Build & push platform image to GHCR (staging-<sha> + staging-latest)
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: ./workspace-server/Dockerfile
@@ -155,7 +155,7 @@ jobs:
org.opencontainers.image.description=Molecule AI platform (Go API server) — pending canary verify
- name: Build & push tenant image to GHCR (staging-<sha> + staging-latest)
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: ./workspace-server/Dockerfile.tenant
+3 -3
View File
@@ -49,7 +49,7 @@ jobs:
timeout-minutes: 10
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Verify RAILWAY_AUDIT_TOKEN present
# Schedule trigger: hard-fail when the secret is missing —
@@ -143,7 +143,7 @@ jobs:
- name: Open / update drift issue
if: failure() && steps.audit.outputs.rc == '1'
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
AUDIT_LOG: ${{ steps.audit.outputs.log }}
with:
@@ -184,7 +184,7 @@ jobs:
# issue with a confirmation comment so the queue doesn't carry
# stale ones.
if: success() && steps.audit.outputs.rc == '0'
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const runURL = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
+66 -12
View File
@@ -17,7 +17,7 @@ name: redeploy-tenants-on-main
# 1. publish-workspace-server-image completes → new :latest in GHCR.
# 2. This workflow fires via workflow_run, waits 30s for GHCR's
# CDN to propagate the new tag to the region the tenants pull from.
# 3. Calls redeploy-fleet with canary_slug=hongmingwang and a 60s
# 3. Calls redeploy-fleet with canary_slug=hongming and a 60s
# soak. Canary proves the image boots; batches follow.
# 4. Any failure aborts the rollout and leaves older tenants on the
# prior image — safer default than half-and-half state.
@@ -34,15 +34,34 @@ on:
workflow_dispatch:
inputs:
target_tag:
description: 'Tenant image tag to deploy (e.g. "latest" or "a59f1a6c"). Defaults to latest when empty.'
# Empty default → auto-trigger and dispatch-without-input both
# resolve to `staging-<short_head_sha>` (the digest publish-image
# just pushed). Pre-fix this defaulted to 'latest', which only
# gets retagged by canary-verify's promote-to-latest job — and
# that job soft-skips when CANARY_TENANT_URLS is unset (the
# current state, until Phase 2 canary fleet is live). Result:
# `:latest` had been pinned to a 4-day-old digest (2026-04-28)
# while every main push pushed fresh `staging-<sha>` images;
# every prod redeploy pulled the stale `:latest` and the verify
# step correctly flagged 3/3 tenants STALE. Pulling the
# just-published `staging-<sha>` directly skips the dead retag
# path. When canary fleet is real, this workflow should chain
# on canary-verify completion (workflow_run from canary-verify),
# not publish-image — separate, smaller PR.
description: 'Tenant image tag to deploy (e.g. "latest", "staging-a59f1a6c"). Empty = auto staging-<head_sha>.'
required: false
type: string
default: 'latest'
default: ''
canary_slug:
description: 'Tenant slug to deploy first + soak (empty = skip canary, fan out immediately).'
required: false
type: string
default: 'hongmingwang'
# Must be an actual prod tenant slug (current: hongming,
# chloe-dong, reno-stars). The previous default 'hongmingwang'
# didn't match any tenant — CP soft-skipped the missing canary
# and the fleet rolled out without the soak gate, defeating the
# whole point of canary-first.
default: 'hongming'
soak_seconds:
description: 'Seconds to wait after canary before fanning out.'
required: false
@@ -91,12 +110,40 @@ jobs:
steps:
- name: Wait for GHCR tag propagation
# GHCR's edge cache takes ~15-30s to consistently serve the new
# :latest manifest after the registry accepts the push. Without
# this sleep, the first tenant's docker pull sometimes races
# and fetches the previous digest; sleeping is the cheapest
# way to reduce that without polling GHCR for the new digest.
# manifest after the registry accepts the push. Without this
# sleep, the first tenant's docker pull sometimes races and
# fetches the previous digest; sleeping is the cheapest way to
# reduce that without polling GHCR for the new digest.
run: sleep 30
- name: Compute target tag
id: tag
# Resolution order:
# 1. Operator-supplied input (workflow_dispatch with explicit
# tag) → used verbatim. Lets ops pin `latest` for emergency
# rollback to last canary-verified digest, or pin a specific
# `staging-<sha>` to roll back to a known-good build.
# 2. Default → `staging-<short_head_sha>`. The just-published
# digest. Bypasses the `:latest` retag path that's currently
# dead (canary-verify soft-skips without canary fleet, so
# the only thing retagging `:latest` today is the manual
# promote-latest.yml — last run 2026-04-28). Auto-trigger
# from workflow_run uses workflow_run.head_sha; manual
# dispatch with no input falls through to github.sha.
env:
INPUT_TAG: ${{ inputs.target_tag }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
run: |
set -euo pipefail
if [ -n "${INPUT_TAG:-}" ]; then
echo "target_tag=$INPUT_TAG" >> "$GITHUB_OUTPUT"
echo "Using operator-pinned tag: $INPUT_TAG"
else
SHORT="${HEAD_SHA:0:7}"
echo "target_tag=staging-$SHORT" >> "$GITHUB_OUTPUT"
echo "Using auto tag: staging-$SHORT (head_sha=$HEAD_SHA)"
fi
- name: Call CP redeploy-fleet
# CP_ADMIN_API_TOKEN must be set as a repo/org secret on
# Molecule-AI/molecule-core, matching the staging/prod CP's
@@ -105,8 +152,8 @@ jobs:
env:
CP_URL: ${{ vars.CP_URL || 'https://api.moleculesai.app' }}
CP_ADMIN_API_TOKEN: ${{ secrets.CP_ADMIN_API_TOKEN }}
TARGET_TAG: ${{ inputs.target_tag || 'latest' }}
CANARY_SLUG: ${{ inputs.canary_slug || 'hongmingwang' }}
TARGET_TAG: ${{ steps.tag.outputs.target_tag }}
CANARY_SLUG: ${{ inputs.canary_slug || 'hongming' }}
SOAK_SECONDS: ${{ inputs.soak_seconds || '60' }}
BATCH_SIZE: ${{ inputs.batch_size || '3' }}
DRY_RUN: ${{ inputs.dry_run || false }}
@@ -209,7 +256,7 @@ jobs:
# workflow_run.head_sha is the SHA that just published.
env:
EXPECTED_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
TARGET_TAG: ${{ inputs.target_tag || 'latest' }}
TARGET_TAG: ${{ steps.tag.outputs.target_tag }}
# Tenant subdomain template — slugs from the response are
# appended. Production CP issues `<slug>.moleculesai.app`;
# staging CP issues `<slug>.staging.moleculesai.app`. This
@@ -218,13 +265,20 @@ jobs:
run: |
set -euo pipefail
if [ "$TARGET_TAG" != "latest" ] && [ "$TARGET_TAG" != "$EXPECTED_SHA" ]; then
EXPECTED_SHORT="${EXPECTED_SHA:0:7}"
if [ "$TARGET_TAG" != "latest" ] \
&& [ "$TARGET_TAG" != "$EXPECTED_SHA" ] \
&& [ "$TARGET_TAG" != "staging-$EXPECTED_SHORT" ]; then
# workflow_dispatch with a pinned tag that isn't the head
# SHA — operator is rolling back / pinning. Skip the
# verification because we don't have the expected SHA in
# this context (would need to crane-inspect the GHCR
# manifest, which is a follow-up). Failing-open here is
# safe: the operator chose the tag deliberately.
#
# `staging-<short_head_sha>` IS verified — it's the new
# auto-trigger default (see Compute target tag step) and
# the digest under that tag SHOULD match EXPECTED_SHA.
echo "::notice::target_tag=$TARGET_TAG (operator-pinned) — skipping per-tenant SHA verification."
exit 0
fi
@@ -172,12 +172,50 @@ jobs:
jq -r '.results[]? | "| \(.slug) | \(.phase) | \(.ssm_status // "-") | \(.ssm_exit_code) | \(.healthz_ok) | \(.error // "-") |"' "$HTTP_RESPONSE" || true
} >> "$GITHUB_STEP_SUMMARY"
if [ "$HTTP_CODE" != "200" ]; then
# Distinguish "real fleet failure" from "E2E teardown race".
#
# CP returns HTTP 500 + ok=false whenever ANY tenant in the
# fleet failed SSM or healthz. In practice the recurring source
# of these is ephemeral test tenants being torn down by their
# parent E2E run mid-redeploy: the EC2 dies → SSM exit=2 or
# healthz timeout → CP marks the fleet failed → this workflow
# goes red even though every operator-facing tenant rolled fine.
#
# Ephemeral slug prefixes (kept in sync with sweep-stale-e2e-orgs.yml
# — see that file for the source-of-truth list and rationale):
# - e2e-* — canvas/saas/ext E2E suites
# - rt-e2e-* — runtime-test harness fixtures (RFC #2251)
# Long-lived prefixes that are NOT ephemeral and MUST hard-fail:
# demo-prep, dryrun-*, dryrun2-*, plus all human tenant slugs.
#
# Filter: if HTTP=500/ok=false AND every failed slug matches an
# ephemeral prefix, treat as soft-warn and let the verify step
# downstream handle unreachable-vs-stale (#2402). Any non-ephemeral
# failure or a non-500 HTTP response remains a hard failure.
OK=$(jq -r '.ok // "false"' "$HTTP_RESPONSE")
FAILED_SLUGS=$(jq -r '
.results[]?
| select((.healthz_ok != true) or (.ssm_status != "Success"))
| .slug' "$HTTP_RESPONSE" 2>/dev/null || true)
EPHEMERAL_PREFIX_RE='^(e2e-|rt-e2e-)'
NON_EPHEMERAL_FAILED=$(printf '%s\n' "$FAILED_SLUGS" | grep -v '^$' | grep -Ev "$EPHEMERAL_PREFIX_RE" || true)
if [ "$HTTP_CODE" = "200" ] && [ "$OK" = "true" ]; then
: # happy path — fall through to verification
elif [ "$HTTP_CODE" = "500" ] && [ -z "$NON_EPHEMERAL_FAILED" ] && [ -n "$FAILED_SLUGS" ]; then
COUNT=$(printf '%s\n' "$FAILED_SLUGS" | grep -Ec "$EPHEMERAL_PREFIX_RE" || true)
echo "::warning::redeploy-fleet returned HTTP 500 but every failed tenant ($COUNT) is ephemeral (e2e-*/rt-e2e-*) — treating as teardown race, soft-warning."
printf '%s\n' "$FAILED_SLUGS" | sed 's/^/::warning:: failed: /'
elif [ "$HTTP_CODE" != "200" ]; then
echo "::error::redeploy-fleet returned HTTP $HTTP_CODE"
if [ -n "$NON_EPHEMERAL_FAILED" ]; then
echo "::error::non-ephemeral tenant(s) failed:"
printf '%s\n' "$NON_EPHEMERAL_FAILED" | sed 's/^/::error:: /'
fi
exit 1
fi
OK=$(jq -r '.ok' "$HTTP_RESPONSE")
if [ "$OK" != "true" ]; then
else
# HTTP=200 but ok=false (shouldn't happen with current CP
# but keep the gate for completeness).
echo "::error::redeploy-fleet reported ok=false (see summary for which tenant halted the rollout)"
exit 1
fi
+15 -4
View File
@@ -26,11 +26,22 @@ jobs:
runs-on: ubuntu-latest
# Only fire for bot-authored PRs. Human CEO PRs (staging→main promotion)
# are intentional and pass through.
#
# Head-ref guard: never retarget a PR whose head IS `staging` — those
# are the auto-promote staging→main PRs (opened by molecule-ai[bot]
# since #2586 switched to an App token, which now passes the bot
# filter below). Retargeting head=staging onto base=staging fails
# with HTTP 422 "no new commits between base 'staging' and head
# 'staging'", which used to surface as a noisy red workflow run on
# every auto-promote (caught 2026-05-03 on PR #2588).
if: >-
github.event.pull_request.user.type == 'Bot'
|| endsWith(github.event.pull_request.user.login, '[bot]')
|| github.event.pull_request.user.login == 'app/molecule-ai'
|| github.event.pull_request.user.login == 'molecule-ai[bot]'
github.event.pull_request.head.ref != 'staging'
&& (
github.event.pull_request.user.type == 'Bot'
|| endsWith(github.event.pull_request.user.login, '[bot]')
|| github.event.pull_request.user.login == 'app/molecule-ai'
|| github.event.pull_request.user.login == 'molecule-ai[bot]'
)
steps:
- name: Retarget PR base to staging
id: retarget
+1 -1
View File
@@ -60,7 +60,7 @@ jobs:
name: PyPI-latest install + import smoke
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.11'
+2 -2
View File
@@ -52,7 +52,7 @@ jobs:
outputs:
wheel: ${{ steps.decide.outputs.wheel }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: filter
with:
@@ -93,7 +93,7 @@ jobs:
echo "No workspace/ / scripts/{build_runtime_package,wheel_smoke}.py / workflow changes — wheel gate satisfied without rebuilding."
echo "::notice::PR-built wheel + import smoke no-op pass (paths filter excluded this commit)."
- if: needs.detect-changes.outputs.wheel == 'true'
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- if: needs.detect-changes.outputs.wheel == 'true'
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
+2 -1
View File
@@ -34,6 +34,7 @@ on:
- ".github/workflows/secret-scan.yml"
- ".github/workflows/secret-pattern-drift.yml"
- ".github/scripts/lint_secret_pattern_drift.py"
- ".githooks/pre-commit"
workflow_dispatch:
# GITHUB_TOKEN scoped to read-only. The lint only does git checkout
@@ -47,7 +48,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
+1 -1
View File
@@ -40,7 +40,7 @@ jobs:
name: Scan diff for credential-shaped strings
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 2 # need previous commit to diff against on push events
+129
View File
@@ -0,0 +1,129 @@
name: Sweep stale AWS Secrets Manager secrets
# Janitor for per-tenant AWS Secrets Manager secrets
# (`molecule/tenant/<org_id>/bootstrap`) whose backing tenant no
# longer exists. Parallel-shape to sweep-cf-tunnels.yml and
# sweep-cf-orphans.yml — different cloud, same justification.
#
# Why this exists separately from a long-term reconciler integration:
# - molecule-controlplane's tenant_resources audit table (mig 024)
# currently tracks four resource kinds: CloudflareTunnel,
# CloudflareDNS, EC2Instance, SecurityGroup. SecretsManager is
# not in the list, so the existing reconciler doesn't catch
# orphan secrets.
# - At ~$0.40/secret/month the cost grew to ~$19/month before this
# sweeper was written, indicating ~45+ orphan secrets from
# crashed provisions and incomplete deprovision flows.
# - The proper fix (KindSecretsManagerSecret + recorder hook +
# reconciler enumerator) is filed as a separate controlplane
# issue. This sweeper is the immediate cost-relief stopgap.
#
# IAM principal: AWS_JANITOR_ACCESS_KEY_ID / AWS_JANITOR_SECRET_ACCESS_KEY.
# This is a DEDICATED principal — the production `molecule-cp` IAM
# user lacks `secretsmanager:ListSecrets` (it only has
# Get/Create/Update/Delete on specific resources, scoped to its
# operational needs). The janitor needs ListSecrets across the
# `molecule/tenant/*` prefix, which warrants a separate principal so
# we don't broaden the prod-CP policy.
#
# Safety: the script's MAX_DELETE_PCT gate (default 50%, mirroring
# sweep-cf-orphans.yml — tenant secrets are durable by design, unlike
# the mostly-orphan tunnels) refuses to nuke past the threshold.
on:
schedule:
# Hourly at :30 — offsets from sweep-cf-orphans (:15) and
# sweep-cf-tunnels (:45) so the three janitors don't burst the
# CP admin endpoints at the same minute.
- cron: '30 * * * *'
workflow_dispatch:
inputs:
dry_run:
description: "Dry run only — list what would be deleted, no deletion"
required: false
type: boolean
default: true
max_delete_pct:
description: "Override safety gate (default 50, set higher only for major cleanup)"
required: false
default: "50"
grace_hours:
description: "Skip secrets created within this many hours (default 24)"
required: false
default: "24"
# Don't let two sweeps race the same AWS account.
concurrency:
group: sweep-aws-secrets
cancel-in-progress: false
permissions:
contents: read
jobs:
sweep:
name: Sweep AWS Secrets Manager
runs-on: ubuntu-latest
# 30 min cap, mirroring the other janitors. AWS DeleteSecret is
# fast (~0.3s/call) so even a 100+ backlog drains in seconds
# under the 8-way xargs parallelism, but the cap is set generously
# to leave headroom for any actual API hang.
timeout-minutes: 30
env:
AWS_REGION: ${{ secrets.AWS_REGION || 'us-east-1' }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_JANITOR_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_JANITOR_SECRET_ACCESS_KEY }}
CP_PROD_ADMIN_TOKEN: ${{ secrets.CP_PROD_ADMIN_TOKEN }}
CP_STAGING_ADMIN_TOKEN: ${{ secrets.CP_STAGING_ADMIN_TOKEN }}
MAX_DELETE_PCT: ${{ github.event.inputs.max_delete_pct || '50' }}
GRACE_HOURS: ${{ github.event.inputs.grace_hours || '24' }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Verify required secrets present
id: verify
# Schedule-vs-dispatch behaviour split mirrors sweep-cf-orphans
# and sweep-cf-tunnels (hardened 2026-04-28). Same principle:
# - schedule → exit 1 on missing secrets (red CI surfaces it)
# - workflow_dispatch → exit 0 with warning (operator-driven,
# they already accepted the repo state)
run: |
missing=()
for var in AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY CP_PROD_ADMIN_TOKEN CP_STAGING_ADMIN_TOKEN; do
if [ -z "${!var:-}" ]; then
missing+=("$var")
fi
done
if [ ${#missing[@]} -gt 0 ]; then
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "::warning::skipping sweep — secrets not configured: ${missing[*]}"
echo "::warning::set them at Settings → Secrets and Variables → Actions, then rerun."
echo "::warning::AWS_JANITOR_* must belong to a principal with secretsmanager:ListSecrets and secretsmanager:DeleteSecret on molecule/tenant/* (the prod molecule-cp principal lacks ListSecrets)."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "::error::sweep cannot run — required secrets missing: ${missing[*]}"
echo "::error::set them at Settings → Secrets and Variables → Actions, or disable this workflow."
echo "::error::AWS_JANITOR_* must belong to a principal with secretsmanager:ListSecrets and secretsmanager:DeleteSecret on molecule/tenant/*."
exit 1
fi
echo "All required secrets present ✓"
echo "skip=false" >> "$GITHUB_OUTPUT"
- name: Run sweep
if: steps.verify.outputs.skip != 'true'
# Schedule-vs-dispatch dry-run asymmetry mirrors sweep-cf-tunnels:
# - Scheduled: input empty → "false" → --execute (the whole
# point of an hourly janitor).
# - Manual workflow_dispatch: input default true → dry-run;
# operator must flip it to actually delete.
run: |
set -euo pipefail
if [ "${{ github.event.inputs.dry_run || 'false' }}" = "true" ]; then
echo "Running in dry-run mode — no deletions"
bash scripts/ops/sweep-aws-secrets.sh
else
echo "Running with --execute — will delete identified orphans"
bash scripts/ops/sweep-aws-secrets.sh --execute
fi
+1 -1
View File
@@ -78,7 +78,7 @@ jobs:
MAX_DELETE_PCT: ${{ github.event.inputs.max_delete_pct || '50' }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Verify required secrets present
id: verify
+17 -5
View File
@@ -47,10 +47,22 @@ jobs:
sweep:
name: Sweep CF tunnels
runs-on: ubuntu-latest
# 5 min surfaces hangs (CF API stall, slow pagination on busy
# accounts). Realistic worst case is ~3 min: 2 CP curls + N CF
# list pages + N×CF-DELETE, each capped at 10-15s by curl -m.
timeout-minutes: 5
# 30 min cap. Was 5 min on the theory that the only thing that
# could take >5min is a CF-API hang — but on 2026-05-02 a backlog
# of 672 stale tunnels accumulated (large staging E2E run + delayed
# sweep) and the serial `curl -X DELETE` loop (~0.7s/tunnel) needed
# ~7-8min to drain. The 5-min cap killed the run mid-sweep
# (cancelled at 424/672, see run 25248788312); a manual rerun
# finished the remainder fine.
#
# The fix is two-part: parallelize the delete loop (8-way xargs in
# the script — see scripts/ops/sweep-cf-tunnels.sh), AND raise the
# cap so a one-off backlog doesn't trip a hangs-detector that
# turned out to be a real-job-too-slow detector. With 8-way
# parallelism, 600+ tunnels drains in ~60s; 30 min is generous
# headroom for actual hangs to still surface (and is in line with
# the sweep-cf-orphans companion job).
timeout-minutes: 30
env:
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
@@ -59,7 +71,7 @@ jobs:
MAX_DELETE_PCT: ${{ github.event.inputs.max_delete_pct || '90' }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Verify required secrets present
id: verify
+25 -10
View File
@@ -25,16 +25,23 @@ name: Sweep stale e2e-* orgs (staging)
on:
schedule:
# Every hour on the hour. E2E orgs are short-lived (~10-25 min wall
# clock from create to teardown). Anything older than the
# MAX_AGE_MINUTES threshold below is presumed dead.
- cron: '0 * * * *'
# Every 15 min. E2E orgs are short-lived (~8-25 min wall clock from
# create to teardown — canary is ~8 min, full SaaS ~25 min). The
# previous hourly + 120-min stale threshold meant a leaked tenant
# could keep an EC2 alive for up to 2 hours, eating ~2 vCPU per
# leak. Tightening the cadence + threshold reduces the worst-case
# leak window from 120 min to ~45 min (15-min sweep cadence + 30-min
# threshold) without risk of catching in-progress runs (the longest
# e2e run is the 25-min canary, well under the 30-min threshold).
# See molecule-controlplane#420 for the leak-class accounting that
# motivated this tightening.
- cron: '*/15 * * * *'
workflow_dispatch:
inputs:
max_age_minutes:
description: "Delete e2e-* orgs older than N minutes (default 120)"
description: "Delete e2e-* orgs older than N minutes (default 30)"
required: false
default: "120"
default: "30"
dry_run:
description: "Dry run only — list what would be deleted"
required: false
@@ -58,7 +65,7 @@ jobs:
env:
MOLECULE_CP_URL: https://staging-api.moleculesai.app
ADMIN_TOKEN: ${{ secrets.MOLECULE_STAGING_ADMIN_TOKEN }}
MAX_AGE_MINUTES: ${{ github.event.inputs.max_age_minutes || '120' }}
MAX_AGE_MINUTES: ${{ github.event.inputs.max_age_minutes || '30' }}
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
# Refuse to delete more than this many orgs in one tick. If the
# CP DB is briefly empty (or the admin endpoint goes weird and
@@ -87,20 +94,28 @@ jobs:
> orgs.json
# Filter:
# 1. slug starts with 'e2e-' (covers e2e-, e2e-canary-,
# e2e-canvas-* — all variants the test scripts mint)
# 1. slug starts with one of the ephemeral test prefixes:
# - 'e2e-' — covers e2e-canary-, e2e-canvas-*, etc.
# - 'rt-e2e-' — runtime-test harness fixtures (RFC #2251);
# missing this prefix left two such tenants
# orphaned 8h on staging (2026-05-03), then
# hard-failed redeploy-tenants-on-staging
# and broke the staging→main auto-promote
# chain. Kept in sync with the EPHEMERAL_PREFIX_RE
# regex in redeploy-tenants-on-staging.yml.
# 2. created_at is older than MAX_AGE_MINUTES ago
# Output one slug per line to a file the next step reads.
python3 > stale_slugs.txt <<'PY'
import json, os
from datetime import datetime, timezone, timedelta
EPHEMERAL_PREFIXES = ("e2e-", "rt-e2e-")
with open("orgs.json") as f:
data = json.load(f)
max_age = int(os.environ["MAX_AGE_MINUTES"])
cutoff = datetime.now(timezone.utc) - timedelta(minutes=max_age)
for o in data.get("orgs", []):
slug = o.get("slug", "")
if not slug.startswith("e2e-"):
if not slug.startswith(EPHEMERAL_PREFIXES):
continue
created = o.get("created_at")
if not created:
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
name: Ops scripts (unittest)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.11'
+2 -3
View File
@@ -4,10 +4,9 @@
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "zinc",
"cssVariables": false
"baseColor": "neutral",
"cssVariables": true
},
"aliases": {
"components": "@/components",
+22 -2
View File
@@ -169,7 +169,17 @@ export default async function globalSetup(_config: FullConfig): Promise<void> {
orgID = row.id;
return true;
}
if (row.instance_status === "failed") throw new Error(`provision failed: ${slug}`);
if (row.instance_status === "failed") {
// Dump every diagnostic field the admin row carries — boot stage,
// last error, terraform/SSM state, etc. The bare slug message used
// to surface ZERO context, so triaging a failed provision meant
// re-running locally to repro. Now the failure log carries enough
// to point at the right subsystem (CP/AWS/SSM/runtime) without a
// second round-trip.
throw new Error(
`provision failed: ${slug} — admin-orgs row: ${JSON.stringify(row)}`,
);
}
return null;
},
PROVISION_TIMEOUT_MS,
@@ -249,7 +259,17 @@ export default async function globalSetup(_config: FullConfig): Promise<void> {
if (r.status !== 200) return null;
if (r.body?.status === "online") return true;
if (r.body?.status === "failed") {
throw new Error(`Workspace failed: ${r.body.last_sample_error || ""}`);
// last_sample_error is often empty when the failure happens before
// the agent emits a sample (e.g. boot crash, image pull error,
// missing PYTHONPATH, OpenAI quota at startup). Dumping the full
// body gives triage the boot_stage / last_error / image fields it
// needs without a second probe. Otherwise this propagates as a
// bare "Workspace failed: " — the exact useless message that
// sent #2632 to the issue tracker.
const detail = r.body.last_sample_error
? r.body.last_sample_error
: `(no last_sample_error) full body: ${JSON.stringify(r.body)}`;
throw new Error(`Workspace failed: ${detail}`);
}
return null;
},
+389 -984
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -37,10 +37,10 @@
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.5",
"autoprefixer": "^10.4.0",
"jsdom": "^29.1.0",
"postcss": "^8.5.12",
"tailwindcss": "^3.4.0",
"@tailwindcss/postcss": "^4.0.0",
"jsdom": "^29.1.1",
"postcss": "^8.5.13",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0",
"vitest": "^4.1.2"
}
+1 -2
View File
@@ -1,6 +1,5 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
"@tailwindcss/postcss": {},
},
};
+125 -13
View File
@@ -1,28 +1,139 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
/*
* Tailwind v4 defaults the `dark:` variant to `prefers-color-scheme: dark`.
* Our theme switcher writes `data-theme="dark"` on <html> instead (so user
* choice via the toggle wins over OS preference). Re-bind `dark:` to that
* attribute so component classes like `dark:bg-zinc-800` track the same
* source of truth as the `[data-theme="dark"]` token overrides below.
*/
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
/*
* Load order:
* 1. Tailwind core (v4) — provides preflight + utility generation.
* 2. xterm — overrides preflight on its own .xterm-* class names; must
* load AFTER tailwind so its specificity wins.
* 3. theme-tokens.css — canvas-only motion + deploy animation vars
* (--mol-duration-*, --mol-easing-*, --mol-deploy-*). NOT colour
* tokens; the warm-paper @theme block below owns those.
* 4. settings-panel.css / org-deploy.css — feature stylesheets that
* reference the variables above.
*/
@import "xterm/css/xterm.css";
/* Theme tokens MUST load before any feature stylesheet that
references them so custom properties are in scope. */
@import "../styles/theme-tokens.css";
@import "../styles/settings-panel.css";
@import "../styles/org-deploy.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
/*
* Warm-paper semantic tokens — light defaults via @theme, dark
* overrides via [data-theme="dark"]. Names are role-based
* (`bg-surface`, `text-ink`, `border-line`) not colour-based, so the
* same component classes work in either mode.
*
* Source of truth: molecule-app/app/globals.css. Keep aligned across
* surfaces (landing, market, app, canvas) so a token tweak ripples
* everywhere via a single PR per repo.
*
* Theme preference is persisted in the `mol_theme` cookie scoped to
* Domain=.moleculesai.app so the choice follows the user across
* subdomains. The inline boot script in app/layout.tsx applies it
* before paint to eliminate flash.
*/
@theme {
/* Surface — page / elevated card / sunken input / deep card */
--color-surface: #fafaf7;
--color-surface-elevated: #ffffff;
--color-surface-sunken: #f3f1ec;
--color-surface-card: #efece4;
/* Borders */
--color-line: #e6e2d8;
--color-line-soft: #efece4;
/* Text */
--color-ink: #15181c;
--color-ink-mid: #5a5e66;
--color-ink-soft: #8b8e95;
/* Brand + state */
--color-accent: #3b5bdb;
--color-accent-strong: #1a2f99;
--color-warm: #c0532b;
--color-good: #2f7a4d;
--color-bad: #b94e4a;
}
[data-theme="dark"] {
--color-surface: #0e1014;
--color-surface-elevated: #15181c;
--color-surface-sunken: #0a0b0e;
--color-surface-card: #1a1d23;
--color-line: #2a2f3a;
--color-line-soft: #1f2329;
--color-ink: #f4f1e9;
--color-ink-mid: #c8c2b4;
--color-ink-soft: #8d92a0;
/* Accents brighten slightly for AA contrast on dark backgrounds. */
--color-accent: #6883e8;
--color-accent-strong: #8aa1ee;
--color-warm: #d96f48;
--color-good: #4ca06e;
--color-bad: #d27773;
}
:root {
color-scheme: light;
}
[data-theme="dark"] {
color-scheme: dark;
}
/*
* Always-dark surface tokens. Terminals (xterm), the console modal,
* and log streams stay dark in both modes — readable green-on-black
* code surfaces don't translate cleanly to a light theme. Components
* that should not light-flip use `bg-bg`, `bg-bg-elev`, `bg-bg-card`,
* `text-ink-mute`, `text-ink-dim`, `border-line-strong` instead of
* the warm-paper utilities above.
*
* Distinct names (bg-* / ink-mute / ink-dim / line-strong) so they
* don't collide with the warm-paper namespace (surface / ink /
* line). Both palettes coexist; the choice between them is per
* component, not per theme.
*/
@theme {
--color-bg: rgb(9 9 11); /* zinc-950 */
--color-bg-elev: rgb(24 24 27); /* zinc-900 */
--color-bg-card: rgb(39 39 42); /* zinc-800 */
--color-line-strong: rgb(63 63 70); /* zinc-700 */
--color-ink-mute: rgb(161 161 170); /* zinc-400 */
--color-ink-dim: rgb(113 113 122); /* zinc-500 */
--color-accent-dim: rgb(96 165 250);/* blue-400 */
--color-plasma: rgb(59 130 246); /* blue-500 */
--color-warn: rgb(251 191 36); /* amber-400 */
}
body {
margin: 0;
padding: 0;
overflow: hidden;
background: #09090b;
color: #e4e4e7;
background-color: var(--color-surface);
color: var(--color-ink);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* React Flow overrides for dark theme */
/* React Flow overrides for both themes. Edge stroke pulls from the
semantic line token so dark mode keeps its existing zinc-700 look
and light mode picks up the warm-paper line colour. */
.react-flow__edge-path {
stroke: #3f3f46 !important;
stroke: var(--color-line) !important;
stroke-width: 1.5 !important;
}
@@ -58,7 +169,8 @@ body {
transition: box-shadow var(--mol-duration-fast) ease;
}
/* Scrollbar styling */
/* Scrollbar styling. Track + thumb pull from the surface tokens so
they feel native to either theme. */
::-webkit-scrollbar {
width: 6px;
height: 6px;
@@ -69,17 +181,17 @@ body {
}
::-webkit-scrollbar-thumb {
background: #3f3f46;
background: var(--color-line);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #52525b;
background: var(--color-line-strong, var(--color-ink-soft));
}
/* Selection */
::selection {
background: rgba(59, 130, 246, 0.3);
background: color-mix(in srgb, var(--color-accent) 30%, transparent);
}
/* Panel slide animation */
+56 -16
View File
@@ -1,8 +1,14 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { cookies, headers } from "next/headers";
import "./globals.css";
import { AuthGate } from "@/components/AuthGate";
import { CookieConsent } from "@/components/CookieConsent";
import { ThemeProvider } from "@/lib/theme-provider";
import {
THEME_COOKIE,
readThemeCookie,
themeBootScript,
} from "@/lib/theme-cookie";
export const metadata: Metadata = {
title: "Molecule AI",
@@ -15,7 +21,7 @@ export default async function RootLayout({
children: React.ReactNode;
}) {
// Read the per-request CSP nonce that middleware.ts sets via the
// `x-nonce` request header. This call is load-bearing for TWO
// `x-nonce` request header. This call is load-bearing for THREE
// independent reasons:
//
// 1. It opts the root layout into dynamic rendering. Without a
@@ -31,22 +37,56 @@ export default async function RootLayout({
// is actually read via `headers()`. The header's existence on
// the request isn't enough — Next.js watches for the read.
//
// Keeping the `nonce` variable unused is intentional: we don't need
// to pass it to any custom <Script nonce={...}> tags right now, the
// framework takes care of its own bootstrap scripts once the read
// happens. Destructuring via `await` + `.get()` is the minimum shape
// Next.js recognizes as "dynamic server-side access".
await headers();
// 3. We need the nonce to attach to the inline theme boot script
// below, otherwise CSP rejects it in production where
// script-src is `'self' 'nonce-{nonce}' 'strict-dynamic'`.
// 'strict-dynamic' propagates trust from a nonce'd script to
// scripts it inserts, but does NOT forgive an un-nonce'd
// sibling — the boot script must carry its own nonce.
const hdrs = await headers();
const nonce = hdrs.get("x-nonce") ?? undefined;
// SSR: read the user's saved preference. For light/dark we can stamp
// data-theme on <html> here so the very first paint matches; for
// "system" we leave the attribute off and let the inline boot script
// resolve from matchMedia before paint.
const cookieStore = await cookies();
const theme = readThemeCookie(cookieStore.get(THEME_COOKIE)?.value);
const initialDataTheme = theme === "system" ? undefined : theme;
return (
<html lang="en">
<body className="bg-zinc-950 text-white">
{/* AuthGate is a client component; it checks the session on mount
and bounces anonymous users to the control plane's login page
when running on a tenant subdomain. Non-SaaS hosts (localhost,
vercel preview URL, apex) pass through unchanged. */}
<AuthGate>{children}</AuthGate>
<CookieConsent />
// suppressHydrationWarning on <html>: the inline boot script below
// mutates `data-theme` before React hydrates (system mode reads
// matchMedia + writes the attribute). That's the entire point of the
// script — eliminate the flash — and it's the documented escape hatch
// for "the server-rendered HTML is intentionally not what React would
// produce client-side at this exact attribute."
<html lang="en" data-theme={initialDataTheme} suppressHydrationWarning>
<head>
{/*
* Boot script: runs synchronously before the body paints, sets
* data-theme on <html> for "system" preference based on the OS
* media query. For explicit light/dark, SSR already set the
* attribute above and the script's write is a no-op.
*
* `nonce` comes from middleware's per-request CSP nonce — see
* the comment block above for why CSP requires this even though
* the page also has 'strict-dynamic'.
*/}
<script
nonce={nonce}
dangerouslySetInnerHTML={{ __html: themeBootScript }}
/>
</head>
<body className="bg-surface text-ink">
<ThemeProvider initialTheme={theme}>
{/* AuthGate is a client component; it checks the session on mount
and bounces anonymous users to the control plane's login page
when running on a tenant subdomain. Non-SaaS hosts (localhost,
vercel preview URL, apex) pass through unchanged. */}
<AuthGate>{children}</AuthGate>
<CookieConsent />
</ThemeProvider>
</body>
</html>
);
+27 -27
View File
@@ -110,15 +110,15 @@ export default function OrgsPage() {
}, []);
if (session === "loading" || (orgs === null && error === null)) {
return <Shell><p className="text-zinc-400">Loading</p></Shell>;
return <Shell><p className="text-ink-mid">Loading</p></Shell>;
}
if (error) {
return (
<Shell>
<p role="alert" className="text-red-400">Error: {error}</p>
<p role="alert" className="text-bad">Error: {error}</p>
<button
onClick={() => window.location.reload()}
className="mt-4 rounded bg-zinc-800 px-4 py-2 text-sm text-zinc-200 hover:bg-zinc-700"
className="mt-4 rounded bg-surface-card px-4 py-2 text-sm text-ink hover:bg-surface-card"
>
Retry
</button>
@@ -136,7 +136,7 @@ export default function OrgsPage() {
<OrgRow key={o.id} org={o} />
))}
</ul>
<div className="mt-8 border-t border-zinc-800 pt-6">
<div className="mt-8 border-t border-line pt-6">
<CreateOrgForm
onCreated={(slug) => {
// Refresh the list so the new org appears + its CTA fires.
@@ -162,11 +162,11 @@ function CheckoutBanner() {
function Shell({ children }: { children: React.ReactNode }) {
return (
<main className="min-h-screen bg-zinc-950 text-zinc-100">
<main className="min-h-screen bg-surface text-ink">
<TermsGate>
<div className="mx-auto max-w-2xl px-6 pt-20 pb-12">
<h1 className="text-3xl font-bold text-white">Your organizations</h1>
<p className="mt-2 text-zinc-400">
<h1 className="text-3xl font-bold text-ink">Your organizations</h1>
<p className="mt-2 text-ink-mid">
Each org is an isolated Molecule workspace.
</p>
<DataResidencyNotice />
@@ -184,7 +184,7 @@ function Shell({ children }: { children: React.ReactNode }) {
// region dropdown.
function DataResidencyNotice() {
return (
<p className="mt-3 rounded border border-zinc-800 bg-zinc-900/60 px-3 py-2 text-xs text-zinc-400">
<p className="mt-3 rounded border border-line bg-surface-sunken/60 px-3 py-2 text-xs text-ink-mid">
Workspaces run in AWS us-east-2 (Ohio, United States). EU region support is on the roadmap reach out to
{" "}
<a href="mailto:support@moleculesai.app" className="underline">
@@ -197,11 +197,11 @@ function DataResidencyNotice() {
function OrgRow({ org }: { org: Org }) {
return (
<li className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
<li className="rounded-lg border border-line bg-surface-sunken p-4">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-white">{org.name}</div>
<div className="text-sm text-zinc-400">
<div className="font-medium text-ink">{org.name}</div>
<div className="text-sm text-ink-mid">
{org.slug} · <StatusLabel status={org.status} /> · {org.plan || "free"}
</div>
<div className="mt-2 flex items-center gap-2">
@@ -237,21 +237,21 @@ function LowCreditsBanner({ org }: { org: Org }) {
if (kind === "overage") {
const used = (org.overage_used_credits ?? 0).toLocaleString();
return (
<span className="text-xs text-amber-300">
<span className="text-xs text-warm">
overage active · {used} used
</span>
);
}
if (kind === "out-of-credits") {
return (
<a href={`/pricing?org=${encodeURIComponent(org.slug)}`} className="text-xs text-red-300 underline">
<a href={`/pricing?org=${encodeURIComponent(org.slug)}`} className="text-xs text-bad underline">
out of credits upgrade to keep running
</a>
);
}
// trial-tail
return (
<a href={`/pricing?org=${encodeURIComponent(org.slug)}`} className="text-xs text-amber-300 underline">
<a href={`/pricing?org=${encodeURIComponent(org.slug)}`} className="text-xs text-warm underline">
trial almost out
</a>
);
@@ -260,11 +260,11 @@ function LowCreditsBanner({ org }: { org: Org }) {
function StatusLabel({ status }: { status: OrgStatus }) {
const cls =
status === "running"
? "text-emerald-400"
? "text-good"
: status === "awaiting_payment"
? "text-amber-400"
? "text-warm"
: status === "failed"
? "text-red-400"
? "text-bad"
: "text-sky-400";
const label =
status === "awaiting_payment"
@@ -303,21 +303,21 @@ function OrgCTA({ org }: { org: Org }) {
return (
<a
href="mailto:support@moleculesai.app"
className="rounded bg-zinc-700 px-4 py-2 text-sm font-medium text-zinc-200 hover:bg-zinc-600"
className="rounded bg-surface-card px-4 py-2 text-sm font-medium text-ink hover:bg-surface-card"
>
Contact support
</a>
);
}
// provisioning / unknown — non-interactive
return <span className="text-sm text-zinc-500">{org.status}</span>;
return <span className="text-sm text-ink-soft">{org.status}</span>;
}
function EmptyState({ banner }: { banner?: React.ReactNode }) {
return (
<Shell>
{banner}
<p className="text-zinc-300">
<p className="text-ink-mid">
You don't have any organizations yet. Create one to get started your
workspace spins up automatically once billing is set up.
</p>
@@ -365,7 +365,7 @@ function CreateOrgForm({ onCreated }: { onCreated: (slug: string) => void }) {
return (
<form onSubmit={submit} className="space-y-3">
<div>
<label htmlFor="org-slug" className="block text-sm text-zinc-300">Slug (URL)</label>
<label htmlFor="org-slug" className="block text-sm text-ink-mid">Slug (URL)</label>
<input
id="org-slug"
value={slug}
@@ -374,28 +374,28 @@ function CreateOrgForm({ onCreated }: { onCreated: (slug: string) => void }) {
placeholder="acme"
required
aria-describedby="org-slug-hint"
className="mt-1 w-full rounded border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100"
className="mt-1 w-full rounded border border-line bg-surface-card px-3 py-2 text-sm text-ink"
/>
<p id="org-slug-hint" className="mt-1 text-xs text-zinc-500">
<p id="org-slug-hint" className="mt-1 text-xs text-ink-soft">
Lowercase letters, numbers, and hyphens only. Cannot be changed later.
</p>
</div>
<div>
<label htmlFor="org-name" className="block text-sm text-zinc-300">Display name</label>
<label htmlFor="org-name" className="block text-sm text-ink-mid">Display name</label>
<input
id="org-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Acme Corp"
required
className="mt-1 w-full rounded border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100"
className="mt-1 w-full rounded border border-line bg-surface-card px-3 py-2 text-sm text-ink"
/>
</div>
{err && <p role="alert" className="text-sm text-red-400">{err}</p>}
{err && <p role="alert" className="text-sm text-bad">{err}</p>}
<button
type="submit"
disabled={submitting}
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 disabled:opacity-50"
className="rounded bg-accent-strong px-4 py-2 text-sm font-medium text-white hover:bg-accent disabled:opacity-50"
>
{submitting ? "Creating…" : "Create organization"}
</button>
+15 -15
View File
@@ -53,10 +53,10 @@ export default function Home() {
if (hydrating) {
return (
<div className="fixed inset-0 flex items-center justify-center bg-zinc-950">
<div className="flex flex-col items-center gap-3">
<div className="fixed inset-0 flex items-center justify-center bg-surface">
<div role="status" aria-live="polite" className="flex flex-col items-center gap-3">
<Spinner size="lg" />
<span className="text-xs text-zinc-500">Loading canvas...</span>
<span className="text-xs text-ink-soft">Loading canvas...</span>
</div>
</div>
);
@@ -79,15 +79,15 @@ export default function Home() {
// selector that's used by other transient toasts. Don't rename
// without updating that spec.
data-testid="hydration-error"
className="fixed inset-0 flex flex-col items-center justify-center bg-zinc-950 text-zinc-300 gap-4 z-[9999]"
className="fixed inset-0 flex flex-col items-center justify-center bg-surface text-ink-mid gap-4 z-[9999]"
>
<p className="text-zinc-400 text-sm">{hydrationError}</p>
<p className="text-ink-mid text-sm">{hydrationError}</p>
<button
onClick={() => {
setHydrationError(null);
window.location.reload();
}}
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-md text-sm"
className="px-4 py-2 bg-accent-strong hover:bg-accent text-white rounded-md text-sm"
>
Retry
</button>
@@ -108,28 +108,28 @@ function PlatformDownDiagnostic() {
return (
<div
role="alert"
className="fixed inset-0 flex flex-col items-center justify-center bg-zinc-950 text-zinc-300 gap-5 z-[9999] px-6"
className="fixed inset-0 flex flex-col items-center justify-center bg-surface text-ink-mid gap-5 z-[9999] px-6"
>
<div className="text-amber-400 text-sm font-semibold uppercase tracking-wider">
<div className="text-warm text-sm font-semibold uppercase tracking-wider">
Platform infrastructure unreachable
</div>
<p className="text-zinc-400 text-sm max-w-lg text-center leading-relaxed">
The platform server returned <code className="font-mono text-amber-300">503 platform_unavailable</code>.
<p className="text-ink-mid text-sm max-w-lg text-center leading-relaxed">
The platform server returned <code className="font-mono text-warm">503 platform_unavailable</code>.
That means it can&apos;t reach Postgres or Redis to validate your session.
Most common cause on a dev host: one of those services stopped.
</p>
<div className="bg-zinc-900/80 border border-zinc-700/50 rounded-lg px-4 py-3 max-w-lg w-full">
<div className="text-[10px] uppercase tracking-wider text-zinc-500 mb-2">Try first</div>
<pre className="text-[12px] text-zinc-300 font-mono whitespace-pre-wrap leading-relaxed">{`brew services start postgresql@14
<div className="bg-surface-sunken/80 border border-line/50 rounded-lg px-4 py-3 max-w-lg w-full">
<div className="text-[10px] uppercase tracking-wider text-ink-soft mb-2">Try first</div>
<pre className="text-[12px] text-ink-mid font-mono whitespace-pre-wrap leading-relaxed">{`brew services start postgresql@14
brew services start redis`}</pre>
</div>
<p className="text-[11px] text-zinc-500 max-w-lg text-center">
<p className="text-[11px] text-ink-soft max-w-lg text-center">
If both are running, check <code className="font-mono">/tmp/molecule-server.log</code> for
the underlying error. If you&apos;re on hosted SaaS, this is a platform incident try again in a moment.
</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-md text-sm mt-2"
className="px-4 py-2 bg-accent-strong hover:bg-accent text-white rounded-md text-sm mt-2"
>
Reload
</button>
+13 -13
View File
@@ -19,17 +19,17 @@ export const metadata = {
export default function PricingPage() {
return (
<main className="min-h-screen bg-zinc-950 text-zinc-100">
<main className="min-h-screen bg-surface text-ink">
<div className="mx-auto max-w-5xl px-6 pt-20 pb-8 text-center">
<h1 className="text-5xl font-bold tracking-tight text-white md:text-6xl">
<h1 className="text-5xl font-bold tracking-tight text-ink md:text-6xl">
Pricing
</h1>
<p className="mx-auto mt-4 max-w-2xl text-lg text-zinc-300">
<p className="mx-auto mt-4 max-w-2xl text-lg text-ink-mid">
One flat price per org not per seat. Every paid tier includes the
full runtime stack. You upgrade for scale, support, and dedicated
infrastructure.
</p>
<p className="mx-auto mt-2 max-w-xl text-sm text-zinc-400">
<p className="mx-auto mt-2 max-w-xl text-sm text-ink-mid">
5-person team? You pay $29/month not $200. No seat math, ever.
</p>
</div>
@@ -37,42 +37,42 @@ export default function PricingPage() {
<PricingTable />
<section className="mx-auto mt-20 max-w-3xl px-6 text-center">
<h2 className="text-2xl font-semibold text-white">Questions?</h2>
<p className="mt-2 text-zinc-400">
<h2 className="text-2xl font-semibold text-ink">Questions?</h2>
<p className="mt-2 text-ink-mid">
We publish the{" "}
<a
href="https://github.com/Molecule-AI/molecule-monorepo"
className="text-blue-400 underline hover:text-blue-300"
className="text-accent underline hover:text-accent"
>
full source on GitHub
</a>
{" "} if something's ambiguous, file an issue or{" "}
<a
href="mailto:support@moleculesai.app"
className="text-blue-400 underline hover:text-blue-300"
className="text-accent underline hover:text-accent"
>
email support
</a>
.
</p>
<p className="mt-6 text-sm text-zinc-500">
<p className="mt-6 text-sm text-ink-soft">
Prices shown in USD. Flat-rate per org no per-seat fees on any paid tier.
Enterprise / self-hosted licensing available contact us.
</p>
</section>
<footer className="mx-auto mt-20 max-w-5xl border-t border-zinc-800 px-6 py-6 text-center text-sm text-zinc-500">
<footer className="mx-auto mt-20 max-w-5xl border-t border-line px-6 py-6 text-center text-sm text-ink-soft">
<p>
© {new Date().getFullYear()} Molecule AI, Inc. ·{" "}
<a href="/legal/terms" className="hover:text-zinc-300">
<a href="/legal/terms" className="hover:text-ink-mid">
Terms
</a>
{" "}·{" "}
<a href="/legal/privacy" className="hover:text-zinc-300">
<a href="/legal/privacy" className="hover:text-ink-mid">
Privacy
</a>
{" "}·{" "}
<a href="/legal/dpa" className="hover:text-zinc-300">
<a href="/legal/dpa" className="hover:text-ink-mid">
DPA
</a>
</p>
+29 -6
View File
@@ -138,14 +138,37 @@ export function A2ATopologyOverlay() {
// Stable Zustand action reference — safe to call inside effects
const setA2AEdges = useCanvasStore((s) => s.setA2AEdges);
// Read the nodes array as a primitive ref; derive visible IDs outside the selector
const nodes = useCanvasStore((s) => s.nodes);
// Subscribe to a STABLE STRING KEY of visible workspace IDs, not the
// nodes array itself. Zustand returns a new array reference on every
// store update (status flips, position drags, peer-discovery writes,
// workspace-tab opens, etc.) — even when the set of visible IDs is
// unchanged. Selecting a sorted-CSV string makes Zustand's default
// shallow-equal short-circuit the re-render unless the actual ID set
// changes.
//
// Why this matters: previously visibleIds was useMemo'd on `nodes`, so
// the array reference recreated on every store mutation. fetchAndUpdate
// (useCallback'd on visibleIds) then recreated, the useEffect re-fired,
// it tore down the 60s setInterval and immediately re-ran the fan-out.
// With ~5 store updates/second from heartbeats + polling, the canvas
// hammered /workspaces/<id>/activity?type=delegation 5×N requests/sec
// until edge rate-limit kicked in with HTTP 429. The recursive React
// render trace in the original bug report (uE → ux → uE → ux ...) is
// the symptom of this re-render storm.
//
// The fix is purely the dependency-stability change here; the fetch
// logic is unchanged.
const visibleIdsKey = useCanvasStore((s) =>
s.nodes
.filter((n) => !n.hidden)
.map((n) => n.id)
.sort()
.join(",")
);
// IDs of visible (non-nested, non-hidden) workspace nodes.
// Recomputed only when the nodes array reference changes.
const visibleIds = useMemo(
() => nodes.filter((n) => !n.hidden).map((n) => n.id),
[nodes]
() => (visibleIdsKey ? visibleIdsKey.split(",") : []),
[visibleIdsKey]
);
// Fetch delegation activity for all visible workspaces and rebuild overlay edges.
+9 -4
View File
@@ -61,26 +61,31 @@ export function ApprovalBanner() {
>
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-lg bg-amber-800/40 flex items-center justify-center shrink-0 mt-0.5">
<span className="text-amber-300 text-lg" aria-hidden="true"></span>
<span className="text-warm text-lg" aria-hidden="true"></span>
</div>
<div className="flex-1 min-w-0">
<div className="text-xs text-amber-200 font-semibold">{approval.workspace_name} needs approval</div>
<div className="text-sm text-amber-100 mt-0.5 font-medium">{approval.action}</div>
{approval.reason && (
<div className="text-xs text-amber-300/70 mt-1">{approval.reason}</div>
<div className="text-xs text-warm/70 mt-1">{approval.reason}</div>
)}
<div className="flex gap-2 mt-3">
<button
type="button"
onClick={() => handleDecide(approval, "approved")}
className="px-3 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-xs rounded-lg text-white font-medium transition-colors"
// Hover DARKER not lighter — emerald-500 on white text
// drops contrast vs emerald-700.
className="px-3 py-1.5 bg-emerald-600 hover:bg-emerald-700 text-xs rounded-lg text-white font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-amber-950 focus-visible:ring-emerald-400/70"
>
Approve
</button>
<button
type="button"
onClick={() => handleDecide(approval, "denied")}
className="px-3 py-1.5 bg-zinc-700 hover:bg-zinc-600 text-xs rounded-lg text-zinc-300 transition-colors"
// Was a no-op hover (`bg-surface-card hover:bg-surface-card`).
// Lift to surface-elevated on hover so the button visibly
// responds before a destructive deny.
className="px-3 py-1.5 bg-surface-card hover:bg-surface-elevated hover:text-ink text-xs rounded-lg text-ink-mid transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-amber-950 focus-visible:ring-amber-400/70"
>
Deny
</button>
+20 -20
View File
@@ -9,7 +9,7 @@ import type { AuditEntry, AuditResponse } from "@/types/audit";
type EventFilter = "all" | AuditEntry["event_type"];
const BADGE_COLORS: Record<AuditEntry["event_type"], { text: string; bg: string; border: string }> = {
delegation: { text: "text-blue-400", bg: "bg-blue-950/40", border: "border-blue-800/40" },
delegation: { text: "text-accent", bg: "bg-blue-950/40", border: "border-blue-800/40" },
decision: { text: "text-violet-400", bg: "bg-violet-950/40", border: "border-violet-800/40" },
gate: { text: "text-yellow-400", bg: "bg-yellow-950/40", border: "border-yellow-800/40" },
hitl: { text: "text-orange-400", bg: "bg-orange-950/40", border: "border-orange-800/40" },
@@ -127,7 +127,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
if (loading) {
return (
<div className="flex items-center justify-center h-32">
<span className="text-xs text-zinc-500">Loading audit trail</span>
<span className="text-xs text-ink-soft">Loading audit trail</span>
</div>
);
}
@@ -135,7 +135,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
return (
<div className="flex flex-col h-full">
{/* Filter bar */}
<div className="px-4 py-2.5 border-b border-zinc-800/40 flex items-center gap-1 overflow-x-auto shrink-0">
<div className="px-4 py-2.5 border-b border-line/40 flex items-center gap-1 overflow-x-auto shrink-0">
{FILTERS.map((f) => (
<button
type="button"
@@ -144,8 +144,8 @@ export function AuditTrailPanel({ workspaceId }: Props) {
aria-pressed={filter === f.id}
className={`px-2 py-1 text-[10px] rounded-md font-medium transition-all shrink-0 ${
filter === f.id
? "bg-zinc-700 text-zinc-100 ring-1 ring-zinc-600"
: "text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800/60"
? "bg-surface-card text-ink ring-1 ring-zinc-600"
: "text-ink-soft hover:text-ink-mid hover:bg-surface-card/60"
}`}
>
{f.label}
@@ -155,7 +155,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
<button
type="button"
onClick={loadEntries}
className="px-2 py-1 text-[10px] bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded transition-colors shrink-0"
className="px-2 py-1 text-[10px] bg-surface-card hover:bg-surface-card text-ink-mid rounded transition-colors shrink-0"
aria-label="Refresh audit trail"
>
@@ -164,7 +164,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
{/* Error banner */}
{error && (
<div className="mx-4 mt-3 px-3 py-2 bg-red-950/30 border border-red-800/40 rounded text-xs text-red-400 shrink-0">
<div className="mx-4 mt-3 px-3 py-2 bg-red-950/30 border border-red-800/40 rounded text-xs text-bad shrink-0">
{error}
</div>
)}
@@ -174,9 +174,9 @@ export function AuditTrailPanel({ workspaceId }: Props) {
{entries.length === 0 ? (
/* Empty state */
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
<span className="text-4xl text-zinc-700" aria-hidden="true"></span>
<p className="text-sm font-medium text-zinc-400">No audit events yet</p>
<p className="text-[11px] text-zinc-600 max-w-[200px] leading-relaxed">
<span className="text-4xl text-ink-soft" aria-hidden="true"></span>
<p className="text-sm font-medium text-ink-mid">No audit events yet</p>
<p className="text-[11px] text-ink-soft max-w-[200px] leading-relaxed">
Delegation, decision, gate, and human-in-the-loop events will appear here.
</p>
</div>
@@ -195,7 +195,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
type="button"
onClick={loadMore}
disabled={loadingMore}
className="px-4 py-2 text-[11px] bg-zinc-800 hover:bg-zinc-700 disabled:opacity-50 disabled:cursor-not-allowed text-zinc-300 rounded-lg transition-colors"
className="px-4 py-2 text-[11px] bg-surface-card hover:bg-surface-card disabled:opacity-50 disabled:cursor-not-allowed text-ink-mid rounded-lg transition-colors"
>
{loadingMore ? "Loading…" : "Load more"}
</button>
@@ -203,7 +203,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
)}
{/* Entry count footer */}
<p className="mt-3 text-center text-[9px] text-zinc-600">
<p className="mt-3 text-center text-[9px] text-ink-soft">
{entries.length} event{entries.length !== 1 ? "s" : ""} loaded
{cursor ? " · more available" : " · all loaded"}
</p>
@@ -227,15 +227,15 @@ export interface AuditEntryRowProps {
*/
export function AuditEntryRow({ entry, now }: AuditEntryRowProps) {
const badge = BADGE_COLORS[entry.event_type] ?? {
text: "text-zinc-400",
bg: "bg-zinc-800/40",
border: "border-zinc-700/40",
text: "text-ink-mid",
bg: "bg-surface-card/40",
border: "border-line/40",
};
return (
<div
role="listitem"
className="rounded-lg border border-zinc-800/60 bg-zinc-900/50 px-3 py-2.5 space-y-1.5"
className="rounded-lg border border-line/60 bg-surface-sunken/50 px-3 py-2.5 space-y-1.5"
>
{/* Header row: badge · actor · tamper flag · timestamp */}
<div className="flex items-center gap-2">
@@ -248,14 +248,14 @@ export function AuditEntryRow({ entry, now }: AuditEntryRowProps) {
</span>
{/* Actor name */}
<span className="text-[10px] text-zinc-400 truncate flex-1 min-w-0 font-mono">
<span className="text-[10px] text-ink-mid truncate flex-1 min-w-0 font-mono">
{entry.actor}
</span>
{/* Tamper warning — only rendered when chain is invalid */}
{!entry.chain_valid && (
<span
className="shrink-0 text-[11px] text-red-400 font-bold leading-none"
className="shrink-0 text-[11px] text-bad font-bold leading-none"
title="Chain integrity check failed — this entry may have been tampered with"
aria-label="Chain integrity warning: tampered entry"
role="img"
@@ -265,13 +265,13 @@ export function AuditEntryRow({ entry, now }: AuditEntryRowProps) {
)}
{/* Relative timestamp */}
<span className="shrink-0 text-[9px] text-zinc-600">
<span className="shrink-0 text-[9px] text-ink-soft">
{formatAuditRelativeTime(entry.created_at, now)}
</span>
</div>
{/* Summary text */}
<p className="text-[11px] text-zinc-300 leading-relaxed break-words">
<p className="text-[11px] text-ink-mid leading-relaxed break-words">
{entry.summary}
</p>
</div>
+1 -1
View File
@@ -63,7 +63,7 @@ export function AuthGate({ children }: { children: ReactNode }) {
if (state.kind === "loading") {
// Zinc-950 backdrop matches the canvas background so the browser
// never paints a white flash while the session round-trip resolves.
return <div className="fixed inset-0 bg-zinc-950" aria-hidden="true" />;
return <div className="fixed inset-0 bg-surface" aria-hidden="true" />;
}
if (state.kind === "anonymous" && !state.skipRedirect) {
// Redirect already firing from the effect above; render nothing in
+25 -7
View File
@@ -30,6 +30,24 @@ export function BatchActionBar() {
if (count === 0 && hasFailedBatch) setHasFailedBatch(false);
}, [count, hasFailedBatch]);
// Esc clears selection — the deselect button title has been promising
// "(Escape)" since the bar shipped, but no handler was wired. Skip when
// the confirm dialog is open (`pending !== null`) so the dialog's own
// Esc-cancels takes precedence and we don't double-handle the keystroke.
// Also skip during a busy in-flight action so the user can't accidentally
// strand a partial-failure mid-flight.
useEffect(() => {
if (count === 0 || pending !== null || busy) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
clearSelection();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [count, pending, busy, clearSelection]);
// Hide when nothing is selected. Hide for single-node selection UNLESS a
// partial-failure left a survivor awaiting retry.
if (count === 0) return null;
@@ -80,14 +98,14 @@ export function BatchActionBar() {
<div
role="toolbar"
aria-label="Batch workspace actions"
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[200] flex items-center gap-3 px-4 py-2.5 rounded-2xl bg-zinc-900/95 border border-zinc-700/70 shadow-2xl shadow-black/50 backdrop-blur-md"
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[200] flex items-center gap-3 px-4 py-2.5 rounded-2xl bg-surface-sunken/95 border border-line/70 shadow-2xl shadow-black/50 backdrop-blur-md"
>
{/* Selection count badge */}
<span className="text-[12px] font-semibold text-zinc-100 bg-blue-600/80 px-2.5 py-0.5 rounded-full tabular-nums">
<span className="text-[12px] font-semibold text-white bg-accent-strong/80 px-2.5 py-0.5 rounded-full tabular-nums">
{count} selected
</span>
<div className="w-px h-5 bg-zinc-700/60" aria-hidden="true" />
<div className="w-px h-5 bg-surface-card/60" aria-hidden="true" />
{/* Action buttons */}
<button
@@ -104,7 +122,7 @@ export function BatchActionBar() {
type="button"
disabled={busy}
onClick={() => setPending("pause")}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium text-amber-300 bg-amber-900/30 hover:bg-amber-800/50 border border-amber-700/30 hover:border-amber-600/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/70"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium text-warm bg-amber-900/30 hover:bg-amber-800/50 border border-amber-700/30 hover:border-amber-600/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/70"
>
<span aria-hidden="true"></span>
Pause All
@@ -114,13 +132,13 @@ export function BatchActionBar() {
type="button"
disabled={busy}
onClick={() => setPending("delete")}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium text-red-300 bg-red-900/30 hover:bg-red-800/50 border border-red-700/30 hover:border-red-600/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/70"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium text-bad bg-red-900/30 hover:bg-red-800/50 border border-red-700/30 hover:border-red-600/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/70"
>
<span aria-hidden="true"></span>
Delete All
</button>
<div className="w-px h-5 bg-zinc-700/60" aria-hidden="true" />
<div className="w-px h-5 bg-surface-card/60" aria-hidden="true" />
{/* Deselect */}
<button
@@ -129,7 +147,7 @@ export function BatchActionBar() {
onClick={clearSelection}
aria-label="Clear selection"
title="Clear selection (Escape)"
className="p-1.5 rounded-lg text-[12px] text-zinc-400 hover:text-zinc-200 hover:bg-zinc-700/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-500/70"
className="p-1.5 rounded-lg text-[12px] text-ink-mid hover:text-ink hover:bg-surface-card/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
>
</button>
+23 -10
View File
@@ -112,27 +112,40 @@ export function BundleDropZone() {
onClick={() => fileInputRef.current?.click()}
aria-label="Import bundle file"
aria-controls="bundle-file-input"
className="sr-only focus:not-sr-only fixed bottom-20 right-4 z-30 px-3 py-1.5 bg-zinc-900/90 border border-zinc-700/50 rounded-lg text-[10px] text-zinc-400 hover:text-zinc-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 transition-colors"
className="sr-only focus:not-sr-only fixed bottom-20 right-4 z-30 px-3 py-1.5 bg-surface-sunken/90 border border-line/50 rounded-lg text-[10px] text-ink-mid hover:text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent transition-colors"
>
📦 Import bundle
</button>
{/* Visual overlay when dragging */}
{/* Visual overlay when dragging — was hardcoded blue-950/blue-400
which doesn't flip with theme. accent colors stay visually
consistent with the rest of the canvas in both modes. */}
{isDragging && (
<div className="fixed inset-0 z-20 flex items-center justify-center bg-blue-950/40 backdrop-blur-sm border-2 border-dashed border-blue-400/50 pointer-events-none">
<div className="bg-zinc-900/95 border border-blue-500/50 rounded-2xl px-8 py-6 shadow-2xl text-center">
<div className="fixed inset-0 z-20 flex items-center justify-center bg-accent/15 backdrop-blur-sm border-2 border-dashed border-accent/40 pointer-events-none">
<div className="bg-surface-sunken/95 border border-accent/50 rounded-2xl px-8 py-6 shadow-2xl text-center">
<div className="text-3xl mb-2" aria-hidden="true">📦</div>
<div className="text-sm font-semibold text-zinc-100">Drop Bundle to Import</div>
<div className="text-xs text-zinc-500 mt-1">.bundle.json files only</div>
<div className="text-sm font-semibold text-ink">Drop Bundle to Import</div>
<div className="text-xs text-ink-soft mt-1">.bundle.json files only</div>
</div>
</div>
)}
{/* Importing spinner */}
{/* Importing indicator — role=status + aria-live so SR users hear
"Importing bundle..." while the API call is in flight, not just
the result toast that fires after. motion-safe:animate-spin
respects prefers-reduced-motion (Tailwind's motion-safe variant
gates animation on the user's OS setting). */}
{importing && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 bg-zinc-900/95 border border-zinc-700/60 rounded-xl px-5 py-3 shadow-2xl flex items-center gap-3">
<div className="w-4 h-4 border-2 border-sky-400 border-t-transparent rounded-full animate-spin" />
<span className="text-sm text-zinc-200">Importing bundle...</span>
<div
role="status"
aria-live="polite"
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 bg-surface-sunken/95 border border-line/60 rounded-xl px-5 py-3 shadow-2xl flex items-center gap-3"
>
<div
aria-hidden="true"
className="w-4 h-4 border-2 border-accent border-t-transparent rounded-full motion-safe:animate-spin"
/>
<span className="text-sm text-ink">Importing bundle...</span>
</div>
)}
+20 -7
View File
@@ -13,6 +13,7 @@ import {
import "@xyflow/react/dist/style.css";
import { useCanvasStore } from "@/store/canvas";
import { useTheme } from "@/lib/theme-provider";
import { A2ATopologyOverlay } from "./A2ATopologyOverlay";
import { WorkspaceNode } from "./WorkspaceNode";
import { SidePanel } from "./SidePanel";
@@ -69,6 +70,14 @@ export function Canvas() {
}
function CanvasInner() {
// ReactFlow's `colorMode` prop drives the styling of every viewport
// primitive it renders directly (background dots, edge defaults,
// selection rings, controls, minimap mask). Pre-fix this was hard-pinned
// to "dark" — so on light theme the chrome (toolbar, side panel) flipped
// to warm-paper but the canvas backplate + edges stayed black, leaving a
// half-themed page. Pull resolvedTheme so the canvas matches the user's
// selected mode (and the system preference when they pick "system").
const { resolvedTheme } = useTheme();
const rawNodes = useCanvasStore((s) => s.nodes);
const edges = useCanvasStore((s) => s.edges);
const a2aEdges = useCanvasStore((s) => s.a2aEdges);
@@ -244,13 +253,13 @@ function CanvasInner() {
<>
<a
href="#canvas-main"
className="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:px-4 focus:py-2 focus:bg-zinc-900 focus:text-zinc-100 focus:rounded-lg focus:border focus:border-zinc-700"
className="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:px-4 focus:py-2 focus:bg-surface-sunken focus:text-ink focus:rounded-lg focus:border focus:border-line"
>
Skip to canvas
</a>
<main id="canvas-main" className="w-screen h-screen bg-zinc-950">
<main id="canvas-main" className="w-screen h-screen bg-surface">
<ReactFlow
colorMode="dark"
colorMode={resolvedTheme}
nodes={nodes}
edges={allEdges}
onNodesChange={onNodesChange}
@@ -273,15 +282,19 @@ function CanvasInner() {
variant={BackgroundVariant.Dots}
gap={24}
size={1}
color="#27272a"
// Match the line token so dots fade with the surface.
// Hard-coded zinc-800 was invisible on warm-paper.
color={resolvedTheme === "dark" ? "#27272a" : "#d4d0c4"}
/>
<Controls
className="!bg-zinc-900/90 !border-zinc-700/50 !rounded-lg !shadow-xl !shadow-black/20 [&>button]:!bg-zinc-800 [&>button]:!border-zinc-700/50 [&>button]:!text-zinc-400 [&>button:hover]:!bg-zinc-700 [&>button:hover]:!text-zinc-200"
className="!bg-surface-sunken/90 !border-line/50 !rounded-lg !shadow-xl !shadow-black/20 [&>button]:!bg-surface-card [&>button]:!border-line/50 [&>button]:!text-ink-mid [&>button:hover]:!bg-surface-card [&>button:hover]:!text-ink"
showInteractive={false}
/>
<MiniMap
className="!bg-zinc-900/90 !border-zinc-700/50 !rounded-lg !shadow-xl !shadow-black/20"
maskColor="rgba(0, 0, 0, 0.7)"
className="!bg-surface-sunken/90 !border-line/50 !rounded-lg !shadow-xl !shadow-black/20"
// Mask dims off-viewport areas; tint matches the surface so
// the dimming doesn't show as a black bar in light mode.
maskColor={resolvedTheme === "dark" ? "rgba(0, 0, 0, 0.7)" : "rgba(232, 226, 211, 0.7)"}
nodeColor={(node) => {
// Parents show as a filled region — hierarchy visible at
// a glance in the minimap without needing to zoom.
+35 -18
View File
@@ -32,11 +32,18 @@ export function CommunicationOverlay() {
const fetchComms = useCallback(async () => {
try {
// Fetch activity from all online workspaces
// Fan-out cap: each polled workspace = 1 round-trip. The platform
// rate limits at 600 req/min/IP; combined with heartbeats + other
// canvas polling, every workspace polled here costs ~6 req/min
// (1 every 30s × 1 per workspace). Capping at 3 keeps this
// overlay's footprint at 18 req/min worst case — well under
// budget even with 8+ workspaces visible. Caught 2026-05-04 when
// a user with 8+ workspaces (Design Director + 6 sub-agents +
// 3 standalones) saw sustained 429s in canvas console.
const onlineNodes = nodesRef.current.filter((n) => n.data.status === "online");
const allComms: Communication[] = [];
for (const node of onlineNodes.slice(0, 6)) {
for (const node of onlineNodes.slice(0, 3)) {
try {
const activities = await api.get<Array<{
id: string;
@@ -91,10 +98,20 @@ export function CommunicationOverlay() {
}, []);
useEffect(() => {
// Gate polling on visibility — when the user collapses the overlay
// the data isn't being read, so the per-workspace fan-out becomes
// pure rate-limit overhead. Pre-fix this overlay polled regardless
// of whether the panel was shown, costing ~36 req/min from a
// hidden surface.
if (!visible) return;
fetchComms();
const interval = setInterval(fetchComms, 10000);
// 30s cadence (was 10s). At 3-workspace fan-out that's 6 req/min
// worst case from this overlay. Combined with heartbeats (~30/min)
// and other canvas polling, leaves ample headroom under the 600/
// min/IP server-side rate limit even at 8+ workspace tenants.
const interval = setInterval(fetchComms, 30000);
return () => clearInterval(interval);
}, [fetchComms]);
}, [fetchComms, visible]);
if (!visible || comms.length === 0) {
return (
@@ -102,7 +119,7 @@ export function CommunicationOverlay() {
type="button"
onClick={() => setVisible(true)}
aria-label="Show communications panel"
className="fixed top-16 right-4 z-30 px-3 py-1.5 bg-zinc-900/90 border border-zinc-700/50 rounded-lg text-[10px] text-zinc-400 hover:text-zinc-200 transition-colors"
className="fixed top-16 right-4 z-30 px-3 py-1.5 bg-surface-sunken/90 border border-line/50 rounded-lg text-[10px] text-ink-mid hover:text-ink transition-colors"
>
<span aria-hidden="true"> </span>{comms.length > 0 ? `${comms.length} comms` : "Communications"}
</button>
@@ -110,16 +127,16 @@ export function CommunicationOverlay() {
}
return (
<div className="fixed top-16 right-4 z-30 w-[320px] max-h-[400px] bg-zinc-900/95 border border-zinc-700/50 rounded-xl shadow-xl shadow-black/30 backdrop-blur-sm overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 border-b border-zinc-800/60">
<div className="text-[10px] font-semibold text-zinc-400 uppercase tracking-wider">
<div className="fixed top-16 right-4 z-30 w-[320px] max-h-[400px] bg-surface-sunken/95 border border-line/50 rounded-xl shadow-xl shadow-black/30 backdrop-blur-sm overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 border-b border-line/60">
<div className="text-[10px] font-semibold text-ink-mid uppercase tracking-wider">
<span aria-hidden="true"> </span>Communications ({comms.length})
</div>
<button
type="button"
onClick={() => setVisible(false)}
aria-label="Close communications panel"
className="text-zinc-500 hover:text-zinc-300 text-xs"
className="text-ink-soft hover:text-ink-mid text-xs"
>
<span aria-hidden="true"></span>
</button>
@@ -128,10 +145,10 @@ export function CommunicationOverlay() {
<div className="overflow-y-auto max-h-[350px] p-2 space-y-1">
{comms.map((c) => {
const isSelected = selectedNodeId === c.sourceId || selectedNodeId === c.targetId;
const typeColor = c.type === "a2a_send" ? "text-cyan-400" : c.type === "a2a_receive" ? "text-blue-400" : "text-amber-400";
const typeColor = c.type === "a2a_send" ? "text-cyan-400" : c.type === "a2a_receive" ? "text-accent" : "text-warm";
const typeIcon = c.type === "a2a_send" ? "↗" : c.type === "a2a_receive" ? "↙" : "◆";
const statusIcon = c.status === "ok" ? "✓" : c.status === "error" ? "✕" : "⏱";
const statusColor = c.status === "ok" ? "text-emerald-400" : c.status === "error" ? "text-red-400" : "text-amber-400";
const statusColor = c.status === "ok" ? "text-good" : c.status === "error" ? "text-bad" : "text-warm";
const age = formatAge(c.timestamp);
return (
@@ -140,31 +157,31 @@ export function CommunicationOverlay() {
className={`rounded-lg px-2.5 py-1.5 text-[9px] border transition-all ${
isSelected
? "bg-blue-950/30 border-blue-800/40"
: "bg-zinc-800/30 border-zinc-700/20 hover:bg-zinc-800/50"
: "bg-surface-card/30 border-line/20 hover:bg-surface-card/50"
}`}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 min-w-0">
<span className={typeColor} aria-hidden="true">{typeIcon}</span>
<span className="sr-only">{COMM_TYPE_LABELS[c.type] ?? c.type}</span>
<span className="text-zinc-300 font-medium truncate">
<span className="text-ink-mid font-medium truncate">
{c.sourceName}
</span>
<span className="text-zinc-400" aria-hidden="true"></span>
<span className="text-ink-mid" aria-hidden="true"></span>
<span className="sr-only">to</span>
<span className="text-zinc-300 truncate">{c.targetName}</span>
<span className="text-ink-mid truncate">{c.targetName}</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<span className={statusColor} aria-hidden="true">{statusIcon}</span>
<span className="sr-only">{c.status}</span>
<span className="text-zinc-400">{age}</span>
<span className="text-ink-mid">{age}</span>
</div>
</div>
{c.summary && (
<div className="text-zinc-500 truncate mt-0.5 pl-4">{c.summary}</div>
<div className="text-ink-soft truncate mt-0.5 pl-4">{c.summary}</div>
)}
{c.durationMs && (
<div className="text-zinc-400 pl-4">{c.durationMs}ms</div>
<div className="text-ink-mid pl-4">{c.durationMs}ms</div>
)}
</div>
);
+12 -9
View File
@@ -91,12 +91,15 @@ export function ConfirmDialog({
if (!open || !mounted) return null;
// Hover goes DARKER, not lighter — lighter shades on white text drop
// contrast below AA on the accent and red ramps. Darker hovers stay
// readable in both light and dark themes.
const confirmColors =
confirmVariant === "danger"
? "bg-red-600 hover:bg-red-500 text-white"
? "bg-red-600 hover:bg-red-700 text-white"
: confirmVariant === "warning"
? "bg-amber-600 hover:bg-amber-500 text-white"
: "bg-blue-600 hover:bg-blue-500 text-white";
? "bg-amber-600 hover:bg-amber-700 text-white"
: "bg-accent hover:bg-accent-strong text-white";
// Render via Portal so the fixed-position dialog escapes any containing block
// (e.g. parents with transform, filter, will-change that break position:fixed).
@@ -111,19 +114,19 @@ export function ConfirmDialog({
role="dialog"
aria-modal="true"
aria-labelledby="confirm-dialog-title"
className="relative bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl shadow-black/50 max-w-[380px] w-full mx-4 overflow-hidden"
className="relative bg-surface-sunken border border-line rounded-xl shadow-2xl shadow-black/50 max-w-[380px] w-full mx-4 overflow-hidden"
>
<div className="px-5 py-4">
<h3 id="confirm-dialog-title" className="text-sm font-semibold text-zinc-100 mb-2">{title}</h3>
<p className="text-[13px] text-zinc-400 leading-relaxed">{message}</p>
<h3 id="confirm-dialog-title" className="text-sm font-semibold text-ink mb-2">{title}</h3>
<p className="text-[13px] text-ink-mid leading-relaxed">{message}</p>
</div>
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-zinc-800 bg-zinc-950/50">
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-line bg-surface/50">
{!singleButton && (
<button
type="button"
onClick={onCancel}
className="px-3.5 py-1.5 text-[13px] text-zinc-400 hover:text-zinc-200 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
className="px-3.5 py-1.5 text-[13px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-elevated border border-line hover:border-line-soft rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
Cancel
</button>
@@ -131,7 +134,7 @@ export function ConfirmDialog({
<button
type="button"
onClick={onConfirm}
className={`px-3.5 py-1.5 text-[13px] rounded-lg transition-colors ${confirmColors}`}
className={`px-3.5 py-1.5 text-[13px] rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken focus-visible:ring-accent/60 ${confirmColors}`}
>
{confirmLabel}
</button>
+25 -12
View File
@@ -95,15 +95,15 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
role="dialog"
aria-modal="true"
aria-labelledby="console-modal-title"
className="relative bg-zinc-950 border border-zinc-800 rounded-xl shadow-2xl w-[min(900px,90vw)] h-[min(70vh,700px)] flex flex-col overflow-hidden"
className="relative bg-surface border border-line rounded-xl shadow-2xl w-[min(900px,90vw)] h-[min(70vh,700px)] flex flex-col overflow-hidden"
>
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-800">
<div className="flex items-center justify-between px-4 py-3 border-b border-line">
<div>
<h3 id="console-modal-title" className="text-sm font-semibold text-zinc-100">
<h3 id="console-modal-title" className="text-sm font-semibold text-ink">
EC2 console output
</h3>
{workspaceName && (
<div className="text-[11px] text-zinc-500 mt-0.5 truncate max-w-[600px]">
<div className="text-[11px] text-ink-soft mt-0.5 truncate max-w-[600px]">
{workspaceName}
</div>
)}
@@ -113,7 +113,10 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
ref={closeButtonRef}
onClick={onClose}
aria-label="Close"
className="text-zinc-400 hover:text-zinc-100 text-sm px-2"
// 24x24 touch target (was ~10x16, well under WCAG 2.5.5).
// Hover bg makes the area visible; focus-visible ring matches
// the rest of the canvas chrome.
className="w-6 h-6 inline-flex items-center justify-center rounded text-sm text-ink-mid hover:text-ink hover:bg-surface-card/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 transition-colors"
>
</button>
@@ -121,14 +124,14 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
<div className="flex-1 overflow-auto bg-black/80 p-4">
{loading && (
<div className="text-[12px] text-zinc-500" data-testid="console-loading">
<div className="text-[12px] text-ink-soft" data-testid="console-loading">
Loading console output
</div>
)}
{!loading && error && (
<div
role="alert"
className="text-[12px] text-amber-300 bg-amber-950/30 border border-amber-900/40 rounded px-3 py-2"
className="text-[12px] text-warm bg-amber-950/30 border border-amber-900/40 rounded px-3 py-2"
data-testid="console-error"
>
{error}
@@ -136,7 +139,7 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
)}
{!loading && !error && output !== null && (
<pre
className="text-[11px] text-zinc-300 font-mono whitespace-pre-wrap break-all leading-tight"
className="text-[11px] text-ink-mid font-mono whitespace-pre-wrap break-all leading-tight"
data-testid="console-output"
>
{output || "(console output is empty — the instance may still be booting)"}
@@ -144,18 +147,25 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
)}
</div>
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-zinc-800 bg-zinc-900/40">
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-line bg-surface-sunken/40">
{output && (
<button
type="button"
onClick={() => {
if (navigator.clipboard) {
navigator.clipboard.writeText(output);
// Add success feedback — without it, clicking Copy
// looked like a no-op since the previous hover bg was
// also a no-op (`hover:bg-surface-card` on top of the
// same base). Toast confirms the write actually fired.
navigator.clipboard
.writeText(output)
.then(() => showToast("Console output copied", "success"))
.catch(() => showToast("Copy failed", "error"));
} else {
showToast("Copy requires HTTPS — please select and copy manually", "info");
}
}}
className="px-3 py-1.5 text-[11px] text-zinc-400 hover:text-zinc-200 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
className="px-3 py-1.5 text-[11px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-elevated border border-line hover:border-line-soft rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
>
Copy
</button>
@@ -163,7 +173,10 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
<button
type="button"
onClick={onClose}
className="px-3 py-1.5 text-[11px] text-zinc-300 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
// Was hover:bg-surface-card (same as base — silent no-op).
// Lift to surface-elevated so the button visibly responds,
// matching the Cancel button in ConfirmDialog.
className="px-3 py-1.5 text-[11px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-elevated border border-line hover:border-line-soft rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
>
Close
</button>
+36 -13
View File
@@ -29,15 +29,38 @@ export function ContextMenu() {
const setPendingDelete = useCanvasStore((s) => s.setPendingDelete);
const ref = useRef<HTMLDivElement>(null);
const [actionLoading, setActionLoading] = useState(false);
// Clamped position — (left, top) from contextMenu may overflow when the
// user right-clicks near the right/bottom viewport edge. We measure the
// rendered menu and shift it back inside on the same frame the cursor
// opens it, so it never visibly clips. Falls back to the raw cursor
// coords until the rAF runs.
const [clamped, setClamped] = useState<{ x: number; y: number } | null>(null);
// Auto-focus first enabled item when menu opens
// Auto-focus first enabled item when menu opens, AND clamp position.
// Both run together in a single rAF so we avoid two synchronous layout
// reads + a paint between them.
useEffect(() => {
if (!contextMenu) return;
requestAnimationFrame(() => {
const first = ref.current?.querySelector<HTMLButtonElement>("button:not(:disabled)");
setClamped(null);
const raf = requestAnimationFrame(() => {
const node = ref.current;
if (!node) return;
const first = node.querySelector<HTMLButtonElement>("button:not(:disabled)");
first?.focus();
// 8px viewport margin so the menu doesn't kiss the edge — matches
// the floating-tooltip top-edge clamp in Tooltip.tsx.
const margin = 8;
const rect = node.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
let x = contextMenu.x;
let y = contextMenu.y;
if (x + rect.width + margin > vw) x = Math.max(margin, vw - rect.width - margin);
if (y + rect.height + margin > vh) y = Math.max(margin, vh - rect.height - margin);
if (x !== contextMenu.x || y !== contextMenu.y) setClamped({ x, y });
});
}, [contextMenu?.nodeId]);
return () => cancelAnimationFrame(raf);
}, [contextMenu?.nodeId, contextMenu?.x, contextMenu?.y]);
// Close on click outside or Escape
useEffect(() => {
@@ -287,24 +310,24 @@ export function ContextMenu() {
role="menu"
aria-label={`Actions for ${contextMenu.nodeData.name}`}
onKeyDown={handleMenuKeyDown}
className="fixed z-[60] min-w-[200px] bg-zinc-950/95 backdrop-blur-xl border border-zinc-800/60 rounded-xl shadow-2xl shadow-black/60 py-1 overflow-hidden"
style={{ left: contextMenu.x, top: contextMenu.y }}
className="fixed z-[60] min-w-[200px] bg-surface/95 backdrop-blur-xl border border-line/60 rounded-xl shadow-2xl shadow-black/60 py-1 overflow-hidden"
style={{ left: clamped?.x ?? contextMenu.x, top: clamped?.y ?? contextMenu.y }}
>
{/* Header */}
<div className="px-3.5 py-2 border-b border-zinc-800/40 mb-0.5">
<div className="text-[11px] font-semibold text-zinc-200 truncate">{contextMenu.nodeData.name}</div>
<div className="px-3.5 py-2 border-b border-line/40 mb-0.5">
<div className="text-[11px] font-semibold text-ink truncate">{contextMenu.nodeData.name}</div>
<div className="flex items-center gap-1.5 mt-0.5">
<div
aria-hidden="true"
className={`w-1.5 h-1.5 rounded-full ${statusDotClass(contextMenu.nodeData.status)}`}
/>
<span className="text-[10px] text-zinc-500">{contextMenu.nodeData.status}</span>
<span className="text-[10px] text-ink-soft">{contextMenu.nodeData.status}</span>
</div>
</div>
{items.map((item, i) => {
if (item.divider) {
return <div key={i} role="separator" className="h-px bg-zinc-800/60 my-1" />;
return <div key={i} role="separator" className="h-px bg-surface-card/60 my-1" />;
}
return (
<button
@@ -314,10 +337,10 @@ export function ContextMenu() {
onClick={item.action}
disabled={item.disabled}
aria-disabled={item.disabled}
className={`w-full px-3.5 py-1.5 flex items-center gap-2.5 text-left text-[11px] transition-colors focus:outline-none focus:ring-1 focus:ring-inset focus:ring-zinc-600 disabled:opacity-25 disabled:cursor-not-allowed ${
className={`w-full px-3.5 py-1.5 flex items-center gap-2.5 text-left text-[11px] transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-accent/50 disabled:opacity-25 disabled:cursor-not-allowed ${
item.danger
? "text-red-400 hover:bg-red-950/40 hover:text-red-300"
: "text-zinc-300 hover:bg-zinc-800/40 hover:text-zinc-100"
? "text-bad hover:bg-red-950/40 hover:text-bad"
: "text-ink-mid hover:bg-surface-card/40 hover:text-ink"
}`}
>
<span aria-hidden="true" className="w-4 text-center text-[10px] shrink-0 opacity-50">{item.icon}</span>
@@ -99,14 +99,14 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
aria-label="Conversation trace"
>
{/* Modal panel */}
<div className="relative bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl max-w-[700px] w-full max-h-[85vh] flex flex-col overflow-hidden">
<div className="relative bg-surface-sunken border border-line rounded-xl shadow-2xl max-w-[700px] w-full max-h-[85vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-5 py-3 border-b border-zinc-800">
<div className="flex items-center justify-between px-5 py-3 border-b border-line">
<div>
<Dialog.Title className="text-sm font-semibold text-zinc-100">
<Dialog.Title className="text-sm font-semibold text-ink">
Conversation Trace
</Dialog.Title>
<p className="text-[10px] text-zinc-500 mt-0.5">
<p className="text-[10px] text-ink-soft mt-0.5">
{entries.length} events across all workspaces
</p>
</div>
@@ -114,7 +114,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
<button
type="button"
aria-label="Close conversation trace"
className="text-zinc-500 hover:text-zinc-300 text-lg px-2"
className="text-ink-soft hover:text-ink-mid text-lg px-2"
>
</button>
@@ -124,13 +124,13 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
{/* Timeline */}
<div className="flex-1 overflow-y-auto px-5 py-4">
{loading && (
<div className="text-xs text-zinc-500 text-center py-8">
<div className="text-xs text-ink-soft text-center py-8">
Loading trace from all workspaces...
</div>
)}
{!loading && entries.length === 0 && (
<div className="text-xs text-zinc-500 text-center py-8">
<div className="text-xs text-ink-soft text-center py-8">
No activity found
</div>
)}
@@ -160,28 +160,28 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
: isSend
? "bg-cyan-500"
: isReceive
? "bg-blue-500"
: "bg-zinc-600"
? "bg-accent"
: "bg-surface-card"
}`}
/>
<div className="w-px flex-1 bg-zinc-800 min-h-[8px]" />
<div className="w-px flex-1 bg-surface-card min-h-[8px]" />
</div>
{/* Content */}
<div className="flex-1 pb-3 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[9px] text-zinc-400 font-mono">
<span className="text-[9px] text-ink-mid font-mono">
{time}
</span>
<span
className={`text-[9px] font-semibold px-1.5 py-0.5 rounded ${
isError
? "bg-red-950/50 text-red-400"
? "bg-red-950/50 text-bad"
: isSend
? "bg-cyan-950/50 text-cyan-400"
: isReceive
? "bg-blue-950/50 text-blue-400"
: "bg-zinc-800 text-zinc-400"
? "bg-blue-950/50 text-accent"
: "bg-surface-card text-ink-mid"
}`}
>
{isSend
@@ -191,7 +191,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
: entry.activity_type.toUpperCase()}
</span>
{entry.duration_ms != null && entry.duration_ms > 0 && (
<span className="text-[9px] text-zinc-400">
<span className="text-[9px] text-ink-mid">
{entry.duration_ms > 1000
? `${Math.round(entry.duration_ms / 1000)}s`
: `${entry.duration_ms}ms`}
@@ -207,19 +207,19 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
<span className="text-cyan-400 font-medium">
{sourceName || wsName}
</span>
<span className="text-zinc-400"> </span>
<span className="text-blue-400 font-medium">
<span className="text-ink-mid"> </span>
<span className="text-accent font-medium">
{targetName}
</span>
</span>
) : (
<span>
<span className="text-blue-400 font-medium">
<span className="text-accent font-medium">
{targetName || wsName}
</span>
{sourceName && (
<>
<span className="text-zinc-400">
<span className="text-ink-mid">
{" "} {" "}
</span>
<span className="text-cyan-400 font-medium">
@@ -234,40 +234,40 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
{/* Summary */}
{entry.summary && !isA2A(entry) && (
<div className="text-[10px] text-zinc-400 mt-1">
<span className="text-zinc-300 font-medium">{wsName}:</span>{" "}
<div className="text-[10px] text-ink-mid mt-1">
<span className="text-ink-mid font-medium">{wsName}:</span>{" "}
{entry.summary}
</div>
)}
{/* Error */}
{isError && entry.error_detail && (
<div className="text-[10px] text-red-400/80 mt-1 truncate">
<div className="text-[10px] text-bad/80 mt-1 truncate">
{entry.error_detail.slice(0, 200)}
</div>
)}
{/* Message content — show request and/or response */}
{requestText && (
<div className="mt-1.5 bg-zinc-950/60 border border-zinc-800/50 rounded-lg px-3 py-2 max-h-32 overflow-y-auto">
<div className="text-[8px] text-zinc-500 uppercase mb-1">
<div className="mt-1.5 bg-surface/60 border border-line/50 rounded-lg px-3 py-2 max-h-32 overflow-y-auto">
<div className="text-[8px] text-ink-soft uppercase mb-1">
{isSend ? "Task" : "Request"}
</div>
<div className="text-[10px] text-zinc-300 whitespace-pre-wrap break-words leading-relaxed">
<div className="text-[10px] text-ink-mid whitespace-pre-wrap break-words leading-relaxed">
{requestText.slice(0, 2000)}
{requestText.length > 2000 && (
<span className="text-zinc-400"> ...({requestText.length} chars)</span>
<span className="text-ink-mid"> ...({requestText.length} chars)</span>
)}
</div>
</div>
)}
{responseText && (
<div className="mt-1 bg-zinc-950/60 border border-emerald-900/30 rounded-lg px-3 py-2 max-h-32 overflow-y-auto">
<div className="text-[8px] text-emerald-500/60 uppercase mb-1">Response</div>
<div className="text-[10px] text-zinc-300 whitespace-pre-wrap break-words leading-relaxed">
<div className="mt-1 bg-surface/60 border border-emerald-900/30 rounded-lg px-3 py-2 max-h-32 overflow-y-auto">
<div className="text-[8px] text-good/60 uppercase mb-1">Response</div>
<div className="text-[10px] text-ink-mid whitespace-pre-wrap break-words leading-relaxed">
{responseText.slice(0, 2000)}
{responseText.length > 2000 && (
<span className="text-zinc-400"> ...({responseText.length} chars)</span>
<span className="text-ink-mid"> ...({responseText.length} chars)</span>
)}
</div>
</div>
@@ -281,11 +281,11 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
</div>
{/* Footer */}
<div className="px-5 py-3 border-t border-zinc-800 bg-zinc-950/50 flex justify-end">
<div className="px-5 py-3 border-t border-line bg-surface/50 flex justify-end">
<Dialog.Close asChild>
<button
type="button"
className="px-4 py-1.5 text-[12px] bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg transition-colors"
className="px-4 py-1.5 text-[12px] bg-surface-card hover:bg-surface-card text-ink-mid rounded-lg transition-colors"
>
Close
</button>
+19 -11
View File
@@ -98,26 +98,34 @@ export function CookieConsent() {
};
return (
<div
role="dialog"
aria-modal="true"
// role="region" + aria-label, NOT role="dialog" + aria-modal. The
// banner is informational — it never blocks the page, never traps
// focus, and the user can keep using the canvas while it's up.
// Claiming aria-modal="true" without a focus trap is genuinely
// harmful for screen-reader users: they get told the rest of the
// page is inert, jump into the banner, and then can't escape.
// Region semantics let assistive tech navigate around it normally.
// (Also: forcing a modal cookie banner would be a dark pattern —
// GDPR explicitly discourages it.)
<section
role="region"
aria-labelledby="cookie-consent-title"
aria-describedby="cookie-consent-body"
className="fixed bottom-0 left-0 right-0 z-[9999] border-t border-zinc-800 bg-zinc-950/95 backdrop-blur-sm p-4 shadow-[0_-4px_12px_rgba(0,0,0,0.4)]"
className="fixed bottom-0 left-0 right-0 z-[9999] border-t border-line bg-surface/95 backdrop-blur-sm p-4 shadow-[0_-4px_12px_rgba(0,0,0,0.4)]"
>
<div className="mx-auto flex max-w-5xl flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="text-sm text-zinc-300">
<p id="cookie-consent-title" className="font-medium text-zinc-100">
<div className="text-sm text-ink-mid">
<p id="cookie-consent-title" className="font-medium text-ink">
Cookies &amp; your privacy
</p>
<p id="cookie-consent-body" className="mt-1 text-zinc-400">
<p id="cookie-consent-body" className="mt-1 text-ink-mid">
We use strictly-necessary cookies for authentication and session
continuity. Accept to also allow optional functional cookies that
improve your canvas experience (layout preferences, recent
workspaces). See our{" "}
<a
href="https://moleculesai.app/legal/privacy"
className="text-blue-400 underline hover:text-blue-300"
className="text-accent underline underline-offset-2 hover:text-accent-strong focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 rounded-sm"
target="_blank"
rel="noreferrer"
>
@@ -130,20 +138,20 @@ export function CookieConsent() {
<button
type="button"
onClick={() => decide("rejected")}
className="rounded border border-zinc-700 bg-zinc-900 px-4 py-2 text-sm text-zinc-200 hover:bg-zinc-800"
className="rounded border border-line bg-surface-sunken px-4 py-2 text-sm text-ink hover:bg-surface-card focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
>
Necessary only
</button>
<button
type="button"
onClick={() => decide("accepted")}
className="rounded border border-blue-600 bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500"
className="rounded border border-accent bg-accent-strong px-4 py-2 text-sm font-medium text-white hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
>
Accept all
</button>
</div>
</div>
</div>
</section>
);
}
+29 -29
View File
@@ -310,7 +310,7 @@ export function CreateWorkspaceButton() {
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger asChild>
<button type="button" className="fixed bottom-6 right-6 z-40 px-5 py-2.5 bg-blue-600 hover:bg-blue-500 active:bg-blue-700 text-sm font-medium rounded-xl text-white shadow-lg shadow-blue-600/20 hover:shadow-xl hover:shadow-blue-500/30 transition-all duration-200 flex items-center gap-2">
<button type="button" className="fixed bottom-6 right-6 z-40 px-5 py-2.5 bg-accent hover:bg-accent-strong active:bg-accent text-sm font-medium rounded-xl text-white shadow-lg shadow-accent/20 hover:shadow-xl hover:shadow-accent/30 transition-all duration-200 flex items-center gap-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface">
<svg
width="14"
height="14"
@@ -333,12 +333,12 @@ export function CreateWorkspaceButton() {
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm" />
<Dialog.Content
className="fixed z-50 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-zinc-900 border border-zinc-700/60 rounded-2xl shadow-2xl shadow-black/40 w-[400px] max-h-[90vh] overflow-y-auto p-6"
className="fixed z-50 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-surface-sunken border border-line/60 rounded-2xl shadow-2xl shadow-black/40 w-[400px] max-h-[90vh] overflow-y-auto p-6"
>
<Dialog.Title className="text-base font-semibold text-zinc-100 mb-1">
<Dialog.Title className="text-base font-semibold text-ink mb-1">
Create Workspace
</Dialog.Title>
<p className="text-xs text-zinc-500 mb-5">
<p className="text-xs text-ink-soft mb-5">
Add a new workspace node to the canvas
</p>
@@ -367,7 +367,7 @@ export function CreateWorkspaceButton() {
{/* External toggle — when on, this workspace is BYO-compute:
no template, no model, no hermes provider fields. Backend
returns a copyable connection snippet via the modal. */}
<label className="flex items-start gap-2 rounded-lg border border-zinc-800 p-3 cursor-pointer hover:border-zinc-700 transition-colors">
<label className="flex items-start gap-2 rounded-lg border border-line p-3 cursor-pointer hover:border-line transition-colors">
<input
type="checkbox"
checked={isExternal}
@@ -375,8 +375,8 @@ export function CreateWorkspaceButton() {
className="mt-0.5"
/>
<div className="text-xs">
<div className="text-zinc-200 font-medium">External agent (bring your own compute)</div>
<div className="text-zinc-500 mt-0.5">
<div className="text-ink font-medium">External agent (bring your own compute)</div>
<div className="text-ink-soft mt-0.5">
Skip the container. We&apos;ll return a workspace_id + auth token + ready-to-paste snippet so an agent running on your laptop / server / CI can register via A2A.
</div>
</div>
@@ -398,7 +398,7 @@ export function CreateWorkspaceButton() {
aria-label="Workspace tier"
className={`grid gap-1.5 ${isSaaS ? "grid-cols-1" : "grid-cols-4"}`}
>
<div className={`text-[11px] text-zinc-400 mb-1 ${isSaaS ? "" : "col-span-4"}`}>
<div className={`text-[11px] text-ink-mid mb-1 ${isSaaS ? "" : "col-span-4"}`}>
Tier{isSaaS ? " — dedicated VM" : ""}
</div>
{TIERS.map((t, idx) => (
@@ -413,8 +413,8 @@ export function CreateWorkspaceButton() {
onKeyDown={(e) => handleRadioKeyDown(e, idx)}
className={`py-2 rounded-lg text-center transition-colors ${
tier === t.value
? "bg-blue-600/20 border border-blue-500/50 text-blue-300"
: "bg-zinc-800/60 border border-zinc-700/40 text-zinc-400 hover:text-zinc-300 hover:border-zinc-600"
? "bg-accent-strong/20 border border-accent/50 text-accent"
: "bg-surface-card/60 border border-line/40 text-ink-mid hover:text-ink-mid hover:border-line"
}`}
>
<div className="text-xs font-mono font-semibold">
@@ -429,13 +429,13 @@ export function CreateWorkspaceButton() {
</div>
<div>
<label className="text-[11px] text-zinc-400 block mb-1">
<label className="text-[11px] text-ink-mid block mb-1">
Parent Workspace
</label>
<select
value={parentId}
onChange={(e) => setParentId(e.target.value)}
className="w-full bg-zinc-800/60 border border-zinc-700/50 rounded-lg px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-blue-500/60 focus:ring-1 focus:ring-blue-500/20 transition-colors"
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors"
>
<option value="">None (root level)</option>
{workspaces.map((ws) => (
@@ -456,7 +456,7 @@ export function CreateWorkspaceButton() {
<p className="text-[11px] font-semibold text-violet-400 uppercase tracking-wide">
Hermes Provider
</p>
<p className="text-[11px] text-zinc-500 -mt-1">
<p className="text-[11px] text-ink-soft -mt-1">
Choose the AI provider and paste your API key. The key is
stored as an encrypted workspace secret.
</p>
@@ -464,7 +464,7 @@ export function CreateWorkspaceButton() {
<div>
<label
htmlFor="hermes-provider-select"
className="text-[11px] text-zinc-400 block mb-1"
className="text-[11px] text-ink-mid block mb-1"
>
Provider
</label>
@@ -473,7 +473,7 @@ export function CreateWorkspaceButton() {
value={hermesProvider}
onChange={(e) => setHermesProvider(e.target.value)}
aria-label="Hermes provider"
className="w-full bg-zinc-800/60 border border-zinc-700/50 rounded-lg px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/20 transition-colors"
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/20 transition-colors"
>
{availableProviders.map((p) => (
<option key={p.id} value={p.id}>
@@ -486,10 +486,10 @@ export function CreateWorkspaceButton() {
<div>
<label
htmlFor="hermes-api-key-input"
className="text-[11px] text-zinc-400 block mb-1"
className="text-[11px] text-ink-mid block mb-1"
>
API Key{" "}
<span aria-hidden="true" className="text-red-400">
<span aria-hidden="true" className="text-bad">
*
</span>
<span className="sr-only"> (required)</span>
@@ -502,17 +502,17 @@ export function CreateWorkspaceButton() {
placeholder="sk-…"
aria-label="Hermes API key"
autoComplete="off"
className="w-full bg-zinc-800/60 border border-zinc-700/50 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-600 focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/20 transition-colors font-mono"
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink placeholder-ink-soft focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/20 transition-colors font-mono"
/>
</div>
<div>
<label
htmlFor="hermes-model-input"
className="text-[11px] text-zinc-400 block mb-1"
className="text-[11px] text-ink-mid block mb-1"
>
Model{" "}
<span aria-hidden="true" className="text-red-400">
<span aria-hidden="true" className="text-bad">
*
</span>
<span className="sr-only"> (required)</span>
@@ -527,14 +527,14 @@ export function CreateWorkspaceButton() {
autoComplete="off"
spellCheck={false}
list="hermes-model-suggestions"
className="w-full bg-zinc-800/60 border border-zinc-700/50 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-600 focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/20 transition-colors font-mono"
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink placeholder-ink-soft focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/20 transition-colors font-mono"
/>
<datalist id="hermes-model-suggestions">
{HERMES_PROVIDERS.find((p) => p.id === hermesProvider)?.models.map(
(m) => <option key={m} value={m} />,
)}
</datalist>
<p className="text-[10px] text-zinc-500 mt-1">
<p className="text-[10px] text-ink-soft mt-1">
Slug determines which provider hermes routes to at install time.
</p>
</div>
@@ -544,7 +544,7 @@ export function CreateWorkspaceButton() {
{error && (
<div
role="alert"
className="mt-4 px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-xs text-red-400"
className="mt-4 px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-xs text-bad"
>
{error}
</div>
@@ -552,7 +552,7 @@ export function CreateWorkspaceButton() {
<div className="flex justify-end gap-2.5 mt-6">
<Dialog.Close asChild>
<button type="button" className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-sm rounded-lg text-zinc-300 transition-colors">
<button type="button" className="px-4 py-2 bg-surface-card hover:bg-surface-elevated hover:text-ink text-sm rounded-lg text-ink-mid transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-surface">
Cancel
</button>
</Dialog.Close>
@@ -560,7 +560,7 @@ export function CreateWorkspaceButton() {
type="button"
onClick={handleCreate}
disabled={creating}
className="px-5 py-2 bg-blue-600 hover:bg-blue-500 active:bg-blue-700 text-sm rounded-lg text-white disabled:opacity-50 transition-colors"
className="px-5 py-2 bg-accent hover:bg-accent-strong active:bg-accent text-sm rounded-lg text-white disabled:opacity-50 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
{creating ? "Creating..." : "Create"}
</button>
@@ -604,11 +604,11 @@ function InputField({
return (
<div>
<label htmlFor={inputId} className="text-[11px] text-zinc-400 block mb-1">
<label htmlFor={inputId} className="text-[11px] text-ink-mid block mb-1">
{label}{" "}
{required && (
<>
<span aria-hidden="true" className="text-red-400">
<span aria-hidden="true" className="text-bad">
*
</span>
<span className="sr-only"> (required)</span>
@@ -623,10 +623,10 @@ function InputField({
placeholder={placeholder}
min={type === "number" ? "0" : undefined}
step={type === "number" ? "0.01" : undefined}
className={`w-full bg-zinc-800/60 border border-zinc-700/50 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-blue-500/60 focus:ring-1 focus:ring-blue-500/20 transition-colors ${mono ? "font-mono text-xs" : ""}`}
className={`w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink placeholder-ink-soft focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors ${mono ? "font-mono text-xs" : ""}`}
/>
{helper && (
<p className="mt-1 text-xs text-zinc-500">{helper}</p>
<p className="mt-1 text-xs text-ink-soft">{helper}</p>
)}
</div>
);
@@ -89,10 +89,10 @@ export function DeleteCascadeConfirmDialog({
role="dialog"
aria-modal="true"
aria-labelledby="cascade-dialog-title"
className="relative bg-zinc-900 border border-red-800/60 rounded-xl shadow-2xl shadow-black/50 max-w-[420px] w-full mx-4 overflow-hidden"
className="relative bg-surface-sunken border border-red-800/60 rounded-xl shadow-2xl shadow-black/50 max-w-[420px] w-full mx-4 overflow-hidden"
>
<div className="px-5 py-4 border-b border-zinc-800">
<h3 id="cascade-dialog-title" className="text-sm font-semibold text-red-400">
<div className="px-5 py-4 border-b border-line">
<h3 id="cascade-dialog-title" className="text-sm font-semibold text-bad">
Delete Workspace and Children
</h3>
</div>
@@ -101,20 +101,20 @@ export function DeleteCascadeConfirmDialog({
{/* Warning */}
<div className="flex gap-3 mb-4">
<div className="mt-0.5 shrink-0 w-8 h-8 rounded-full bg-red-900/30 flex items-center justify-center">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="text-red-400" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="text-bad" aria-hidden="true">
<path d="M8 3L14 13H2L8 3Z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"/>
<path d="M8 7v3M8 11.5v.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
</div>
<p className="text-[13px] text-zinc-300 leading-relaxed">
<span className="font-medium text-red-300">"{name}"</span> has{" "}
<strong className="text-zinc-100">{children.length}</strong> child{" "}
<p className="text-[13px] text-ink-mid leading-relaxed">
<span className="font-medium text-bad">"{name}"</span> has{" "}
<strong className="text-ink">{children.length}</strong> child{" "}
{children.length === 1 ? "workspace" : "workspaces"}:
</p>
</div>
{/* Child list */}
<ul className="space-y-1.5 mb-4 ml-4 list-disc list-inside text-[12px] text-zinc-400 max-h-32 overflow-y-auto">
<ul className="space-y-1.5 mb-4 ml-4 list-disc list-inside text-[12px] text-ink-mid max-h-32 overflow-y-auto">
{children.map((c) => (
<li key={c.id} className="truncate" title={c.name}>{c.name}</li>
))}
@@ -122,30 +122,37 @@ export function DeleteCascadeConfirmDialog({
{/* Cascade warning */}
<div className="rounded border border-red-900/40 bg-red-950/20 px-3 py-2.5 mb-4">
<p className="text-[12px] text-red-300/80 leading-relaxed">
<p className="text-[12px] text-bad/80 leading-relaxed">
Deleting will cascade <strong className="text-red-200">all child workspaces and their data will be permanently removed.</strong> This cannot be undone.
</p>
</div>
{/* Checkbox guard */}
{/* Checkbox guard. Ring-offset color was zinc-900 — the dialog
actually sits on bg-surface-sunken, so the offset showed
the wrong color through the ring gap. Switched to the
real bg + a danger-tinted ring. */}
<label className="flex items-start gap-2.5 cursor-pointer group select-none">
<input
type="checkbox"
checked={checked}
onChange={(e) => onCheckedChange(e.target.checked)}
className="mt-0.5 w-4 h-4 rounded border-zinc-600 bg-zinc-800 text-red-500 focus:ring-red-500 focus:ring-offset-0 focus:ring-offset-zinc-900 cursor-pointer"
className="mt-0.5 w-4 h-4 rounded border-line bg-surface-card text-bad cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken"
/>
<span className="text-[12px] text-zinc-400 group-hover:text-zinc-300 leading-relaxed">
<span className="text-[12px] text-ink-mid group-hover:text-ink-mid leading-relaxed">
I understand this will permanently delete all listed workspaces and their data
</span>
</label>
</div>
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-zinc-800 bg-zinc-950/50">
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-line bg-surface/50">
<button
type="button"
onClick={onCancel}
className="px-3.5 py-1.5 text-[13px] text-zinc-400 hover:text-zinc-200 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
// Was hover:bg-surface-card (same as base — silent no-op).
// Lift to surface-elevated to match the Cancel pattern in
// ConfirmDialog. Added focus-visible ring so keyboard users
// see where focus lands.
className="px-3.5 py-1.5 text-[13px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-elevated border border-line hover:border-line-soft rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken"
>
Cancel
</button>
@@ -153,10 +160,13 @@ export function DeleteCascadeConfirmDialog({
type="button"
onClick={onConfirm}
disabled={!checked}
className={`px-3.5 py-1.5 text-[13px] rounded-lg transition-colors
// Hover goes DARKER, not lighter — bg-red-500 on white text
// drops contrast below AA vs bg-red-700. Same trap fixed in
// ConfirmDialog and ApprovalBanner. focus-visible ring matches.
className={`px-3.5 py-1.5 text-[13px] rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken
${checked
? "bg-red-600 hover:bg-red-500 text-white cursor-pointer"
: "bg-red-900/30 text-red-500/40 cursor-not-allowed"
? "bg-red-600 hover:bg-red-700 text-white cursor-pointer"
: "bg-red-900/30 text-bad/40 cursor-not-allowed"
}`}
>
Delete All
+17 -17
View File
@@ -75,11 +75,11 @@ export function EmptyState() {
return (
<div className="absolute inset-0 flex items-start justify-center pointer-events-none z-[1] overflow-y-auto py-8">
<div className="relative max-w-2xl w-full rounded-3xl border border-zinc-800/70 bg-zinc-950/80 backdrop-blur-xl px-8 py-8 text-center shadow-2xl shadow-black/40 pointer-events-auto mx-4">
<div className="relative max-w-2xl w-full rounded-3xl border border-line/70 bg-surface/80 backdrop-blur-xl px-8 py-8 text-center shadow-2xl shadow-black/40 pointer-events-auto mx-4">
<div className="absolute inset-x-8 top-0 h-px bg-gradient-to-r from-transparent via-blue-500/50 to-transparent" />
{/* Logo */}
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-sky-500/20 via-blue-500/20 to-violet-500/20 border border-blue-500/20 flex items-center justify-center">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-sky-500/20 via-blue-500/20 to-violet-500/20 border border-accent/20 flex items-center justify-center">
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
<rect x="3" y="3" width="10" height="10" rx="2" stroke="#60a5fa" strokeWidth="1.5" opacity="0.65" />
<rect x="15" y="3" width="10" height="10" rx="2" stroke="#60a5fa" strokeWidth="1.5" opacity="0.65" />
@@ -91,16 +91,16 @@ export function EmptyState() {
<p className="text-[10px] font-semibold uppercase tracking-[0.28em] text-sky-400/80 mb-2">
Welcome to Molecule AI
</p>
<h2 className="text-xl font-semibold text-zinc-100 mb-1">
<h2 className="text-xl font-semibold text-ink mb-1">
Deploy your first agent
</h2>
<p className="text-sm text-zinc-400 mb-6 leading-relaxed">
<p className="text-sm text-ink-mid mb-6 leading-relaxed">
Pick a template to get started instantly, or create a blank workspace.
</p>
{/* Template grid */}
{loading ? (
<div className="flex items-center justify-center gap-2 text-xs text-zinc-400 py-4">
<div className="flex items-center justify-center gap-2 text-xs text-ink-mid py-4">
<Spinner />
Loading templates...
</div>
@@ -114,21 +114,21 @@ export function EmptyState() {
key={t.id}
onClick={() => void deploy(t)}
disabled={anyDeploying}
className="group rounded-xl border border-zinc-800/60 bg-zinc-900/50 px-3.5 py-3 hover:border-blue-500/40 hover:bg-zinc-900/80 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-zinc-800/60 disabled:hover:bg-zinc-900/50 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
className="group rounded-xl border border-line/60 bg-surface-sunken/50 px-3.5 py-3 hover:border-accent/40 hover:bg-surface-sunken/80 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-line/60 disabled:hover:bg-surface-sunken/50 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/70"
>
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium text-zinc-200 group-hover:text-zinc-100 truncate">
<span className="text-sm font-medium text-ink group-hover:text-ink truncate">
{deploying === t.id ? "Deploying..." : t.name}
</span>
<span className={`text-[8px] font-mono font-semibold px-1.5 py-0.5 rounded-md border ${tierColor}`}>
T{t.tier}
</span>
</div>
<p className="text-[11px] text-zinc-500 line-clamp-2 leading-relaxed">
<p className="text-[11px] text-ink-soft line-clamp-2 leading-relaxed">
{t.description || "No description"}
</p>
{t.skill_count > 0 && (
<p className="text-[9px] text-zinc-500 mt-1.5">
<p className="text-[9px] text-ink-soft mt-1.5">
{t.skill_count} skill{t.skill_count !== 1 ? "s" : ""}
{t.model ? ` · ${t.model}` : ""}
</p>
@@ -144,18 +144,18 @@ export function EmptyState() {
type="button"
onClick={createBlank}
disabled={anyDeploying}
className="w-full rounded-xl border border-dashed border-zinc-700/60 bg-zinc-900/30 px-4 py-3 text-sm text-zinc-400 hover:text-zinc-200 hover:border-zinc-600 hover:bg-zinc-900/50 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:text-zinc-400 disabled:hover:border-zinc-700/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
className="w-full rounded-xl border border-dashed border-line/60 bg-surface-sunken/30 px-4 py-3 text-sm text-ink-mid hover:text-ink hover:border-line hover:bg-surface-sunken/50 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:text-ink-mid disabled:hover:border-line/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/70"
>
{blankCreating ? "Creating..." : "+ Create blank workspace"}
</button>
{/* Org templates — instantiate a whole team in one click */}
<div className="mt-4 pt-4 border-t border-zinc-800/50 text-left">
<div className="mt-4 pt-4 border-t border-line/50 text-left">
<OrgTemplatesSection />
</div>
{displayError && (
<div role="alert" className="mt-3 px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-xs text-red-400">
<div role="alert" className="mt-3 px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-xs text-bad">
{displayError}
</div>
)}
@@ -166,13 +166,13 @@ export function EmptyState() {
{modal}
{/* Tips */}
<div className="mt-5 pt-4 border-t border-zinc-800/50">
<div className="flex items-center justify-center gap-6 text-[10px] text-zinc-400">
<div className="mt-5 pt-4 border-t border-line/50">
<div className="flex items-center justify-center gap-6 text-[10px] text-ink-mid">
<span>Drag to nest workspaces into teams</span>
<span className="text-zinc-700">|</span>
<span className="text-ink-soft">|</span>
<span>Right-click for actions</span>
<span className="text-zinc-700">|</span>
<span>Press <kbd className="px-1 py-0.5 bg-zinc-800 rounded text-zinc-500 font-mono">&#8984;K</kbd> to search</span>
<span className="text-ink-soft">|</span>
<span>Press <kbd className="px-1 py-0.5 bg-surface-card rounded text-ink-soft font-mono">&#8984;K</kbd> to search</span>
</div>
</div>
</div>
+7 -7
View File
@@ -51,8 +51,8 @@ export class ErrorBoundary extends React.Component<
render() {
if (this.state.hasError) {
return (
<div className="fixed inset-0 flex items-center justify-center bg-zinc-950 z-50">
<div className="max-w-md rounded-2xl border border-red-500/30 bg-zinc-900/90 px-8 py-8 text-center shadow-2xl shadow-black/40">
<div className="fixed inset-0 flex items-center justify-center bg-surface z-50">
<div className="max-w-md rounded-2xl border border-red-500/30 bg-surface-sunken/90 px-8 py-8 text-center shadow-2xl shadow-black/40">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-red-500/10 border border-red-500/30">
<svg
width="24"
@@ -70,20 +70,20 @@ export class ErrorBoundary extends React.Component<
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</div>
<h2 className="text-lg font-semibold text-zinc-100 mb-2">
<h2 className="text-lg font-semibold text-ink mb-2">
Something went wrong
</h2>
<p className="text-sm text-zinc-400 mb-1">
<p className="text-sm text-ink-mid mb-1">
An unexpected error occurred while rendering the application.
</p>
<p className="text-xs text-red-400/80 mb-6 font-mono break-all">
<p className="text-xs text-bad/80 mb-6 font-mono break-all">
{this.state.error?.message ?? "Unknown error"}
</p>
<div className="flex items-center justify-center gap-3">
<button
type="button"
onClick={this.handleReload}
className="rounded-lg bg-blue-600 hover:bg-blue-500 px-5 py-2 text-sm font-medium text-white transition-colors"
className="rounded-lg bg-accent-strong hover:bg-accent px-5 py-2 text-sm font-medium text-white transition-colors"
>
Reload
</button>
@@ -93,7 +93,7 @@ export class ErrorBoundary extends React.Component<
e.preventDefault();
this.handleReport();
}}
className="rounded-lg border border-zinc-700 hover:border-zinc-600 px-5 py-2 text-sm font-medium text-zinc-300 hover:text-zinc-100 transition-colors"
className="rounded-lg border border-line hover:border-line px-5 py-2 text-sm font-medium text-ink-mid hover:text-ink transition-colors"
>
Report
</a>
+319 -22
View File
@@ -18,6 +18,157 @@
import { useCallback, useState } from "react";
import * as Dialog from "@radix-ui/react-dialog";
type Tab = "python" | "curl" | "claude" | "mcp" | "hermes" | "codex" | "openclaw" | "fields";
// Per-tab help metadata: docs link, where-to-install link, common errors.
// All URLs verified against repo content (docs/guides/* file paths map to
// docs.molecule.ai/docs/guides/*; canonical hostname confirmed by existing
// blog post canonical metadata) or against the snippet text the operator
// just copied. Never linking to a URL that wasn't already in product —
// dead links here defeat the purpose of "more comprehensive instructions."
const TAB_HELP: Record<
Tab,
{
docsUrl?: string;
docsLabel?: string;
downloadUrl?: string;
downloadLabel?: string;
commonIssues?: { symptom: string; check: string }[];
}
> = {
mcp: {
docsUrl: "https://docs.molecule.ai/docs/guides/mcp-server-setup",
docsLabel: "MCP server setup guide",
downloadUrl: "https://pypi.org/project/molecule-ai-workspace-runtime/",
downloadLabel: "molecule-ai-workspace-runtime on PyPI",
commonIssues: [
{
symptom: "Tools not appearing in your agent",
check:
"Run `claude mcp list` (or your runtime's equivalent) — the molecule entry should be listed. If missing, re-run the `claude mcp add` line.",
},
{
symptom: "ConnectionRefused / DNS error on first call",
check:
"PLATFORM_URL must include the scheme (https://) and have no trailing slash. Verify with `curl $PLATFORM_URL/healthz`.",
},
],
},
python: {
docsUrl:
"https://docs.molecule.ai/docs/guides/external-agent-registration",
docsLabel: "External agent registration guide",
downloadUrl: "https://pypi.org/project/molecule-ai-workspace-runtime/",
downloadLabel: "molecule-ai-workspace-runtime on PyPI",
commonIssues: [
{
symptom: "401 from /heartbeat",
check:
"AUTH_TOKEN expired or wrong workspace_id. Tokens are shown only once at create time — re-create the workspace to get a fresh token.",
},
{
symptom: "AGENT_URL not reachable from platform",
check:
"Public HTTPS URL required for inbound A2A. Use ngrok or Cloudflare Tunnel if your agent is behind NAT.",
},
],
},
claude: {
docsUrl:
"https://docs.molecule.ai/docs/guides/external-agent-registration",
docsLabel: "External agent registration guide",
downloadUrl: "https://claude.com/claude-code",
downloadLabel: "Claude Code (claude.com)",
commonIssues: [
{
symptom: "plugin not installed",
check:
"Run `/plugin marketplace add Molecule-AI/molecule-mcp-claude-channel` then `/plugin install molecule@molecule-mcp-claude-channel` inside Claude Code, then `/reload-plugins`.",
},
{
symptom: "not on the approved channels allowlist",
check:
"Custom channels need `--dangerously-load-development-channels` on the launch command. Team/Enterprise orgs need admin to set `channelsEnabled` + `allowedChannelPlugins` in claude.ai admin settings.",
},
{
symptom: "Inbound messages not arriving",
check:
"Check stderr for `molecule channel: connected — watching N workspace(s)`. Verify ~/.claude/channels/molecule/.env has the right PLATFORM_URL + token.",
},
],
},
hermes: {
docsUrl:
"https://docs.molecule.ai/docs/guides/external-agent-registration",
docsLabel: "External agent registration guide",
downloadUrl: "https://github.com/NousResearch/hermes-agent",
downloadLabel: "hermes-agent (NousResearch)",
commonIssues: [
{
symptom: "Gateway start failure",
check:
"Tail ~/.hermes/gateway.log. YAML duplicate-key in config.yaml is the most common cause — `gateway:` block must appear exactly once.",
},
{
symptom: "Plugin not discovered after install",
check:
"Run `pip show hermes-channel-molecule` to confirm install. Some hermes builds need `hermes plugin reload` before the new platform_plugins entry takes effect.",
},
],
},
codex: {
docsUrl: "https://docs.molecule.ai/docs/guides/mcp-server-setup",
docsLabel: "MCP server setup guide",
downloadUrl: "https://github.com/openai/codex",
downloadLabel: "openai/codex",
commonIssues: [
{
symptom: "[mcp_servers.molecule] not loaded",
check:
"Codex must be ≥ 0.57. Check with `codex --version`; upgrade via `npm install -g @openai/codex@latest`.",
},
{
symptom: "TOML parse error after re-running setup",
check:
"TOML rejects duplicate `[mcp_servers.molecule]` tables. Open ~/.codex/config.toml and remove the old block before pasting the new one.",
},
],
},
openclaw: {
docsUrl: "https://docs.molecule.ai/docs/guides/mcp-server-setup",
docsLabel: "MCP server setup guide",
commonIssues: [
{
symptom: "Gateway not starting",
check:
"Tail ~/.openclaw/gateway.log. The loopback bind requires :18789 to be free — check with `lsof -iTCP:18789`.",
},
{
symptom: "openclaw mcp set rejected",
check:
"The heredoc generates JSON; verify it parsed by running `jq < ~/.openclaw/mcp/molecule.json`. Re-run `openclaw mcp set` if the file is malformed.",
},
],
},
curl: {
docsUrl:
"https://docs.molecule.ai/docs/guides/external-agent-registration",
docsLabel: "External agent registration guide",
commonIssues: [
{
symptom: "401 / 403 on register",
check:
"WORKSPACE_AUTH_TOKEN must be the value shown at workspace create. Tokens are shown only once.",
},
],
},
fields: {
docsUrl:
"https://docs.molecule.ai/docs/guides/external-agent-registration",
docsLabel: "External agent registration guide",
},
};
export interface ExternalConnectionInfo {
workspace_id: string;
platform_url: string;
@@ -40,6 +191,22 @@ export interface ExternalConnectionInfo {
// + inbound. Optional for backward compat with platforms that
// haven't shipped PR #2413 yet.
universal_mcp_snippet?: string;
// Hermes channel snippet — for operators whose external agent IS a
// hermes-agent session. Routes A2A traffic into the hermes gateway
// via the molecule-channel plugin (Molecule-AI/hermes-channel-molecule).
// Long-poll based (no tunnel) — same UX shape as the Claude Code
// channel tab. Gives hermes true push parity. Optional for backward
// compat with platforms that haven't shipped this PR yet.
hermes_channel_snippet?: string;
// Codex MCP config snippet — wires the molecule MCP server into
// ~/.codex/config.toml so codex agents can call platform tools.
// Outbound-tools-only today (codex's MCP client doesn't route
// notifications/*); push parity would need a separate bridge daemon.
codex_snippet?: string;
// OpenClaw MCP config snippet — wires molecule MCP + starts the
// openclaw gateway on loopback. Outbound-tools-only today; push
// parity on an external openclaw needs a sessions.steer bridge.
openclaw_snippet?: string;
}
interface Props {
@@ -47,13 +214,19 @@ interface Props {
onClose: () => void;
}
type Tab = "python" | "curl" | "claude" | "mcp" | "fields";
export function ExternalConnectModal({ info, onClose }: Props) {
// Default to Claude Code when the platform offers it — that's the
// newest + simplest path (no tunnel needed). Falls back to Python
// for older platform builds that don't ship the snippet.
const initialTab: Tab = info?.claude_code_channel_snippet ? "claude" : "python";
// Default to Universal MCP when the platform offers it — runtime-
// agnostic outbound tool path that works for any MCP-aware runtime
// (Claude Code, hermes, codex, etc.) and lets operators inspect the
// primitives before picking a runtime-specific tab. Python SDK is
// the fallback for platforms predating the universal_mcp_snippet
// field. Pre-2026-05-03 the default was "claude" (Claude Code first)
// but operators using non-Claude runtimes opened to a tab they had
// to skip past — universal MCP works for everyone as a starting
// point and the runtime-specific tabs are still one click away.
const initialTab: Tab = info?.universal_mcp_snippet
? "mcp"
: "python";
const [tab, setTab] = useState<Tab>(initialTab);
const [copiedKey, setCopiedKey] = useState<string | null>(null);
@@ -108,18 +281,36 @@ export function ExternalConnectModal({ info, onClose }: Props) {
'MOLECULE_WORKSPACE_TOKEN="<paste from create response>"',
`MOLECULE_WORKSPACE_TOKEN="${info.auth_token}"`,
);
// Hermes channel snippet uses MOLECULE_WORKSPACE_TOKEN (same env-var
// name as Universal MCP). Stamp the auth_token in so the operator's
// copy-paste is fully ready-to-run.
const filledHermes = info.hermes_channel_snippet?.replace(
'MOLECULE_WORKSPACE_TOKEN="<paste from create response>"',
`MOLECULE_WORKSPACE_TOKEN="${info.auth_token}"`,
);
// Codex + OpenClaw snippets carry the placeholder inside the
// generated config block (TOML / JSON respectively). Stamp the
// token in so the copy-paste is one less manual edit.
const filledCodex = info.codex_snippet?.replace(
'MOLECULE_WORKSPACE_TOKEN = "<paste from create response>"',
`MOLECULE_WORKSPACE_TOKEN = "${info.auth_token}"`,
);
const filledOpenClaw = info.openclaw_snippet?.replace(
'WORKSPACE_TOKEN="<paste from create response>"',
`WORKSPACE_TOKEN="${info.auth_token}"`,
);
return (
<Dialog.Root open onOpenChange={(o) => !o && onClose()}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/60 z-50" />
<Dialog.Content className="fixed left-1/2 top-1/2 z-50 w-[min(720px,92vw)] -translate-x-1/2 -translate-y-1/2 rounded-xl bg-zinc-900 border border-zinc-700 p-6 shadow-2xl">
<Dialog.Title className="text-lg font-semibold text-white">
<Dialog.Content className="fixed left-1/2 top-1/2 z-50 w-[min(720px,92vw)] -translate-x-1/2 -translate-y-1/2 rounded-xl bg-surface-sunken border border-line p-6 shadow-2xl">
<Dialog.Title className="text-lg font-semibold text-ink">
Connect your external agent
</Dialog.Title>
<Dialog.Description className="mt-1 text-sm text-zinc-400">
<Dialog.Description className="mt-1 text-sm text-ink-mid">
Paste the snippet below into your agent&apos;s deployment. The
auth token is shown <span className="text-amber-400">only once</span>
auth token is shown <span className="text-warm">only once</span>
{" "} save it somewhere safe before closing this dialog.
</Dialog.Description>
@@ -127,7 +318,7 @@ export function ExternalConnectModal({ info, onClose }: Props) {
<div
role="tablist"
aria-label="Connection snippet format"
className="mt-4 flex gap-1 border-b border-zinc-800"
className="mt-4 flex gap-1 border-b border-line"
>
{(() => {
// Build the tab order dynamically. Claude Code first
@@ -135,10 +326,18 @@ export function ExternalConnectModal({ info, onClose }: Props) {
// SDK second (full register+heartbeat+inbound); Universal
// MCP third (any MCP-aware runtime, outbound-only); curl
// for one-shot register; Fields for raw values.
// Tab order: Universal MCP first (default, runtime-
// agnostic primitives), then runtime-specific channel/
// SDK tabs, then curl + Fields. Each runtime tab only
// appears when the platform supplies the snippet — no
// dead "tab missing snippet" UX.
const tabs: Tab[] = [];
if (filledChannel) tabs.push("claude");
tabs.push("python");
if (filledUniversalMcp) tabs.push("mcp");
tabs.push("python");
if (filledChannel) tabs.push("claude");
if (filledHermes) tabs.push("hermes");
if (filledCodex) tabs.push("codex");
if (filledOpenClaw) tabs.push("openclaw");
tabs.push("curl", "fields");
return tabs;
})().map((t) => (
@@ -150,12 +349,18 @@ export function ExternalConnectModal({ info, onClose }: Props) {
onClick={() => setTab(t)}
className={`px-3 py-2 text-sm border-b-2 -mb-px transition-colors ${
tab === t
? "border-blue-500 text-white"
: "border-transparent text-zinc-500 hover:text-zinc-300"
? "border-accent text-ink"
: "border-transparent text-ink-soft hover:text-ink-mid"
}`}
>
{t === "claude"
? "Claude Code"
: t === "hermes"
? "Hermes"
: t === "codex"
? "Codex"
: t === "openclaw"
? "OpenClaw"
: t === "python"
? "Python SDK"
: t === "mcp"
@@ -205,6 +410,33 @@ export function ExternalConnectModal({ info, onClose }: Props) {
onCopy={() => copy(filledUniversalMcp, "mcp")}
/>
)}
{tab === "hermes" && filledHermes && (
<SnippetBlock
value={filledHermes}
label="Hermes channel — bridges this workspace's A2A traffic into your hermes-agent session as platform messages (push parity with Claude Code). Long-poll based; no tunnel needed."
copyKey="hermes"
copied={copiedKey === "hermes"}
onCopy={() => copy(filledHermes, "hermes")}
/>
)}
{tab === "codex" && filledCodex && (
<SnippetBlock
value={filledCodex}
label="Codex MCP config — wires the molecule MCP server into ~/.codex/config.toml. Outbound tools today; inbound A2A push needs the Python SDK tab paired in (codex's MCP runtime doesn't route arbitrary notifications/* yet)."
copyKey="codex"
copied={copiedKey === "codex"}
onCopy={() => copy(filledCodex, "codex")}
/>
)}
{tab === "openclaw" && filledOpenClaw && (
<SnippetBlock
value={filledOpenClaw}
label="OpenClaw MCP config — wires the molecule MCP server via openclaw mcp set + starts the gateway on loopback. Outbound tools today; inbound A2A push on an external openclaw needs the Python SDK tab paired in (a sessions.steer bridge daemon is future work)."
copyKey="openclaw"
copied={copiedKey === "openclaw"}
onCopy={() => copy(filledOpenClaw, "openclaw")}
/>
)}
{tab === "fields" && (
<div className="space-y-2">
<Field label="workspace_id" value={info.workspace_id} onCopy={() => copy(info.workspace_id, "wsid")} copied={copiedKey === "wsid"} />
@@ -220,13 +452,14 @@ export function ExternalConnectModal({ info, onClose }: Props) {
<Field label="heartbeat_endpoint" value={info.heartbeat_endpoint} onCopy={() => copy(info.heartbeat_endpoint, "hb")} copied={copiedKey === "hb"} />
</div>
)}
<HelpBlock help={TAB_HELP[tab]} />
</div>
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-200"
className="px-4 py-2 text-sm rounded-lg bg-surface-card hover:bg-surface-card text-ink"
>
I&apos;ve saved it close
</button>
@@ -252,22 +485,86 @@ function SnippetBlock({
return (
<div>
<div className="flex items-center justify-between pb-1">
<span className="text-xs text-zinc-500">{label}</span>
<span className="text-xs text-ink-soft">{label}</span>
<button
type="button"
onClick={onCopy}
className="text-xs px-2 py-1 rounded bg-blue-600/80 hover:bg-blue-500 text-white"
className="text-xs px-2 py-1 rounded bg-accent-strong/80 hover:bg-accent text-white"
>
{copied ? "Copied!" : "Copy"}
</button>
</div>
<pre className="text-xs bg-zinc-950 border border-zinc-800 rounded-lg p-3 max-h-80 overflow-auto whitespace-pre-wrap break-all font-mono text-zinc-200">
<pre className="text-xs bg-surface border border-line rounded-lg p-3 max-h-80 overflow-auto whitespace-pre-wrap break-all font-mono text-ink">
{value}
</pre>
</div>
);
}
// HelpBlock — collapsible "Need help?" section under each tab's snippet.
// Renders only the keys present in the per-tab help metadata (no empty
// sections). Closed by default so the snippet stays the visual focus;
// operators with a working setup never see this. Uses native <details>
// for keyboard accessibility (Tab + Enter) without extra ARIA wiring.
function HelpBlock({
help,
}: {
help: (typeof TAB_HELP)[Tab] | undefined;
}) {
if (!help) return null;
const { docsUrl, docsLabel, downloadUrl, downloadLabel, commonIssues } = help;
if (!docsUrl && !downloadUrl && !commonIssues?.length) return null;
return (
<details className="mt-3 border border-line rounded-lg bg-surface text-xs">
<summary className="cursor-pointer select-none px-3 py-2 text-ink-mid hover:text-ink">
Need help? install link, docs, common errors
</summary>
<div className="px-3 pb-3 pt-1 space-y-2">
{downloadUrl && (
<div>
<span className="text-ink-soft">Where to install: </span>
<a
href={downloadUrl}
target="_blank"
rel="noopener noreferrer"
className="text-accent underline hover:text-accent-strong"
>
{downloadLabel || downloadUrl}
</a>
</div>
)}
{docsUrl && (
<div>
<span className="text-ink-soft">Documentation: </span>
<a
href={docsUrl}
target="_blank"
rel="noopener noreferrer"
className="text-accent underline hover:text-accent-strong"
>
{docsLabel || docsUrl}
</a>
</div>
)}
{commonIssues && commonIssues.length > 0 && (
<div>
<div className="text-ink-soft mb-1">Common errors:</div>
<ul className="space-y-1.5 pl-3">
{commonIssues.map((issue, i) => (
<li key={i}>
<code className="text-warm font-mono">{issue.symptom}</code>
<span className="text-ink-mid"> {issue.check}</span>
</li>
))}
</ul>
</div>
)}
</div>
</details>
);
}
function Field({
label,
value,
@@ -283,9 +580,9 @@ function Field({
}) {
return (
<div className="flex items-center gap-2">
<span className="text-xs text-zinc-500 w-36 shrink-0">{label}</span>
<span className="text-xs text-ink-soft w-36 shrink-0">{label}</span>
<code
className={`flex-1 text-xs bg-zinc-950 border border-zinc-800 rounded px-2 py-1 text-zinc-200 break-all ${mono ? "font-mono" : ""}`}
className={`flex-1 text-xs bg-surface border border-line rounded px-2 py-1 text-ink break-all ${mono ? "font-mono" : ""}`}
>
{value || "(missing)"}
</code>
@@ -293,7 +590,7 @@ function Field({
type="button"
onClick={onCopy}
disabled={!value}
className="text-xs px-2 py-1 rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-200 disabled:opacity-40"
className="text-xs px-2 py-1 rounded bg-surface-card hover:bg-surface-card text-ink disabled:opacity-40"
>
{copied ? "Copied!" : "Copy"}
</button>
+32 -17
View File
@@ -1,11 +1,23 @@
"use client";
import { useEffect, useState } from "react";
import { STATUS_CONFIG } from "@/lib/design-tokens";
import { STATUS_CONFIG, TIER_CONFIG } from "@/lib/design-tokens";
import { useCanvasStore } from "@/store/canvas";
const LEGEND_STATUSES = ["online", "provisioning", "degraded", "failed", "paused", "offline"] as const;
// Tier descriptions kept in sync with CreateWorkspaceDialog.tsx (the
// source of truth for what each tier means semantically). Colors come
// from TIER_CONFIG so the legend swatch matches the badge actually
// rendered on every WorkspaceNode — drift here misled users into
// thinking the legend documented a different tier than the one shown.
const LEGEND_TIERS: ReadonlyArray<{ tier: number; label: string }> = [
{ tier: 1, label: "Sandboxed" },
{ tier: 2, label: "Standard" },
{ tier: 3, label: "Privileged" },
{ tier: 4, label: "Full Access" },
];
// Persist the user's choice across sessions. Default is "open" so
// first-time users still see the symbol key; once dismissed we
// respect that until they explicitly reopen via the floating pill.
@@ -65,7 +77,7 @@ export function Legend() {
onClick={openLegend}
aria-label="Show legend"
title="Show legend"
className={`fixed bottom-6 ${leftClass} z-30 flex items-center gap-1.5 rounded-full bg-zinc-900/95 border border-zinc-700/50 px-3 py-1.5 text-[11px] font-semibold text-zinc-400 uppercase tracking-wider shadow-xl shadow-black/30 backdrop-blur-sm hover:text-zinc-200 hover:border-zinc-600 transition-[left,colors] duration-200`}
className={`fixed bottom-6 ${leftClass} z-30 flex items-center gap-1.5 rounded-full bg-surface-sunken/95 border border-line/50 px-3 py-1.5 text-[11px] font-semibold text-ink-mid uppercase tracking-wider shadow-xl shadow-black/30 backdrop-blur-sm hover:text-ink hover:border-line focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface transition-[left,colors] duration-200`}
>
<span aria-hidden="true" className="text-[10px]"></span>
Legend
@@ -74,15 +86,18 @@ export function Legend() {
}
return (
<div className={`fixed bottom-6 ${leftClass} z-30 bg-zinc-900/95 border border-zinc-700/50 rounded-xl px-4 py-3 shadow-xl shadow-black/30 backdrop-blur-sm max-w-[280px] transition-[left] duration-200`}>
<div className={`fixed bottom-6 ${leftClass} z-30 bg-surface-sunken/95 border border-line/50 rounded-xl px-4 py-3 shadow-xl shadow-black/30 backdrop-blur-sm max-w-[280px] transition-[left] duration-200`}>
<div className="flex items-start justify-between mb-2">
<div className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider">Legend</div>
<div className="text-[11px] font-semibold text-ink-mid uppercase tracking-wider">Legend</div>
<button
type="button"
onClick={closeLegend}
aria-label="Hide legend"
title="Hide legend"
className="-mt-0.5 -mr-1 px-1.5 text-[14px] leading-none text-zinc-500 hover:text-zinc-200 transition-colors"
// 24×24 touch target (was ~10×16, well under WCAG 2.5.5 min).
// Negative margin keeps the visual position the same as before
// — only the hit area + focus ring are larger.
className="-mt-1.5 -mr-1.5 w-6 h-6 inline-flex items-center justify-center rounded text-[14px] leading-none text-ink-soft hover:text-ink hover:bg-surface-card/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 transition-colors"
>
×
</button>
@@ -90,7 +105,7 @@ export function Legend() {
{/* Status */}
<div className="mb-2">
<div className="text-[11px] text-zinc-500 font-medium mb-1">Status</div>
<div className="text-[11px] text-ink-soft font-medium mb-1">Status</div>
<div className="flex flex-wrap gap-x-3 gap-y-1">
{LEGEND_STATUSES.map((s) => (
<StatusItem key={s} color={STATUS_CONFIG[s].dot} label={STATUS_CONFIG[s].label} />
@@ -100,22 +115,22 @@ export function Legend() {
{/* Tiers */}
<div className="mb-2">
<div className="text-[11px] text-zinc-500 font-medium mb-1">Tier</div>
<div className="text-[11px] text-ink-soft font-medium mb-1">Tier</div>
<div className="flex flex-wrap gap-x-3 gap-y-1">
<TierItem tier={1} label="Sandboxed" color="text-sky-300 bg-sky-950/40 border-sky-700/30" />
<TierItem tier={2} label="Standard" color="text-violet-300 bg-violet-950/40 border-violet-700/30" />
<TierItem tier={3} label="Full Access" color="text-amber-300 bg-amber-950/40 border-amber-700/30" />
{LEGEND_TIERS.map(({ tier, label }) => (
<TierItem key={tier} tier={tier} label={label} color={TIER_CONFIG[tier].border} />
))}
</div>
</div>
{/* Communication */}
<div>
<div className="text-[11px] text-zinc-500 font-medium mb-1">Communication</div>
<div className="text-[11px] text-ink-soft font-medium mb-1">Communication</div>
<div className="flex flex-wrap gap-x-3 gap-y-1">
<CommItem icon="↗" color="text-cyan-400" label="A2A Out" />
<CommItem icon="↙" color="text-blue-400" label="A2A In" />
<CommItem icon="◆" color="text-amber-400" label="Task" />
<CommItem icon="!" color="text-red-400" label="Error" />
<CommItem icon="↙" color="text-accent" label="A2A In" />
<CommItem icon="◆" color="text-warm" label="Task" />
<CommItem icon="!" color="text-bad" label="Error" />
</div>
</div>
</div>
@@ -126,7 +141,7 @@ function StatusItem({ color, label }: { color: string; label: string }) {
return (
<div className="flex items-center gap-1">
<div className={`w-1.5 h-1.5 rounded-full ${color}`} />
<span className="text-[11px] text-zinc-400">{label}</span>
<span className="text-[11px] text-ink-mid">{label}</span>
</div>
);
}
@@ -135,7 +150,7 @@ function TierItem({ tier, label, color }: { tier: number; label: string; color:
return (
<div className="flex items-center gap-1">
<span className={`text-[11px] font-mono px-1 py-0.5 rounded border ${color}`}>T{tier}</span>
<span className="text-[11px] text-zinc-400">{label}</span>
<span className="text-[11px] text-ink-mid">{label}</span>
</div>
);
}
@@ -144,7 +159,7 @@ function CommItem({ icon, color, label }: { icon: string; color: string; label:
return (
<div className="flex items-center gap-1">
<span className={`text-[11px] ${color}`}>{icon}</span>
<span className="text-[11px] text-zinc-400">{label}</span>
<span className="text-[11px] text-ink-mid">{label}</span>
</div>
);
}
+40 -40
View File
@@ -54,13 +54,13 @@ function MemorySkeletonRows() {
{Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
className="rounded-lg border border-zinc-800/60 bg-zinc-900/50 px-3 py-3 animate-pulse"
className="rounded-lg border border-line/60 bg-surface-sunken/50 px-3 py-3 animate-pulse"
>
<div className="flex items-center gap-2">
<div className="h-2 rounded bg-zinc-700/50 flex-1" />
<div className="h-2 rounded bg-zinc-700/50 w-8" />
<div className="h-2 rounded bg-zinc-700/50 w-6" />
<div className="h-2 rounded bg-zinc-700/50 w-10" />
<div className="h-2 rounded bg-surface-card/50 flex-1" />
<div className="h-2 rounded bg-surface-card/50 w-8" />
<div className="h-2 rounded bg-surface-card/50 w-6" />
<div className="h-2 rounded bg-surface-card/50 w-10" />
</div>
</div>
))}
@@ -148,7 +148,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
if (loading && entries.length === 0 && !error) {
return (
<div className="flex items-center justify-center h-32">
<span className="text-xs text-zinc-500">Loading memories</span>
<span className="text-xs text-ink-soft">Loading memories</span>
</div>
);
}
@@ -156,7 +156,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
return (
<div className="flex flex-col h-full">
{/* Scope tabs */}
<div className="px-4 pt-3 pb-2 border-b border-zinc-800/40 shrink-0">
<div className="px-4 pt-3 pb-2 border-b border-line/40 shrink-0">
<div className="flex items-center gap-1">
{SCOPES.map((scope) => (
<button
@@ -167,8 +167,8 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
className={[
"px-3 py-1 text-[11px] rounded transition-colors",
activeScope === scope
? "bg-blue-600 text-white"
: "bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200",
? "bg-accent-strong text-white"
: "bg-surface-card text-ink-mid hover:bg-surface-card hover:text-ink",
].join(" ")}
>
{scope}
@@ -178,7 +178,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
</div>
{/* Search bar + namespace filter */}
<div className="px-4 pt-3 pb-2 border-b border-zinc-800/40 shrink-0 space-y-2">
<div className="px-4 pt-3 pb-2 border-b border-line/40 shrink-0 space-y-2">
<div className="relative flex items-center">
{/* Magnifying glass icon */}
<svg
@@ -186,7 +186,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
height="12"
viewBox="0 0 16 16"
fill="none"
className="absolute left-2.5 text-zinc-500 pointer-events-none shrink-0"
className="absolute left-2.5 text-ink-soft pointer-events-none shrink-0"
aria-hidden="true"
>
<circle cx="7" cy="7" r="4.5" stroke="currentColor" strokeWidth="1.5" />
@@ -198,7 +198,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Semantic search…"
aria-label="Search memories"
className="w-full bg-zinc-900 border border-zinc-700/60 focus:border-blue-500/60 rounded-lg pl-8 pr-7 py-1.5 text-[11px] text-zinc-200 placeholder-zinc-600 focus:outline-none transition-colors"
className="w-full bg-surface-sunken border border-line/60 focus:border-accent/60 rounded-lg pl-8 pr-7 py-1.5 text-[11px] text-ink placeholder-zinc-600 focus:outline-none transition-colors"
/>
{searchQuery && (
<button
@@ -208,7 +208,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
setDebouncedQuery("");
}}
aria-label="Clear search"
className="absolute right-2 text-zinc-500 hover:text-zinc-200 transition-colors text-sm leading-none"
className="absolute right-2 text-ink-soft hover:text-ink transition-colors text-sm leading-none"
>
×
</button>
@@ -217,7 +217,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
{/* Namespace filter */}
<div className="flex items-center gap-2">
<label htmlFor="namespace-filter" className="text-[10px] text-zinc-500 shrink-0">
<label htmlFor="namespace-filter" className="text-[10px] text-ink-soft shrink-0">
Namespace:
</label>
<input
@@ -227,14 +227,14 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
onChange={(e) => setActiveNamespace(e.target.value)}
placeholder="all namespaces"
aria-label="Filter by namespace"
className="flex-1 bg-zinc-900 border border-zinc-700/60 focus:border-blue-500/60 rounded px-2 py-1 text-[11px] text-zinc-200 placeholder-zinc-600 focus:outline-none transition-colors min-w-0"
className="flex-1 bg-surface-sunken border border-line/60 focus:border-accent/60 rounded px-2 py-1 text-[11px] text-ink placeholder-zinc-600 focus:outline-none transition-colors min-w-0"
/>
</div>
</div>
{/* Toolbar */}
<div className="px-4 py-2.5 border-b border-zinc-800/40 flex items-center justify-between shrink-0">
<span className="text-[11px] text-zinc-500">
<div className="px-4 py-2.5 border-b border-line/40 flex items-center justify-between shrink-0">
<span className="text-[11px] text-ink-soft">
{debouncedQuery
? `${entries.length} result${entries.length !== 1 ? "s" : ""}`
: entries.length === 1
@@ -244,7 +244,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
<button
type="button"
onClick={loadEntries}
className="px-2 py-1 text-[11px] bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded transition-colors"
className="px-2 py-1 text-[11px] bg-surface-card hover:bg-surface-card text-ink-mid rounded transition-colors"
aria-label="Refresh memories"
>
Refresh
@@ -256,7 +256,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
<div
role="alert"
aria-live="assertive"
className="mx-4 mt-3 px-3 py-2 bg-red-950/30 border border-red-800/40 rounded text-xs text-red-400 shrink-0"
className="mx-4 mt-3 px-3 py-2 bg-red-950/30 border border-red-800/40 rounded text-xs text-bad shrink-0"
>
{error}
</div>
@@ -269,11 +269,11 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
) : entries.length === 0 ? (
debouncedQuery ? (
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
<span className="text-4xl text-zinc-700" aria-hidden="true"></span>
<p className="text-sm font-medium text-zinc-400">
<span className="text-4xl text-ink-soft" aria-hidden="true"></span>
<p className="text-sm font-medium text-ink-mid">
No memories match your search
</p>
<p className="text-[11px] text-zinc-600 max-w-[200px] leading-relaxed">
<p className="text-[11px] text-ink-soft max-w-[200px] leading-relaxed">
Try a different query or{" "}
<button
type="button"
@@ -281,7 +281,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
setSearchQuery("");
setDebouncedQuery("");
}}
className="text-blue-500 hover:text-blue-400 underline transition-colors"
className="text-accent hover:text-accent underline transition-colors"
>
clear the search
</button>
@@ -290,9 +290,9 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
</div>
) : (
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
<span className="text-4xl text-zinc-700" aria-hidden="true"></span>
<p className="text-sm font-medium text-zinc-400">No {activeScope} memories</p>
<p className="text-[11px] text-zinc-600 max-w-[200px] leading-relaxed">
<span className="text-4xl text-ink-soft" aria-hidden="true"></span>
<p className="text-sm font-medium text-ink-mid">No {activeScope} memories</p>
<p className="text-[11px] text-ink-soft max-w-[200px] leading-relaxed">
{activeScope === "LOCAL"
? "This workspace has not written any local memories yet."
: activeScope === "TEAM"
@@ -340,11 +340,11 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
const bodyId = `mem-body-${sanitizeId(entry.id)}`;
return (
<div className="rounded-lg border border-zinc-800/60 bg-zinc-900/50 overflow-hidden">
<div className="rounded-lg border border-line/60 bg-surface-sunken/50 overflow-hidden">
{/* Header row */}
<button
type="button"
className="w-full flex items-center gap-2 px-3 py-2.5 text-left hover:bg-zinc-800/30 transition-colors"
className="w-full flex items-center gap-2 px-3 py-2.5 text-left hover:bg-surface-card/30 transition-colors"
onClick={() => setExpanded((prev) => !prev)}
aria-expanded={expanded}
aria-controls={bodyId}
@@ -354,9 +354,9 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
className={[
"text-[9px] shrink-0 font-mono px-1 py-0.5 rounded",
entry.scope === "LOCAL"
? "bg-zinc-700 text-zinc-400"
? "bg-surface-card text-ink-mid"
: entry.scope === "TEAM"
? "bg-blue-950 text-blue-400"
? "bg-blue-950 text-accent"
: "bg-violet-950 text-violet-400",
].join(" ")}
title={`Scope: ${entry.scope}`}
@@ -365,12 +365,12 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
</span>
{/* Namespace tag */}
<span className="text-[9px] shrink-0 font-mono text-zinc-500 truncate max-w-[80px]" title={entry.namespace}>
<span className="text-[9px] shrink-0 font-mono text-ink-soft truncate max-w-[80px]" title={entry.namespace}>
{entry.namespace}
</span>
{/* Content preview */}
<span className="flex-1 min-w-0 text-[10px] font-mono text-zinc-300 truncate text-left">
<span className="flex-1 min-w-0 text-[10px] font-mono text-ink-mid truncate text-left">
{entry.content.length > 60 ? entry.content.slice(0, 60) + "…" : entry.content}
</span>
@@ -380,8 +380,8 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
className={[
"text-[9px] shrink-0 font-mono tabular-nums",
entry.similarity_score >= 0.8
? "text-blue-500"
: "text-zinc-400",
? "text-accent"
: "text-ink-mid",
].join(" ")}
title={`Similarity: ${(entry.similarity_score * 100).toFixed(1)}%`}
data-testid="similarity-badge"
@@ -390,10 +390,10 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
</span>
)}
<span className="text-[9px] text-zinc-600 shrink-0">
<span className="text-[9px] text-ink-soft shrink-0">
{formatRelativeTime(entry.created_at)}
</span>
<span className="text-[9px] text-zinc-500 shrink-0" aria-hidden="true">
<span className="text-[9px] text-ink-soft shrink-0" aria-hidden="true">
{expanded ? "▼" : "▶"}
</span>
</button>
@@ -404,13 +404,13 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
id={bodyId}
role="region"
aria-label="Memory details"
className="border-t border-zinc-800/50 px-3 pb-3 pt-2 space-y-2"
className="border-t border-line/50 px-3 pb-3 pt-2 space-y-2"
>
<pre className="text-[10px] font-mono text-zinc-300 bg-zinc-950 rounded p-2 overflow-x-auto max-h-48 whitespace-pre-wrap break-all">
<pre className="text-[10px] font-mono text-ink-mid bg-surface rounded p-2 overflow-x-auto max-h-48 whitespace-pre-wrap break-all">
{entry.content}
</pre>
<div className="flex items-center justify-between gap-2">
<span className="text-[9px] text-zinc-600">
<span className="text-[9px] text-ink-soft">
Created: {new Date(entry.created_at).toLocaleString()}
</span>
<button
@@ -420,7 +420,7 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
onDelete();
}}
aria-label="Delete memory"
className="text-[10px] px-2 py-0.5 bg-red-950/40 hover:bg-red-900/50 border border-red-900/30 rounded text-red-400 transition-colors shrink-0"
className="text-[10px] px-2 py-0.5 bg-red-950/40 hover:bg-red-900/50 border border-red-900/30 rounded text-bad transition-colors shrink-0"
>
Delete
</button>
+146 -121
View File
@@ -3,7 +3,17 @@
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { createPortal } from "react-dom";
import { api } from "@/lib/api";
import { getKeyLabel, type ProviderChoice } from "@/lib/deploy-preflight";
import {
getKeyLabel,
type ModelSpec,
type ProviderChoice,
} from "@/lib/deploy-preflight";
import {
ProviderModelSelector,
buildProviderCatalog,
findProviderForModel,
type SelectorValue,
} from "./ProviderModelSelector";
interface Props {
open: boolean;
@@ -38,6 +48,14 @@ interface Props {
* the API-key fields. The picker passes the entered slug back via
* onKeysAdded. */
modelSuggestions?: string[];
/** Full model specs from the template (with required_env per model).
* When provided, the picker auto-snaps the provider radio to the
* matching provider as the user changes the model — fixes the
* "type MiniMax model, see ANTHROPIC_API_KEY field" cascade bug
* (sibling of the ConfigTab cascade fix in #2516). Optional so
* callers without model→provider mapping data can still use the
* picker as-is. */
models?: ModelSpec[];
/** Pre-fill the model input. */
initialModel?: string;
/** Override the modal's title + description copy. The default
@@ -83,6 +101,7 @@ export function MissingKeysModal({
workspaceId,
configuredKeys,
modelSuggestions,
models,
initialModel,
title,
description,
@@ -102,6 +121,7 @@ export function MissingKeysModal({
workspaceId={workspaceId}
configuredKeys={configuredKeys}
modelSuggestions={modelSuggestions}
models={models}
initialModel={initialModel}
title={title}
description={description}
@@ -131,6 +151,22 @@ export function MissingKeysModal({
// Provider-picker mode — choose one option, save its env var(s), deploy.
// -----------------------------------------------------------------------------
/** Provider id derived from a model spec — sorted+joined required_env,
* matching the formula in providersFromTemplate(). When the model has
* no required_env (local/self-hosted endpoints) returns null, since
* there's no provider option the radio could snap to. Exported for
* the cascade-snap test. */
export function providerIdForModel(
modelId: string,
models: ModelSpec[] | undefined,
): string | null {
const trimmed = modelId.trim();
if (!trimmed || !models) return null;
const m = models.find((x) => x.id === trimmed);
if (!m?.required_env || m.required_env.length === 0) return null;
return [...m.required_env].sort().join("|");
}
function ProviderPickerModal({
open,
providers,
@@ -141,6 +177,7 @@ function ProviderPickerModal({
workspaceId,
configuredKeys,
modelSuggestions,
models,
initialModel,
title,
description,
@@ -154,45 +191,87 @@ function ProviderPickerModal({
workspaceId?: string;
configuredKeys?: Set<string>;
modelSuggestions?: string[];
models?: ModelSpec[];
initialModel?: string;
title?: string;
description?: string;
}) {
// Prefer the first provider whose env vars are already satisfied by
// the configured set — pre-selecting "the option the user already has
// keys for" matches expected UX. Falls back to providers[0] otherwise.
const initialSelected = useMemo(() => {
// Single model source: `models` from caller when present, else
// synthesize a stub list from the legacy `providers` shape so older
// callers (pre-PR-2534) still drive the picker. ProviderModelSelector
// and findProviderForModel BOTH consume this list — passing the same
// shape to both keeps ids identical, so back-derivation matches the
// dropdown's option values.
const selectorModels = useMemo(() => {
if (models && models.length > 0) return models;
return providers.map((p) => ({
id: p.id,
name: p.label,
required_env: p.envVars,
}));
}, [models, providers]);
const catalog = useMemo(() => buildProviderCatalog(selectorModels), [selectorModels]);
// Initial selector value: prefer back-derivation from initialModel
// (template-deploy passes the template default), then the first
// provider already satisfied by configuredKeys, then catalog[0].
const initial = useMemo<SelectorValue>(() => {
if (initialModel) {
const matched = findProviderForModel(catalog, initialModel);
if (matched) {
return {
providerId: matched.id,
model: initialModel,
envVars: matched.envVars,
};
}
}
if (configuredKeys) {
const satisfied = providers.find((p) =>
const satisfied = catalog.find((p) =>
p.envVars.every((k) => configuredKeys.has(k)),
);
if (satisfied) return satisfied.id;
if (satisfied) {
return {
providerId: satisfied.id,
model: satisfied.wildcard ? "" : satisfied.models[0]?.id ?? "",
envVars: satisfied.envVars,
};
}
}
return providers[0].id;
}, [providers, configuredKeys]);
const first = catalog[0];
if (!first) return { providerId: "", model: "", envVars: [] };
return {
providerId: first.id,
model: first.wildcard ? "" : first.models[0]?.id ?? "",
envVars: first.envVars,
};
}, [catalog, initialModel, configuredKeys]);
const [selectedId, setSelectedId] = useState(initialSelected);
const [selectorValue, setSelectorValue] = useState<SelectorValue>(initial);
const [entries, setEntries] = useState<KeyEntry[]>([]);
const [model, setModel] = useState(initialModel ?? "");
const firstInputRef = useRef<HTMLInputElement>(null);
// Legacy compat: map the selector value back into the old `selected`/
// `model` shape for the rest of the modal body (footer copy, etc.).
const selected = useMemo(
() => providers.find((p) => p.id === selectedId) ?? providers[0],
[providers, selectedId],
() =>
providers.find((p) => p.id === selectorValue.providerId) ??
providers[0],
[providers, selectorValue.providerId],
);
const showModelInput = (modelSuggestions?.length ?? 0) > 0 || initialModel !== undefined;
const model = selectorValue.model;
const showModelInput = catalog.length > 0;
useEffect(() => {
if (!open) return;
setSelectedId(initialSelected);
setModel(initialModel ?? "");
}, [open, initialSelected, initialModel]);
setSelectorValue(initial);
}, [open, initial]);
useEffect(() => {
if (!open) return;
setEntries(
selected.envVars.map((key) => ({
selectorValue.envVars.map((key) => ({
key,
value: "",
// Pre-mark as saved when the key is already in the configured
@@ -203,13 +282,13 @@ function ProviderPickerModal({
error: null,
})),
);
}, [open, selected, configuredKeys]);
}, [open, selectorValue.envVars, configuredKeys]);
useEffect(() => {
if (!open) return;
const raf = requestAnimationFrame(() => firstInputRef.current?.focus());
return () => cancelAnimationFrame(raf);
}, [open, selectedId]);
}, [open, selectorValue.providerId]);
useEffect(() => {
if (!open) return;
@@ -289,9 +368,9 @@ function ProviderPickerModal({
role="dialog"
aria-modal="true"
aria-labelledby="missing-keys-title"
className="relative bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl shadow-black/50 max-w-[480px] w-full mx-4 max-h-[80vh] overflow-auto"
className="relative bg-surface-sunken border border-line rounded-xl shadow-2xl shadow-black/50 max-w-[480px] w-full mx-4 max-h-[80vh] overflow-auto"
>
<div className="px-5 py-4 border-b border-zinc-800">
<div className="px-5 py-4 border-b border-line">
<div className="flex items-center gap-2 mb-1">
<div
className="w-5 h-5 rounded-md bg-amber-600/20 border border-amber-500/30 flex items-center justify-center"
@@ -303,14 +382,14 @@ function ProviderPickerModal({
<circle cx="6" cy="8.5" r="0.5" fill="#fbbf24" />
</svg>
</div>
<h3 id="missing-keys-title" className="text-sm font-semibold text-zinc-100">
<h3 id="missing-keys-title" className="text-sm font-semibold text-ink">
{title ?? "Missing API Keys"}
</h3>
</div>
<p className="text-[12px] text-zinc-400 leading-relaxed">
<p className="text-[12px] text-ink-mid leading-relaxed">
{description ?? (
<>
The <span className="text-amber-300 font-medium">{runtimeLabel}</span>{" "}
The <span className="text-warm font-medium">{runtimeLabel}</span>{" "}
runtime supports multiple providers. Pick one and paste its API key.
</>
)}
@@ -318,89 +397,34 @@ function ProviderPickerModal({
</div>
<div className="px-5 py-4 space-y-3">
{showModelInput && (
<div>
<label
htmlFor="provider-picker-model-input"
className="text-[10px] uppercase tracking-wide text-zinc-500 font-semibold mb-1.5 block"
>
Model{" "}
<span aria-hidden="true" className="text-red-400">*</span>
<span className="sr-only"> (required)</span>
</label>
<input
id="provider-picker-model-input"
type="text"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="e.g. minimax/MiniMax-M2.7"
aria-label="Model slug"
autoComplete="off"
spellCheck={false}
list="provider-picker-model-suggestions"
className="w-full bg-zinc-900 border border-zinc-600 rounded px-2 py-1.5 text-[11px] text-zinc-100 font-mono focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 transition-colors"
/>
<datalist id="provider-picker-model-suggestions">
{modelSuggestions?.map((m) => (
<option key={m} value={m} />
))}
</datalist>
<p className="text-[9px] text-zinc-500 mt-1 leading-relaxed">
Slug determines provider routing at install time.
</p>
</div>
)}
<fieldset className="space-y-1.5">
<legend className="text-[10px] uppercase tracking-wide text-zinc-500 font-semibold mb-1.5">
Provider
</legend>
{providers.map((p) => (
<label
key={p.id}
className={`flex items-start gap-2.5 rounded-lg border px-3 py-2 cursor-pointer transition-colors ${
selectedId === p.id
? "bg-blue-600/15 border-blue-500/50"
: "bg-zinc-800/40 border-zinc-700/50 hover:border-zinc-600"
}`}
>
<input
type="radio"
name="provider"
value={p.id}
checked={selectedId === p.id}
onChange={() => setSelectedId(p.id)}
className="mt-0.5 accent-blue-500"
/>
<div className="min-w-0 flex-1">
<div className="text-[12px] text-zinc-100 font-medium">{p.label}</div>
<div className="text-[10px] font-mono text-zinc-500">
{p.envVars.join(", ")}
</div>
{p.note && (
<div className="text-[10px] text-zinc-500 mt-1 leading-relaxed">
{p.note}
</div>
)}
</div>
</label>
))}
</fieldset>
{/* Shared provider→model selector. Source of truth for provider
taxonomy + model filtering. Same component is used in
ConfigTab so behavior + vendor split is identical across
all 3 deploy surfaces (modal here, settings tab, template
palette flow). */}
<ProviderModelSelector
models={selectorModels}
value={selectorValue}
onChange={setSelectorValue}
variant="stack"
idPrefix="provider-picker"
/>
<div className="space-y-2">
{entries.map((entry, index) => (
<div
key={entry.key}
className="bg-zinc-800/50 rounded-lg px-3 py-2.5 border border-zinc-700/50"
className="bg-surface-card/50 rounded-lg px-3 py-2.5 border border-line/50"
>
<div className="flex items-center justify-between mb-1.5">
<div>
<div className="text-[11px] text-zinc-300 font-medium">
<div className="text-[11px] text-ink-mid font-medium">
{getKeyLabel(entry.key)}
</div>
<div className="text-[9px] font-mono text-zinc-500">{entry.key}</div>
<div className="text-[9px] font-mono text-ink-soft">{entry.key}</div>
</div>
{entry.saved && (
<span className="text-[9px] text-emerald-400 bg-emerald-900/30 px-1.5 py-0.5 rounded flex items-center gap-1">
<span className="text-[9px] text-good bg-emerald-900/30 px-1.5 py-0.5 rounded flex items-center gap-1">
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
<path d="M1.5 4L3.5 6L6.5 2" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
@@ -422,12 +446,12 @@ function ProviderPickerModal({
handleSaveKey(index);
}
}}
className="flex-1 bg-zinc-900 border border-zinc-600 rounded px-2 py-1.5 text-[11px] text-zinc-100 font-mono focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 transition-colors"
className="flex-1 bg-surface-sunken border border-line rounded px-2 py-1.5 text-[11px] text-ink font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors"
/>
<button
onClick={() => handleSaveKey(index)}
disabled={!entry.value.trim() || entry.saving}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-[11px] rounded text-white disabled:opacity-30 transition-colors shrink-0"
className="px-3 py-1.5 bg-accent-strong hover:bg-accent text-[11px] rounded text-white disabled:opacity-30 transition-colors shrink-0"
>
{entry.saving ? "..." : "Save"}
</button>
@@ -435,19 +459,19 @@ function ProviderPickerModal({
)}
{entry.error && (
<div className="mt-1.5 text-[10px] text-red-400">{entry.error}</div>
<div className="mt-1.5 text-[10px] text-bad">{entry.error}</div>
)}
</div>
))}
</div>
</div>
<div className="px-5 py-3 border-t border-zinc-800 bg-zinc-950/50 flex items-center justify-between gap-2">
<div className="px-5 py-3 border-t border-line bg-surface/50 flex items-center justify-between gap-2">
<div>
{onOpenSettings && (
<button
onClick={onOpenSettings}
className="text-[11px] text-blue-400 hover:text-blue-300 transition-colors"
className="text-[11px] text-accent hover:text-accent transition-colors"
>
Open Settings Panel
</button>
@@ -456,7 +480,7 @@ function ProviderPickerModal({
<div className="flex items-center gap-2">
<button
onClick={onCancel}
className="px-3.5 py-1.5 text-[12px] text-zinc-400 hover:text-zinc-200 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
className="px-3.5 py-1.5 text-[12px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-card border border-line rounded-lg transition-colors"
>
Cancel Deploy
</button>
@@ -465,9 +489,10 @@ function ProviderPickerModal({
disabled={
!allSaved ||
anySaving ||
!selectorValue.providerId ||
(showModelInput && model.trim() === "")
}
className="px-3.5 py-1.5 text-[12px] bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors disabled:opacity-40"
className="px-3.5 py-1.5 text-[12px] bg-accent-strong hover:bg-accent text-white rounded-lg transition-colors disabled:opacity-40"
>
{allSaved ? "Deploy" : entries.length > 1 ? "Add Keys" : "Add Key"}
</button>
@@ -615,9 +640,9 @@ function AllKeysModal({
role="dialog"
aria-modal="true"
aria-labelledby="missing-keys-title"
className="relative bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl shadow-black/50 max-w-[440px] w-full mx-4 max-h-[80vh] overflow-auto"
className="relative bg-surface-sunken border border-line rounded-xl shadow-2xl shadow-black/50 max-w-[440px] w-full mx-4 max-h-[80vh] overflow-auto"
>
<div className="px-5 py-4 border-b border-zinc-800">
<div className="px-5 py-4 border-b border-line">
<div className="flex items-center gap-2 mb-1">
<div
className="w-5 h-5 rounded-md bg-amber-600/20 border border-amber-500/30 flex items-center justify-center"
@@ -629,12 +654,12 @@ function AllKeysModal({
<circle cx="6" cy="8.5" r="0.5" fill="#fbbf24" />
</svg>
</div>
<h3 id="missing-keys-title" className="text-sm font-semibold text-zinc-100">
<h3 id="missing-keys-title" className="text-sm font-semibold text-ink">
Missing API Keys
</h3>
</div>
<p className="text-[12px] text-zinc-400 leading-relaxed">
The <span className="text-amber-300 font-medium">{runtimeLabel}</span>{" "}
<p className="text-[12px] text-ink-mid leading-relaxed">
The <span className="text-warm font-medium">{runtimeLabel}</span>{" "}
runtime requires the following keys to be configured before deploying.
</p>
</div>
@@ -643,17 +668,17 @@ function AllKeysModal({
{entries.map((entry, index) => (
<div
key={entry.key}
className="bg-zinc-800/50 rounded-lg px-3 py-2.5 border border-zinc-700/50"
className="bg-surface-card/50 rounded-lg px-3 py-2.5 border border-line/50"
>
<div className="flex items-center justify-between mb-1">
<div>
<div className="text-[11px] text-zinc-300 font-medium">
<div className="text-[11px] text-ink-mid font-medium">
{getKeyLabel(entry.key)}
</div>
<div className="text-[9px] font-mono text-zinc-500">{entry.key}</div>
<div className="text-[9px] font-mono text-ink-soft">{entry.key}</div>
</div>
{entry.saved && (
<span className="text-[9px] text-emerald-400 bg-emerald-900/30 px-1.5 py-0.5 rounded flex items-center gap-1">
<span className="text-[9px] text-good bg-emerald-900/30 px-1.5 py-0.5 rounded flex items-center gap-1">
<svg width="8" height="8" viewBox="0 0 8 8" fill="none">
<path d="M1.5 4L3.5 6L6.5 2" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
@@ -675,37 +700,37 @@ function AllKeysModal({
handleSaveKey(index);
}
}}
className="flex-1 bg-zinc-900 border border-zinc-600 rounded px-2 py-1.5 text-[11px] text-zinc-100 font-mono focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 transition-colors"
className="flex-1 bg-surface-sunken border border-line rounded px-2 py-1.5 text-[11px] text-ink font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors"
/>
<button
type="button"
onClick={() => handleSaveKey(index)}
disabled={!entry.value.trim() || entry.saving}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-[11px] rounded text-white disabled:opacity-30 transition-colors shrink-0"
className="px-3 py-1.5 bg-accent-strong hover:bg-accent text-[11px] rounded text-white disabled:opacity-30 transition-colors shrink-0"
>
{entry.saving ? "..." : "Save"}
</button>
</div>
)}
{entry.error && <div className="mt-1.5 text-[10px] text-red-400">{entry.error}</div>}
{entry.error && <div className="mt-1.5 text-[10px] text-bad">{entry.error}</div>}
</div>
))}
{globalError && (
<div className="px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-[11px] text-red-400">
<div className="px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-[11px] text-bad">
{globalError}
</div>
)}
</div>
<div className="px-5 py-3 border-t border-zinc-800 bg-zinc-950/50 flex items-center justify-between gap-2">
<div className="px-5 py-3 border-t border-line bg-surface/50 flex items-center justify-between gap-2">
<div>
{onOpenSettings && (
<button
type="button"
onClick={onOpenSettings}
className="text-[11px] text-blue-400 hover:text-blue-300 transition-colors"
className="text-[11px] text-accent hover:text-accent transition-colors"
>
Open Settings Panel
</button>
@@ -715,7 +740,7 @@ function AllKeysModal({
<button
type="button"
onClick={onCancel}
className="px-3.5 py-1.5 text-[12px] text-zinc-400 hover:text-zinc-200 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
className="px-3.5 py-1.5 text-[12px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-card border border-line rounded-lg transition-colors"
>
Cancel Deploy
</button>
@@ -723,7 +748,7 @@ function AllKeysModal({
type="button"
onClick={handleAddKeysAndDeploy}
disabled={!allSaved || anySaving}
className="px-3.5 py-1.5 text-[12px] bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors disabled:opacity-40"
className="px-3.5 py-1.5 text-[12px] bg-accent-strong hover:bg-accent text-white rounded-lg transition-colors disabled:opacity-40"
>
{anySaving ? "Saving..." : allSaved ? "Deploy" : "Add Keys"}
</button>
+21 -10
View File
@@ -132,12 +132,14 @@ export function OnboardingWizard() {
<div
role="complementary"
aria-label="Onboarding guide"
className="fixed bottom-20 left-4 z-50 w-80 rounded-2xl border border-zinc-700/60 bg-zinc-900/95 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
className="fixed bottom-20 left-4 z-50 w-80 rounded-2xl border border-line/60 bg-surface-sunken/95 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
>
{/* Progress bar */}
<div className="h-1 bg-zinc-800">
{/* Progress bar — was hardcoded from-blue-500 to-sky-400, neither
tone exists in warm-paper light theme. Switched to the accent
ramp so the gradient reads as brand color in both themes. */}
<div className="h-1 bg-surface-card">
<div
className="h-full bg-gradient-to-r from-blue-500 to-sky-400 transition-all duration-500"
className="h-full bg-gradient-to-r from-accent to-accent-strong transition-all duration-500"
style={{ width: `${((currentStepIdx + 1) / STEPS.length) * 100}%` }}
/>
</div>
@@ -155,24 +157,26 @@ export function OnboardingWizard() {
<div className="p-4">
{/* Step indicator */}
<div className="flex items-center justify-between mb-2">
<span className="text-[9px] font-semibold uppercase tracking-widest text-sky-400/80">
{/* text-sky-400/80 was hardcoded; flip to text-accent so the
indicator stays brand-tinted in both themes. */}
<span className="text-[9px] font-semibold uppercase tracking-widest text-accent">
Step {currentStepIdx + 1} of {STEPS.length}
</span>
<button
type="button"
onClick={dismiss}
aria-label="Skip onboarding guide"
className="text-[10px] text-zinc-400 hover:text-zinc-200 transition-colors"
className="text-[10px] text-ink-mid hover:text-ink transition-colors rounded-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
>
Skip guide
</button>
</div>
{/* Content */}
<h3 className="text-sm font-medium text-zinc-100 mb-1">
<h3 className="text-sm font-medium text-ink mb-1">
{currentStep.title}
</h3>
<p className="text-[11px] text-zinc-400 leading-relaxed mb-3">
<p className="text-[11px] text-ink-mid leading-relaxed mb-3">
{currentStep.description}
</p>
@@ -181,7 +185,11 @@ export function OnboardingWizard() {
<button
type="button"
onClick={handleAction}
className="flex-1 px-3 py-1.5 bg-blue-600/90 hover:bg-blue-500 rounded-lg text-[11px] font-medium text-white transition-colors"
// Was bg-accent-strong/90 hover:bg-accent — accent is the
// LIGHTER variant, so this hovered lighter on white text and
// dropped contrast below AA. Same trap fixed in
// ConfirmDialog/ApprovalBanner. Hover the OTHER direction.
className="flex-1 px-3 py-1.5 bg-accent hover:bg-accent-strong rounded-lg text-[11px] font-medium text-white transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken"
>
{step === "welcome"
? "Create Workspace"
@@ -199,7 +207,10 @@ export function OnboardingWizard() {
if (next) setStep(next.id);
else dismiss();
}}
className="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 rounded-lg text-[11px] text-zinc-400 transition-colors"
// Was hover:bg-surface-card on top of bg-surface-card —
// silent no-op hover. Lift to surface-elevated, matching
// the Cancel pattern in ConfirmDialog.
className="px-3 py-1.5 bg-surface-card hover:bg-surface-elevated hover:text-ink rounded-lg text-[11px] text-ink-mid transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken"
>
Next
</button>
@@ -240,14 +240,14 @@ export function OrgImportPreflightModal({
onClick={onCancel}
>
<div
className="w-[560px] max-h-[80vh] overflow-auto rounded-xl bg-zinc-900 border border-zinc-700 shadow-2xl"
className="w-[560px] max-h-[80vh] overflow-auto rounded-xl bg-surface-sunken border border-line shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<header className="px-5 py-4 border-b border-zinc-800">
<h2 id="org-preflight-title" className="text-sm font-semibold text-zinc-100">
<header className="px-5 py-4 border-b border-line">
<h2 id="org-preflight-title" className="text-sm font-semibold text-ink">
Deploy {orgName}
</h2>
<p className="mt-0.5 text-[11px] text-zinc-500">
<p className="mt-0.5 text-[11px] text-ink-soft">
{workspaceCount} workspace{workspaceCount === 1 ? "" : "s"}.
Review the credentials needed before import.
</p>
@@ -283,23 +283,23 @@ export function OrgImportPreflightModal({
/>
)}
{requiredEnv.length === 0 && recommendedEnv.length === 0 && (
<p className="text-[12px] text-zinc-400">
<p className="text-[12px] text-ink-mid">
No additional credentials required for this template.
</p>
)}
</section>
<footer className="px-5 py-3 border-t border-zinc-800 flex items-center justify-between">
<footer className="px-5 py-3 border-t border-line flex items-center justify-between">
<button
type="button"
onClick={onCancel}
className="px-3 py-1.5 text-[11px] rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-300"
className="px-3 py-1.5 text-[11px] rounded bg-surface-card hover:bg-surface-elevated hover:text-ink text-ink-mid transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
Cancel
</button>
<div className="flex items-center gap-2">
{missingRecommended.length > 0 && canProceed && (
<span className="text-[10px] text-amber-400/90">
<span className="text-[10px] text-warm/90">
{missingRecommended.length} recommended key
{missingRecommended.length === 1 ? "" : "s"} still unset
</span>
@@ -308,7 +308,7 @@ export function OrgImportPreflightModal({
type="button"
onClick={onProceed}
disabled={!canProceed}
className="px-4 py-1.5 text-[11px] font-semibold rounded bg-blue-600 hover:bg-blue-500 text-white disabled:bg-zinc-700 disabled:text-zinc-500 disabled:cursor-not-allowed"
className="px-4 py-1.5 text-[11px] font-semibold rounded bg-accent hover:bg-accent-strong text-white disabled:bg-surface-card disabled:text-white-soft disabled:cursor-not-allowed"
>
Import
</button>
@@ -346,14 +346,14 @@ function EnvList({
? "border-red-800/60 bg-red-950/20"
: "border-amber-800/50 bg-amber-950/15";
const headerColor =
tone === "required" ? "text-red-300" : "text-amber-300";
tone === "required" ? "text-bad" : "text-warm";
return (
<div className={`rounded-lg border ${accent} p-3`}>
<h3 className={`text-[11px] font-semibold uppercase tracking-wide ${headerColor}`}>
{title}
</h3>
<p className="mt-0.5 mb-2 text-[10px] text-zinc-400">{subtitle}</p>
<p className="mt-0.5 mb-2 text-[10px] text-ink-mid">{subtitle}</p>
<ul className="space-y-2">
{entries.map((entry) =>
typeof entry === "string" ? (
@@ -397,16 +397,16 @@ function StrictEnvRow({
onSave,
}: StrictEnvRowProps) {
return (
<li className="flex items-center gap-2 rounded bg-zinc-900/70 border border-zinc-800 px-2 py-1.5">
<li className="flex items-center gap-2 rounded bg-surface-sunken/70 border border-line px-2 py-1.5">
<code
className={`text-[11px] font-mono flex-1 ${
configured ? "text-zinc-500 line-through" : "text-zinc-200"
configured ? "text-ink-soft line-through" : "text-ink"
}`}
>
{envKey}
</code>
{configured ? (
<span className="text-[10px] text-emerald-400"> set</span>
<span className="text-[10px] text-good"> set</span>
) : (
<>
<input
@@ -422,20 +422,20 @@ function StrictEnvRow({
}
}}
disabled={d?.saving}
className="flex-1 px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-[11px] text-zinc-200 focus:outline-none focus:border-blue-500 disabled:opacity-50"
className="flex-1 px-2 py-1 rounded bg-surface-card border border-line text-[11px] text-ink focus:outline-none focus:border-accent disabled:opacity-50"
/>
<button
type="button"
onClick={() => onSave(envKey)}
disabled={d?.saving || !d?.value.trim()}
className="px-2 py-1 text-[10px] rounded bg-blue-600 hover:bg-blue-500 text-white disabled:opacity-40 disabled:cursor-not-allowed"
className="px-2 py-1 text-[10px] rounded bg-accent hover:bg-accent-strong text-white disabled:opacity-40 disabled:cursor-not-allowed"
>
{d?.saving ? "…" : "Save"}
</button>
</>
)}
{d?.error && (
<span className="text-[9px] text-red-400 basis-full pl-1">
<span className="text-[9px] text-bad basis-full pl-1">
{d.error}
</span>
)}
@@ -467,13 +467,13 @@ function AnyOfEnvGroup({
}: AnyOfEnvGroupProps) {
const satisfiedBy = members.find((m) => configuredKeys.has(m));
return (
<li className="rounded border border-zinc-800 bg-zinc-900/50 px-2.5 py-2">
<li className="rounded border border-line bg-surface-sunken/50 px-2.5 py-2">
<div className="flex items-center justify-between mb-1.5">
<span className="text-[10px] uppercase tracking-wide text-zinc-400">
<span className="text-[10px] uppercase tracking-wide text-ink-mid">
Configure any one
</span>
{satisfiedBy && (
<span className="text-[10px] text-emerald-400">
<span className="text-[10px] text-good">
using <code className="font-mono">{satisfiedBy}</code>
</span>
)}
@@ -486,19 +486,19 @@ function AnyOfEnvGroup({
return (
<li
key={m}
className={`flex items-center gap-2 rounded bg-zinc-900/70 border border-zinc-800 px-2 py-1 ${
className={`flex items-center gap-2 rounded bg-surface-sunken/70 border border-line px-2 py-1 ${
dimmed ? "opacity-50" : ""
}`}
>
<code
className={`text-[11px] font-mono flex-1 ${
isConfigured ? "text-zinc-500 line-through" : "text-zinc-200"
isConfigured ? "text-ink-soft line-through" : "text-ink"
}`}
>
{m}
</code>
{isConfigured ? (
<span className="text-[10px] text-emerald-400"> set</span>
<span className="text-[10px] text-good"> set</span>
) : (
<>
<input
@@ -514,20 +514,20 @@ function AnyOfEnvGroup({
}
}}
disabled={d?.saving}
className="flex-1 px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-[11px] text-zinc-200 focus:outline-none focus:border-blue-500 disabled:opacity-50"
className="flex-1 px-2 py-1 rounded bg-surface-card border border-line text-[11px] text-ink focus:outline-none focus:border-accent disabled:opacity-50"
/>
<button
type="button"
onClick={() => onSave(m)}
disabled={d?.saving || !d?.value.trim()}
className="px-2 py-1 text-[10px] rounded bg-blue-600 hover:bg-blue-500 text-white disabled:opacity-40 disabled:cursor-not-allowed"
className="px-2 py-1 text-[10px] rounded bg-accent hover:bg-accent-strong text-white disabled:opacity-40 disabled:cursor-not-allowed"
>
{d?.saving ? "…" : "Save"}
</button>
</>
)}
{d?.error && (
<span className="text-[9px] text-red-400 basis-full pl-1">
<span className="text-[9px] text-bad basis-full pl-1">
{d.error}
</span>
)}
+11 -11
View File
@@ -97,27 +97,27 @@ function PlanCard({
onSelect: () => void;
}) {
const ring = plan.highlighted
? "border-blue-600 ring-2 ring-blue-600/30"
: "border-zinc-800";
? "border-accent ring-2 ring-blue-600/30"
: "border-line";
return (
<article
className={`flex flex-col rounded-lg border ${ring} bg-zinc-900/40 p-6`}
className={`flex flex-col rounded-lg border ${ring} bg-surface-sunken/40 p-6`}
aria-labelledby={`plan-${plan.id}-name`}
>
{plan.highlighted && (
<span className="mb-3 inline-block rounded-full bg-blue-600/20 px-3 py-1 text-xs font-medium text-blue-300">
<span className="mb-3 inline-block rounded-full bg-accent-strong/20 px-3 py-1 text-xs font-medium text-accent">
Most popular
</span>
)}
<h2 id={`plan-${plan.id}-name`} className="text-xl font-semibold text-white">
<h2 id={`plan-${plan.id}-name`} className="text-xl font-semibold text-ink">
{plan.name}
</h2>
<p className="mt-1 text-sm text-zinc-400">{plan.tagline}</p>
<p className="mt-4 text-3xl font-bold text-white">{plan.price}</p>
<ul className="mt-6 flex-1 space-y-2 text-sm text-zinc-300">
<p className="mt-1 text-sm text-ink-mid">{plan.tagline}</p>
<p className="mt-4 text-3xl font-bold text-ink">{plan.price}</p>
<ul className="mt-6 flex-1 space-y-2 text-sm text-ink-mid">
{plan.features.map((f) => (
<li key={f} className="flex items-start">
<span className="mr-2 text-blue-400" aria-hidden>
<span className="mr-2 text-accent" aria-hidden>
</span>
{f}
@@ -130,8 +130,8 @@ function PlanCard({
disabled={loading}
className={`mt-6 rounded-lg px-4 py-3 text-sm font-medium ${
plan.highlighted
? "bg-blue-600 text-white hover:bg-blue-500 disabled:bg-blue-900"
: "border border-zinc-700 bg-zinc-900 text-zinc-100 hover:bg-zinc-800 disabled:opacity-50"
? "bg-accent-strong text-white hover:bg-accent disabled:bg-blue-900"
: "border border-line bg-surface-sunken text-ink hover:bg-surface-card disabled:opacity-50"
}`}
>
{loading ? "Opening checkout…" : plan.ctaLabel}
@@ -0,0 +1,523 @@
"use client";
/**
* ProviderModelSelector — single source of truth for the provider→model
* dropdown chain shared across:
* 1. MissingKeysModal (template deploy / first-time onboarding modal)
* 2. ConfigTab (per-workspace settings — Runtime section)
* 3. TemplatePalette (template side panel — inherits via MissingKeysModal)
*
* The user picks Provider FIRST (Anthropic API, Claude Code subscription,
* MiniMax, Z.ai GLM, ...). The model dropdown then filters to only that
* provider's models. Wildcard providers (huggingface/*, openrouter/*,
* custom/*) reveal a free-text model input with a tooltip explaining the
* wildcard.
*
* Provider taxonomy:
* - Multiple models can share the same `required_env` (e.g. all
* ANTHROPIC_AUTH_TOKEN-routed third-party providers — MiniMax, GLM,
* Kimi, DeepSeek). Grouping ONLY by env-tuple collapses them all into
* one bucket. We split further by vendor inferred from the model id
* so the user sees "MiniMax" and "Z.ai (GLM)" as separate options.
* - Vendor is inferred via prefix rules below. Templates that ship
* explicit vendor metadata (future) should override the heuristic.
*/
import { useId, useMemo } from "react";
export interface SelectorModel {
id: string;
name?: string;
required_env?: string[];
}
/** A provider option in the dropdown — one row corresponds to one
* vendor + env-tuple combo, holding the models that map to it. */
export interface ProviderEntry {
/** Stable id used as the <option value>. `${vendor}|${sortedEnv}`. */
id: string;
/** Inferred vendor key (e.g. "minimax", "anthropic-oauth"). */
vendor: string;
/** Human label shown in the dropdown. */
label: string;
/** Env vars required by every model in this provider. */
envVars: string[];
/** Models bucketed under this provider. */
models: SelectorModel[];
/** True when ANY model id contains "*" — UI shows free-text model input. */
wildcard: boolean;
/** Optional tooltip text (rendered as native title=). */
tooltip?: string;
}
export interface SelectorValue {
/** ProviderEntry.id of the selected provider. Empty string = nothing
* picked yet (parent should treat as invalid for save). */
providerId: string;
/** Selected model slug. For wildcard providers this is whatever the
* user typed in the free-text input. */
model: string;
/** Snapshot of envVars from the selected provider. Re-emitted on every
* change so consumers can re-render credential fields without
* re-inferring from the model. */
envVars: string[];
}
interface Props {
models: SelectorModel[];
value: SelectorValue;
onChange: (next: SelectorValue) => void;
/** Display variant. "grid" = label+control side-by-side (used in ConfigTab
* Runtime section). "stack" = vertical (used in MissingKeysModal). */
variant?: "grid" | "stack";
/** When true, parent caller is opting in to power-user free-text. Adds a
* "Custom (type model id)..." escape-hatch entry as a model option even
* when the chosen provider isn't wildcard. ConfigTab uses this; the
* deploy modal does not. */
allowCustomModelEscape?: boolean;
disabled?: boolean;
/** Optional id-prefix for label↔control wiring (WCAG 1.3.1). Default
* uses useId(). */
idPrefix?: string;
}
// -----------------------------------------------------------------------------
// Vendor detection — id-prefix heuristic + bare-name patterns.
// -----------------------------------------------------------------------------
/** Vendor keys → human label. Add new vendors here when templates pick
* up new model families. */
const VENDOR_LABELS: Record<string, string> = {
"anthropic-oauth": "Claude Code subscription",
anthropic: "Anthropic API",
minimax: "MiniMax",
zai: "Z.ai (GLM)",
moonshot: "Moonshot (Kimi)",
deepseek: "DeepSeek",
"xiaomi-mimo": "Xiaomi MiMo",
openai: "OpenAI",
google: "Google Gemini",
alibaba: "Alibaba Qwen (DashScope)",
nousresearch: "Nous Research (Hermes)",
openrouter: "OpenRouter (any model)",
huggingface: "Hugging Face Inference",
"ai-gateway": "Vercel AI Gateway",
"opencode-zen": "OpenCode Zen",
"opencode-go": "OpenCode Go",
kilocode: "Kilo Code",
"kimi-coding": "Moonshot Kimi (coding-tuned)",
"minimax-cn": "MiniMax China",
"ollama-cloud": "Ollama Cloud",
ollama: "Ollama (self-hosted)",
nvidia: "NVIDIA NIM",
arcee: "Arcee",
xiaomi: "Xiaomi MiMo",
gemini: "Google Gemini",
custom: "Custom OpenAI-compat endpoint",
};
/** Optional per-vendor tooltip shown on hover. */
const VENDOR_TOOLTIPS: Record<string, string> = {
"anthropic-oauth":
"Use your Claude.ai (Pro/Max/Team) subscription via OAuth. Run `claude login` in the workspace terminal to mint the token, then paste it here. No API spend.",
anthropic:
"Pay-per-token via the Anthropic API (Console). Provide an API key starting with sk-ant-…",
minimax:
"MiniMax models served through their Anthropic-API-compatible endpoint. Get a key at platform.minimax.io.",
zai:
"Zhipu AI / z.ai GLM models through the Anthropic-compatible gateway. Get a key at docs.z.ai.",
moonshot:
"Moonshot Kimi K2-series via Anthropic-API-compatible endpoint. Get a key at platform.kimi.ai.",
deepseek:
"DeepSeek V4 via Anthropic-API-compatible endpoint. Get a key at api-docs.deepseek.com.",
openrouter:
"OpenRouter routes to 200+ models behind one API. Use any openrouter/<model> id. Get a key at openrouter.ai.",
huggingface:
"Any model hosted on Hugging Face Inference. Type the full model id (e.g. mistralai/Mistral-7B-Instruct-v0.3).",
custom:
"Self-hosted OpenAI-compatible endpoint (LM Studio, Ollama local, vLLM, llama.cpp). Configure base_url in the workspace's runtime config. No API key required.",
};
/** Sentinel value used in the model <select> for the free-text escape hatch
* added by `allowCustomModelEscape`. The component swaps to a text input
* when this is selected. */
const CUSTOM_MODEL_SENTINEL = "__custom__";
/** Bare-id vendor patterns (no slash separator). Order matters — first
* match wins. */
const BARE_VENDOR_PATTERNS: Array<{ test: (id: string) => boolean; vendor: string }> = [
{ test: (id) => /^minimax-/i.test(id) || /^MiniMax-/.test(id), vendor: "minimax" },
{ test: (id) => /^GLM-/i.test(id), vendor: "zai" },
{ test: (id) => /^kimi-/i.test(id), vendor: "moonshot" },
{ test: (id) => /^deepseek-/i.test(id), vendor: "deepseek" },
{ test: (id) => /^mimo-/i.test(id), vendor: "xiaomi-mimo" },
{ test: (id) => /^claude-/i.test(id), vendor: "anthropic" },
{ test: (id) => /^gpt-/i.test(id), vendor: "openai" },
{ test: (id) => /^gemini-/i.test(id), vendor: "google" },
{ test: (id) => /^qwen-/i.test(id), vendor: "alibaba" },
// Claude-Code OAuth aliases — bare "sonnet"/"opus"/"haiku" + CLAUDE_CODE_OAUTH_TOKEN
// is the strongest signal that this is a subscription model. We also
// gate on env in inferVendor() below to avoid mis-tagging non-OAuth
// models that happen to be named "sonnet".
{ test: (id) => /^(sonnet|opus|haiku)$/i.test(id), vendor: "anthropic-oauth" },
];
/** Infer a vendor key from a model spec. Combines id-prefix and env
* signals. Exported for tests. */
export function inferVendor(model: SelectorModel): string {
const id = model.id || "";
const envSet = new Set(model.required_env ?? []);
// 1. Explicit slash-separated prefix wins (e.g. nousresearch/hermes-4-70b).
const slashIdx = id.indexOf("/");
if (slashIdx > 0) {
return id.slice(0, slashIdx).toLowerCase();
}
// 2. Bare-id pattern. Special-case the OAuth aliases — they only count
// when the env actually demands the OAuth token. Otherwise (e.g.
// a hypothetical "sonnet" alias against ANTHROPIC_API_KEY) fall
// through and let the env-based fallback bucket it under
// "anthropic".
for (const p of BARE_VENDOR_PATTERNS) {
if (!p.test(id)) continue;
if (p.vendor === "anthropic-oauth" && !envSet.has("CLAUDE_CODE_OAUTH_TOKEN")) {
continue;
}
return p.vendor;
}
// 3. Env-tuple fallback. Pick the first env's "namespace" as the
// vendor — e.g. OPENROUTER_API_KEY → "openrouter".
const env = model.required_env?.[0];
if (env) {
const ns = env.replace(/_API_KEY$|_TOKEN$|_KEY$/i, "").toLowerCase();
return ns || "unknown";
}
return "unknown";
}
/** Build the provider catalog from the template's models[]. Models are
* bucketed by `(vendor, sortedEnv)` so two distinct env-tuples for the
* same vendor (rare but possible) become two separate entries. */
export function buildProviderCatalog(models: SelectorModel[]): ProviderEntry[] {
const buckets = new Map<string, ProviderEntry>();
for (const m of models) {
const envs = m.required_env ?? [];
const sortedEnv = [...envs].sort().join("|");
const vendor = inferVendor(m);
const id = `${vendor}|${sortedEnv}`;
const wildcard = m.id.includes("*");
let entry = buckets.get(id);
if (!entry) {
const baseLabel = VENDOR_LABELS[vendor] ?? vendor;
entry = {
id,
vendor,
label: baseLabel,
envVars: envs,
models: [],
wildcard,
tooltip: VENDOR_TOOLTIPS[vendor],
};
buckets.set(id, entry);
}
entry.models.push(m);
// Wildcard sticks if any model in the bucket is a wildcard — same
// bucket can't mix wildcard and concrete because they'd typically
// share required_env but rarely the same vendor. Defensive OR.
entry.wildcard = entry.wildcard || wildcard;
}
// Decorate label with model-count when ≥2 concrete models share the
// bucket. Helps the user understand "Anthropic API (5 models)" vs
// "MiniMax (3 models)".
for (const e of buckets.values()) {
if (!e.wildcard && e.models.length > 1) {
e.label = `${e.label} (${e.models.length} models)`;
}
}
return Array.from(buckets.values());
}
/** Find the provider entry that contains a given model id. Used by
* callers to back-derive the provider when only the model is known
* (e.g. ConfigTab loading from saved state). */
export function findProviderForModel(
catalog: ProviderEntry[],
modelId: string,
): ProviderEntry | null {
if (!modelId) return null;
for (const p of catalog) {
if (p.models.some((m) => m.id === modelId)) return p;
// Wildcard match — entry has model id ending in "*" and the typed
// id starts with the wildcard's prefix (e.g. "openrouter/anthropic/
// claude-3.5-sonnet" matches the "openrouter/*" bucket).
if (p.wildcard) {
for (const m of p.models) {
if (!m.id.endsWith("*")) continue;
const prefix = m.id.slice(0, -1);
if (modelId.startsWith(prefix)) return p;
}
}
}
return null;
}
// -----------------------------------------------------------------------------
// Component
// -----------------------------------------------------------------------------
export function ProviderModelSelector({
models,
value,
onChange,
variant = "stack",
allowCustomModelEscape = false,
disabled = false,
idPrefix,
}: Props) {
const generatedId = useId();
const baseId = idPrefix ?? generatedId;
const providerSelectId = `${baseId}-provider`;
const modelSelectId = `${baseId}-model`;
const catalog = useMemo(() => buildProviderCatalog(models), [models]);
const selected = useMemo(
() => catalog.find((p) => p.id === value.providerId) ?? null,
[catalog, value.providerId],
);
// True when the user picked the "Custom (type model id)..." escape entry
// in the model dropdown — switches to free-text. Wildcard providers
// ALWAYS use free-text, so this flag is for the escape hatch on
// non-wildcard providers.
const userPickedCustom = value.model === CUSTOM_MODEL_SENTINEL || (
!!selected &&
!selected.wildcard &&
!!value.model &&
!selected.models.some((m) => m.id === value.model)
);
const useTextInput = (selected?.wildcard ?? false) || userPickedCustom;
const handleProviderChange = (nextProviderId: string) => {
const next = catalog.find((p) => p.id === nextProviderId) ?? null;
if (!next) {
onChange({ providerId: "", model: "", envVars: [] });
return;
}
// When switching providers:
// - wildcard provider → empty (free-text input takes over)
// - exactly 1 concrete model → auto-pick (no choice to make)
// - 2+ concrete models → leave empty so the operator MUST pick
//
// Background: previously this defaulted to `next.models[0]` for any
// non-wildcard provider, which silently set the alphabetically-first
// model in the bucket. Bit a real user on 2026-05-03 — they picked
// the MiniMax provider intending `MiniMax-M2.7` but the form silently
// set `MiniMax-M2` (first in the list). They never saw the model
// dropdown change because the provider+model widgets are visually
// distinct, and the workspace deployed with the wrong model. Caller
// already disables Deploy/Save while `model.trim() === ""`, so the
// empty default forces an explicit pick without loosening any other
// gate.
const defaultModel = next.wildcard
? ""
: next.models.length === 1
? next.models[0]?.id ?? ""
: "";
onChange({
providerId: next.id,
model: defaultModel,
envVars: next.envVars,
});
};
const handleModelChange = (nextModel: string) => {
if (!selected) {
onChange({ ...value, model: nextModel });
return;
}
onChange({
providerId: selected.id,
model: nextModel,
envVars: selected.envVars,
});
};
const containerClass = variant === "grid" ? "grid grid-cols-2 gap-3" : "space-y-3";
return (
<div className={containerClass} data-testid="provider-model-selector">
<div>
<label
htmlFor={providerSelectId}
className="text-[10px] uppercase tracking-wide text-ink-soft font-semibold mb-1.5 block"
>
Provider <span aria-hidden="true" className="text-bad">*</span>
<span className="sr-only"> (required)</span>
</label>
<select
id={providerSelectId}
value={value.providerId}
onChange={(e) => handleProviderChange(e.target.value)}
disabled={disabled || catalog.length === 0}
aria-describedby={selected?.tooltip ? `${providerSelectId}-help` : undefined}
data-testid="provider-select"
className="w-full bg-surface-sunken border border-line rounded px-2 py-1.5 text-[11px] text-ink focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors disabled:opacity-50"
>
<option value="" disabled>
select provider
</option>
{catalog.map((p) => (
<option key={p.id} value={p.id} title={p.tooltip}>
{p.label}
</option>
))}
</select>
{selected?.tooltip && (
<p
id={`${providerSelectId}-help`}
className="text-[9px] text-ink-soft mt-1 leading-relaxed"
>
{selected.tooltip}
</p>
)}
{selected && selected.envVars.length > 0 && (
<p className="text-[9px] text-ink-soft mt-0.5 font-mono">
requires: {selected.envVars.join(", ")}
</p>
)}
</div>
<div>
<label
htmlFor={modelSelectId}
className="text-[10px] uppercase tracking-wide text-ink-soft font-semibold mb-1.5 block"
>
Model <span aria-hidden="true" className="text-bad">*</span>
<span className="sr-only"> (required)</span>
</label>
{useTextInput ? (
<>
<input
id={modelSelectId}
type="text"
value={
value.model === CUSTOM_MODEL_SENTINEL ? "" : value.model
}
onChange={(e) => handleModelChange(e.target.value.trim())}
placeholder={
selected?.wildcard
? wildcardPlaceholder(selected)
: "type any model id"
}
disabled={disabled || !selected}
spellCheck={false}
autoComplete="off"
data-testid="model-input"
className="w-full bg-surface-sunken border border-line rounded px-2 py-1.5 text-[11px] text-ink font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors disabled:opacity-50"
/>
<p className="text-[9px] text-ink-soft mt-1 leading-relaxed">
{selected?.wildcard
? wildcardHelpText(selected)
: "Free-text model id. Make sure the provider can resolve it."}
</p>
{!selected?.wildcard && (
<button
type="button"
onClick={() => {
// Switch back to dropdown by setting model to first
// concrete option.
if (selected) {
handleModelChange(selected.models[0]?.id ?? "");
}
}}
className="text-[9px] text-accent hover:text-accent mt-0.5"
>
back to model list
</button>
)}
</>
) : (
<select
id={modelSelectId}
value={
value.model && selected?.models.some((m) => m.id === value.model)
? value.model
: ""
}
onChange={(e) => {
if (e.target.value === CUSTOM_MODEL_SENTINEL) {
handleModelChange(CUSTOM_MODEL_SENTINEL);
} else {
handleModelChange(e.target.value);
}
}}
disabled={disabled || !selected || selected.models.length === 0}
data-testid="model-select"
className="w-full bg-surface-sunken border border-line rounded px-2 py-1.5 text-[11px] text-ink font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors disabled:opacity-50"
>
<option value="" disabled>
{selected ? "— select model —" : "— select provider first —"}
</option>
{selected?.models
.filter((m) => !m.id.includes("*"))
.map((m) => (
<option
key={m.id}
value={m.id}
title={m.name ?? m.id}
>
{m.name ?? m.id}
</option>
))}
{allowCustomModelEscape && selected && (
<option value={CUSTOM_MODEL_SENTINEL}>
Custom (type model id)
</option>
)}
</select>
)}
</div>
</div>
);
}
function wildcardPlaceholder(p: ProviderEntry): string {
const example = p.models.find((m) => m.id.includes("*"))?.id ?? "";
if (!example) return "type any model id";
// Strip trailing star — show the pattern as a hint.
const prefix = example.replace(/\*$/, "");
switch (p.vendor) {
case "huggingface":
return `e.g. ${prefix}meta-llama/Meta-Llama-3-70B-Instruct`;
case "openrouter":
return `e.g. ${prefix}anthropic/claude-3.5-sonnet`;
case "custom":
return `e.g. ${prefix}my-local-model`;
default:
return `e.g. ${prefix}<model-id>`;
}
}
function wildcardHelpText(p: ProviderEntry): string {
switch (p.vendor) {
case "huggingface":
return "Any model hosted on Hugging Face Inference. Browse at huggingface.co/models?inference=warm.";
case "openrouter":
return "Any of OpenRouter's 200+ routed models. Browse at openrouter.ai/models.";
case "custom":
return "Self-hosted endpoint. Configure base_url in your workspace's runtime config (no API key required).";
case "ai-gateway":
return "Vercel AI Gateway model id. See vercel.com/docs/ai-gateway.";
case "opencode-zen":
return "OpenCode Zen model id. See opencode.zen.";
default:
return "Wildcard provider — type the model id in full. Provider routes by id prefix.";
}
}
@@ -321,17 +321,17 @@ export function ProvisioningTimeout({
onClick={() => handleDismiss(entry.workspaceId)}
aria-label="Dismiss provisioning timeout warning"
title="Dismiss — keep this workspace running without the warning"
className="shrink-0 text-amber-400/60 hover:text-amber-200 transition-colors -mr-1"
className="shrink-0 text-warm/60 hover:text-amber-200 transition-colors -mr-1"
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
</button>
</div>
<div className="text-[11px] text-amber-300/80 leading-relaxed">
<div className="text-[11px] text-warm/80 leading-relaxed">
<span className="font-medium text-amber-200">{entry.workspaceName}</span>{" "}
has been provisioning for{" "}
<span className="font-mono text-amber-300">{formatDuration(elapsed)}</span>.
<span className="font-mono text-warm">{formatDuration(elapsed)}</span>.
It may have encountered an issue.
</div>
@@ -349,14 +349,14 @@ export function ProvisioningTimeout({
type="button"
onClick={() => handleCancelRequest(entry.workspaceId)}
disabled={isRetrying || isCancelling}
className="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-[11px] text-zinc-300 rounded-lg border border-zinc-600 disabled:opacity-40 transition-colors"
className="px-3 py-1.5 bg-surface-card hover:bg-surface-card text-[11px] text-ink-mid rounded-lg border border-line disabled:opacity-40 transition-colors"
>
{isCancelling ? "Cancelling..." : "Cancel"}
</button>
<button
type="button"
onClick={() => handleViewLogs(entry.workspaceId)}
className="px-3 py-1.5 text-[11px] text-amber-400 hover:text-amber-300 transition-colors"
className="px-3 py-1.5 text-[11px] text-warm hover:text-warm transition-colors"
>
View Logs
</button>
@@ -371,18 +371,18 @@ export function ProvisioningTimeout({
{confirmingCancel && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div aria-hidden="true" className="absolute inset-0 bg-black/60" onClick={() => setConfirmingCancel(null)} />
<div className="relative bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl p-5 max-w-[340px] w-full mx-4">
<h3 className="text-sm font-semibold text-zinc-100 mb-2">
<div className="relative bg-surface-sunken border border-line rounded-xl shadow-2xl p-5 max-w-[340px] w-full mx-4">
<h3 className="text-sm font-semibold text-ink mb-2">
Cancel deployment?
</h3>
<p className="text-[12px] text-zinc-400 mb-4 leading-relaxed">
<p className="text-[12px] text-ink-mid mb-4 leading-relaxed">
This will permanently remove the workspace. This action cannot be undone.
</p>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setConfirmingCancel(null)}
className="px-3.5 py-1.5 text-[12px] text-zinc-400 hover:text-zinc-200 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
className="px-3.5 py-1.5 text-[12px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-card border border-line rounded-lg transition-colors"
>
Keep
</button>
+26 -19
View File
@@ -36,11 +36,6 @@ export function SearchDialog() {
}
}, [open]);
// Reset focused index when query changes
useEffect(() => {
setFocusedIndex(-1);
}, [query]);
const filtered = nodes.filter((n) => {
if (!query) return true;
const q = query.toLowerCase();
@@ -51,6 +46,18 @@ export function SearchDialog() {
);
});
// Auto-highlight the first match while the user is typing, so Enter
// selects something instead of being a no-op. With an empty query we
// keep -1 so opening the dialog (which shows ALL workspaces) doesn't
// visually pin one row arbitrarily — only commit a highlight once the
// user has narrowed the list.
useEffect(() => {
setFocusedIndex(query && filtered.length > 0 ? 0 : -1);
// Re-running on filtered.length keeps the highlight pinned to the
// first row while the result set shrinks/grows; the effect handler
// above already short-circuits to -1 when results disappear.
}, [query, filtered.length]);
const handleSelect = useCallback(
(nodeId: string) => {
selectNode(nodeId);
@@ -92,12 +99,12 @@ export function SearchDialog() {
role="dialog"
aria-modal="true"
aria-label="Search workspaces"
className="w-[420px] bg-zinc-950/95 backdrop-blur-xl border border-zinc-800/60 rounded-2xl shadow-2xl shadow-black/50 overflow-hidden"
className="w-[420px] bg-surface/95 backdrop-blur-xl border border-line/60 rounded-2xl shadow-2xl shadow-black/50 overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Search input */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-zinc-800/40">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="shrink-0 text-zinc-500" aria-hidden="true">
<div className="flex items-center gap-3 px-4 py-3 border-b border-line/40">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="shrink-0 text-ink-soft" aria-hidden="true">
<circle cx="7" cy="7" r="5.5" stroke="currentColor" strokeWidth="1.5" />
<path d="M11 11l3.5 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
@@ -113,9 +120,9 @@ export function SearchDialog() {
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleInputKeyDown}
placeholder="Search workspaces..."
className="flex-1 bg-transparent text-sm text-zinc-100 placeholder-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus:outline-none rounded"
className="flex-1 bg-transparent text-sm text-ink placeholder-ink-soft focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent rounded"
/>
<kbd className="text-[9px] text-zinc-400 bg-zinc-800/60 px-1.5 py-0.5 rounded border border-zinc-700/40">ESC</kbd>
<kbd className="text-[9px] text-ink-mid bg-surface-card/60 px-1.5 py-0.5 rounded border border-line/40">ESC</kbd>
</div>
{/* Results */}
@@ -126,7 +133,7 @@ export function SearchDialog() {
className="max-h-[300px] overflow-y-auto py-1"
>
{filtered.length === 0 ? (
<div role="status" aria-live="polite" className="px-4 py-6 text-center text-xs text-zinc-400">
<div role="status" aria-live="polite" className="px-4 py-6 text-center text-xs text-ink-mid">
{query ? "No workspaces match" : "No workspaces yet"}
</div>
) : (
@@ -139,7 +146,7 @@ export function SearchDialog() {
aria-selected={index === focusedIndex}
onClick={() => handleSelect(node.id)}
className={`w-full px-4 py-2.5 flex items-center gap-3 text-left transition-colors ${
index === focusedIndex ? "bg-zinc-800/60" : "hover:bg-zinc-800/40"
index === focusedIndex ? "bg-surface-card/60" : "hover:bg-surface-card/40"
}`}
>
<div
@@ -147,13 +154,13 @@ export function SearchDialog() {
className={`w-2 h-2 rounded-full shrink-0 ${statusDotClass(node.data.status)}`}
/>
<div className="min-w-0 flex-1">
<div className="text-sm text-zinc-200 truncate">{node.data.name}</div>
<div className="text-sm text-ink truncate">{node.data.name}</div>
{node.data.role && (
<div className="text-[10px] text-zinc-500 truncate">{node.data.role}</div>
<div className="text-[10px] text-ink-soft truncate">{node.data.role}</div>
)}
</div>
<span
className="text-[9px] font-mono text-zinc-400"
className="text-[9px] font-mono text-ink-mid"
aria-label={`Tier ${node.data.tier}`}
>
T{node.data.tier}
@@ -164,11 +171,11 @@ export function SearchDialog() {
</div>
{/* Footer */}
<div className="px-4 py-2 border-t border-zinc-800/40 flex items-center justify-between">
<span className="text-[9px] text-zinc-400">{filtered.length} workspace{filtered.length !== 1 ? "s" : ""}</span>
<div className="px-4 py-2 border-t border-line/40 flex items-center justify-between">
<span className="text-[9px] text-ink-mid">{filtered.length} workspace{filtered.length !== 1 ? "s" : ""}</span>
<div className="flex gap-2">
<kbd className="text-[9px] text-zinc-400 bg-zinc-800/60 px-1.5 py-0.5 rounded border border-zinc-700/40"> navigate</kbd>
<kbd className="text-[9px] text-zinc-400 bg-zinc-800/60 px-1.5 py-0.5 rounded border border-zinc-700/40"> select</kbd>
<kbd className="text-[9px] text-ink-mid bg-surface-card/60 px-1.5 py-0.5 rounded border border-line/40"> navigate</kbd>
<kbd className="text-[9px] text-ink-mid bg-surface-card/60 px-1.5 py-0.5 rounded border border-line/40"> select</kbd>
</div>
</div>
</div>
+20 -20
View File
@@ -137,7 +137,7 @@ export function SidePanel() {
return (
<div
className="fixed top-0 right-0 h-full bg-zinc-950/95 backdrop-blur-xl border-l border-zinc-800/50 flex flex-col z-50 shadow-2xl shadow-black/50 animate-in slide-in-from-right duration-200"
className="fixed top-0 right-0 h-full bg-surface/95 backdrop-blur-xl border-l border-line/50 flex flex-col z-50 shadow-2xl shadow-black/50 animate-in slide-in-from-right duration-200"
style={{ width }}
>
{/* Resize handle */}
@@ -151,26 +151,26 @@ export function SidePanel() {
tabIndex={0}
onMouseDown={onMouseDown}
onKeyDown={onResizeKeyDown}
className="absolute left-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-blue-500/30 active:bg-blue-500/50 transition-colors z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-inset"
className="absolute left-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-accent/30 active:bg-accent/50 transition-colors z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-inset"
/>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-zinc-800/40 bg-zinc-900/30">
<div className="flex items-center justify-between px-5 py-4 border-b border-line/40 bg-surface-sunken/30">
<div className="flex items-center gap-3 min-w-0">
<div className="relative">
<StatusDot status={node.data.status} size="md" />
</div>
<div className="min-w-0">
<h2 className="text-[14px] font-semibold text-zinc-100 truncate leading-tight">
<h2 className="text-[14px] font-semibold text-ink truncate leading-tight">
{node.data.name}
</h2>
<div className="flex items-center gap-2 mt-0.5">
{node.data.role && (
<span className="text-[10px] text-zinc-500 truncate">
<span className="text-[10px] text-ink-soft truncate">
{node.data.role}
</span>
)}
<span className={`text-[9px] px-1.5 py-0.5 rounded-md font-mono ${
isOnline ? "text-emerald-400 bg-emerald-950/30" : "text-zinc-500 bg-zinc-800/50"
isOnline ? "text-good bg-emerald-950/30" : "text-ink-soft bg-surface-card/50"
}`}>
T{node.data.tier}
</span>
@@ -181,7 +181,7 @@ export function SidePanel() {
type="button"
onClick={() => selectNode(null)}
aria-label="Close workspace panel"
className="w-7 h-7 flex items-center justify-center rounded-lg text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800/60 transition-colors"
className="w-7 h-7 flex items-center justify-center rounded-lg text-ink-soft hover:text-ink hover:bg-surface-card/60 transition-colors"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M1 1l10 10M11 1L1 11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
@@ -190,7 +190,7 @@ export function SidePanel() {
</div>
{/* Capability summary */}
<div className="px-5 py-3 border-b border-zinc-800/40 bg-zinc-900/20">
<div className="px-5 py-3 border-b border-line/40 bg-surface-sunken/20">
<div className="flex flex-wrap gap-2">
<MetaPill label="Tier" value={`T${node.data.tier}`} />
<MetaPill label="Runtime" value={capability.runtime || "unknown"} />
@@ -200,13 +200,13 @@ export function SidePanel() {
</div>
{/* Tabs — relative wrapper lets the fade gradient position against the scroll container */}
<div className="relative border-b border-zinc-800/40">
<div className="relative border-b border-line/40">
{/* Right-edge fade: signals more tabs are hidden off-screen when the bar overflows */}
<div className="pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-zinc-950 to-transparent z-10" aria-hidden="true" />
<div className="pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-surface to-transparent z-10" aria-hidden="true" />
<div
role="tablist"
aria-label="Workspace panel tabs"
className="flex overflow-x-auto bg-zinc-900/20 px-1"
className="flex overflow-x-auto bg-surface-sunken/20 px-1"
onKeyDown={(e) => {
const idx = TABS.findIndex((t) => t.id === panelTab);
let next: number | null = null;
@@ -230,10 +230,10 @@ export function SidePanel() {
aria-controls={`panel-${tab.id}`}
tabIndex={panelTab === tab.id ? 0 : -1}
onClick={() => setPanelTab(tab.id)}
className={`shrink-0 px-3 py-2.5 text-[10px] font-medium tracking-wide transition-all rounded-t-lg mx-0.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70 ${
className={`shrink-0 px-3 py-2.5 text-[10px] font-medium tracking-wide transition-all rounded-t-lg mx-0.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/70 ${
panelTab === tab.id
? "text-zinc-100 bg-zinc-800/40 border-b-2 border-blue-500"
: "text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800/40"
? "text-ink bg-surface-card border-b-2 border-accent"
: "text-ink-mid hover:text-ink hover:bg-surface-card/60"
}`}
>
<span className="mr-1 opacity-50" aria-hidden="true">{tab.icon}</span>
@@ -264,7 +264,7 @@ export function SidePanel() {
<Tooltip text={node.data.currentTask as string}>
<div className="px-4 py-2 bg-amber-950/20 border-b border-amber-800/20 flex items-center gap-2 cursor-default">
<div className="w-1.5 h-1.5 rounded-full bg-amber-400 motion-safe:animate-pulse shrink-0" />
<span className="text-[10px] text-amber-300/90 truncate">
<span className="text-[10px] text-warm/90 truncate">
{node.data.currentTask}
</span>
</div>
@@ -295,8 +295,8 @@ export function SidePanel() {
</div>
{/* Footer — workspace ID */}
<div className="px-5 py-2 border-t border-zinc-800/40 bg-zinc-900/20">
<span className="text-[9px] font-mono text-zinc-500 select-all">
<div className="px-5 py-2 border-t border-line/40 bg-surface-sunken/20">
<span className="text-[9px] font-mono text-ink-soft select-all">
{selectedNodeId}
</span>
</div>
@@ -306,9 +306,9 @@ export function SidePanel() {
function MetaPill({ label, value, tone = "zinc" }: { label: string; value: string; tone?: "zinc" | "emerald" | "amber" }) {
const toneClasses = {
zinc: "border-zinc-700/50 bg-zinc-900/70 text-zinc-400",
emerald: "border-emerald-500/20 bg-emerald-950/20 text-emerald-300",
amber: "border-amber-500/20 bg-amber-950/20 text-amber-300",
zinc: "border-line/50 bg-surface-sunken/70 text-ink-mid",
emerald: "border-emerald-500/20 bg-emerald-950/20 text-good",
amber: "border-amber-500/20 bg-amber-950/20 text-warm",
}[tone];
return (
+27 -27
View File
@@ -236,7 +236,7 @@ export function OrgTemplatesSection() {
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
aria-controls="org-templates-body"
className="flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-zinc-500 hover:text-zinc-300 font-semibold transition-colors"
className="flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-ink-soft hover:text-ink-mid font-semibold transition-colors"
>
<span
aria-hidden="true"
@@ -246,7 +246,7 @@ export function OrgTemplatesSection() {
</span>
Org Templates
{orgs.length > 0 && (
<span className="text-zinc-600 normal-case tracking-normal">
<span className="text-ink-soft normal-case tracking-normal">
({orgs.length})
</span>
)}
@@ -255,7 +255,7 @@ export function OrgTemplatesSection() {
type="button"
onClick={loadOrgs}
aria-label="Refresh org templates"
className="text-[10px] text-zinc-500 hover:text-zinc-300"
className="text-[10px] text-ink-soft hover:text-ink-mid"
>
</button>
@@ -264,20 +264,20 @@ export function OrgTemplatesSection() {
{expanded && (
<div id="org-templates-body" className="space-y-2">
{loading && (
<div role="status" aria-live="polite" className="flex items-center gap-1.5 text-[10px] text-zinc-500">
<div role="status" aria-live="polite" className="flex items-center gap-1.5 text-[10px] text-ink-soft">
<Spinner size="sm" />
Loading
</div>
)}
{!loading && orgs.length === 0 && (
<div className="text-[10px] text-zinc-500">
<div className="text-[10px] text-ink-soft">
No org templates in <code>org-templates/</code>
</div>
)}
{error && (
<div className="px-2 py-1 bg-red-950/40 border border-red-800/50 rounded text-[10px] text-red-400">
<div className="px-2 py-1 bg-red-950/40 border border-red-800/50 rounded text-[10px] text-bad">
{error}
</div>
)}
@@ -287,10 +287,10 @@ export function OrgTemplatesSection() {
return (
<div
key={o.dir}
className="bg-zinc-900/50 border border-zinc-800/60 rounded-xl p-3 hover:border-zinc-700/60 transition-all"
className="bg-surface-sunken/50 border border-line/60 rounded-xl p-3 hover:border-line/60 transition-all"
>
<div className="flex items-center justify-between mb-1">
<span className="text-[12px] font-semibold text-zinc-200 truncate">
<span className="text-[12px] font-semibold text-ink truncate">
{o.name || o.dir}
</span>
<span className="text-[9px] font-mono text-sky-400 bg-sky-950/40 px-1.5 py-0.5 rounded-md shrink-0">
@@ -298,7 +298,7 @@ export function OrgTemplatesSection() {
</span>
</div>
{o.description && (
<p className="text-[10px] text-zinc-500 mb-2.5 line-clamp-2 leading-relaxed">
<p className="text-[10px] text-ink-soft mb-2.5 line-clamp-2 leading-relaxed">
{o.description}
</p>
)}
@@ -306,7 +306,7 @@ export function OrgTemplatesSection() {
type="button"
onClick={() => handleImport(o)}
disabled={isImporting}
className="w-full px-2 py-1.5 bg-blue-600/20 hover:bg-blue-600/30 border border-blue-500/30 rounded-lg text-[10px] text-blue-300 font-medium transition-colors disabled:opacity-50"
className="w-full px-2 py-1.5 bg-accent-strong/20 hover:bg-accent-strong/30 border border-accent/30 rounded-lg text-[10px] text-accent font-medium transition-colors disabled:opacity-50"
>
{isImporting ? "Importing…" : "Import org"}
</button>
@@ -411,7 +411,7 @@ function ImportAgentButton({ onImported }: { onImported: () => void }) {
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={importing}
className="w-full px-3 py-2 bg-blue-600/20 hover:bg-blue-600/30 border border-blue-500/30 rounded-lg text-[11px] text-blue-300 font-medium transition-colors disabled:opacity-50"
className="w-full px-3 py-2 bg-accent-strong/20 hover:bg-accent-strong/30 border border-accent/30 rounded-lg text-[11px] text-accent font-medium transition-colors disabled:opacity-50"
>
{importing ? "Importing..." : "Import Agent Folder"}
</button>
@@ -476,8 +476,8 @@ export function TemplatePalette() {
onClick={() => setOpen(!open)}
className={`fixed top-4 left-4 z-40 w-9 h-9 flex items-center justify-center rounded-lg transition-colors ${
open
? "bg-blue-600 text-white"
: "bg-zinc-900/90 border border-zinc-700/50 text-zinc-400 hover:text-zinc-200 hover:border-zinc-600"
? "bg-accent-strong text-white"
: "bg-surface-sunken/90 border border-line/50 text-ink-mid hover:text-ink hover:border-line"
}`}
title="Template Palette"
aria-label={open ? "Close template palette" : "Open template palette"}
@@ -496,10 +496,10 @@ export function TemplatePalette() {
{/* Sidebar */}
{open && (
<div className="fixed top-0 left-0 h-full w-[280px] bg-zinc-900/95 backdrop-blur-md border-r border-zinc-800/60 z-30 flex flex-col shadow-2xl shadow-black/40">
<div className="px-4 pt-14 pb-3 border-b border-zinc-800/60">
<h2 className="text-sm font-semibold text-zinc-100">Templates</h2>
<p className="text-[10px] text-zinc-500 mt-0.5">Click to deploy a workspace</p>
<div className="fixed top-0 left-0 h-full w-[280px] bg-surface-sunken/95 backdrop-blur-md border-r border-line/60 z-30 flex flex-col shadow-2xl shadow-black/40">
<div className="px-4 pt-14 pb-3 border-b border-line/60">
<h2 className="text-sm font-semibold text-ink">Templates</h2>
<p className="text-[10px] text-ink-soft mt-0.5">Click to deploy a workspace</p>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-2">
@@ -509,20 +509,20 @@ export function TemplatePalette() {
<OrgTemplatesSection />
{loading && (
<div role="status" aria-live="polite" className="flex items-center justify-center gap-2 text-xs text-zinc-500 text-center py-8">
<div role="status" aria-live="polite" className="flex items-center justify-center gap-2 text-xs text-ink-soft text-center py-8">
<Spinner />
Loading
</div>
)}
{!loading && templates.length === 0 && (
<div role="status" aria-live="polite" className="text-xs text-zinc-500 text-center py-8">
<div role="status" aria-live="polite" className="text-xs text-ink-soft text-center py-8">
No templates found in<br />workspace-configs-templates/
</div>
)}
{error && (
<div className="px-3 py-1.5 bg-red-950/40 border border-red-800/50 rounded-lg text-xs text-red-400">
<div className="px-3 py-1.5 bg-red-950/40 border border-red-800/50 rounded-lg text-xs text-bad">
{error}
</div>
)}
@@ -537,10 +537,10 @@ export function TemplatePalette() {
key={t.id}
onClick={() => void handleDeploy(t)}
disabled={isDeploying}
className="w-full text-left bg-zinc-800/40 hover:bg-zinc-800/70 border border-zinc-700/40 hover:border-zinc-600/50 rounded-xl p-3 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-zinc-800/40 disabled:hover:border-zinc-700/40 group focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
className="w-full text-left bg-surface-card/40 hover:bg-surface-card/70 border border-line/40 hover:border-line/50 rounded-xl p-3 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-surface-card/40 disabled:hover:border-line/40 group focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/70"
>
<div className="flex items-center justify-between mb-1">
<span className="text-[12px] font-semibold text-zinc-200 group-hover:text-zinc-100 truncate">
<span className="text-[12px] font-semibold text-ink group-hover:text-ink truncate">
{t.name}
</span>
<span className={`text-[9px] font-mono px-1.5 py-0.5 rounded-md shrink-0 ${tierCfg.color}`}>
@@ -549,7 +549,7 @@ export function TemplatePalette() {
</div>
{t.description && (
<p className="text-[10px] text-zinc-500 mb-2 line-clamp-2 leading-relaxed">
<p className="text-[10px] text-ink-soft mb-2 line-clamp-2 leading-relaxed">
{t.description}
</p>
)}
@@ -557,12 +557,12 @@ export function TemplatePalette() {
{t.skills?.length > 0 && (
<div className="flex flex-wrap gap-1">
{t.skills.slice(0, 3).map((s) => (
<span key={s} className="text-[8px] text-zinc-400 bg-zinc-700/40 px-1.5 py-0.5 rounded">
<span key={s} className="text-[8px] text-ink-mid bg-surface-card/40 px-1.5 py-0.5 rounded">
{s}
</span>
))}
{t.skills.length > 3 && (
<span className="text-[8px] text-zinc-500">+{t.skills.length - 3}</span>
<span className="text-[8px] text-ink-soft">+{t.skills.length - 3}</span>
)}
</div>
)}
@@ -575,12 +575,12 @@ export function TemplatePalette() {
})}
</div>
<div className="px-4 py-3 border-t border-zinc-800/60 space-y-3">
<div className="px-4 py-3 border-t border-line/60 space-y-3">
<ImportAgentButton onImported={loadTemplates} />
<button
type="button"
onClick={loadTemplates}
className="text-[10px] text-zinc-500 hover:text-zinc-300 transition-colors block"
className="text-[10px] text-ink-soft hover:text-ink-mid transition-colors block"
>
Refresh templates
</button>
+53 -20
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { PLATFORM_URL } from "@/lib/api";
// TermsGate blocks the page it wraps until the user has accepted the
@@ -73,39 +73,72 @@ export function TermsGate({ children }: { children: React.ReactNode }) {
}
};
// Move focus to the "I agree" button when the modal opens (WCAG 2.4.3).
// The dialog is a hard gate — no Esc dismiss — so we don't need a focus
// trap loop, just a one-shot focus move into the dialog.
const agreeButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (status !== "pending") return;
const raf = requestAnimationFrame(() => agreeButtonRef.current?.focus());
return () => cancelAnimationFrame(raf);
}, [status]);
return (
<>
{children}
{status === "pending" && (
<div aria-hidden="true" className="fixed inset-0 z-50 flex items-center justify-center bg-zinc-950/80 backdrop-blur-sm">
// Backdrop is decorative — does NOT carry aria-hidden anymore.
// The earlier version put aria-hidden="true" on this wrapper,
// which hid the dialog AND its descendants from screen readers,
// making the entire terms-acceptance flow invisible to AT users.
// Backdrop click intentionally does nothing — this is a hard
// gate.
<div className="fixed inset-0 z-50 flex items-center justify-center bg-surface/80 backdrop-blur-sm">
<div
role="dialog"
aria-modal="true"
aria-labelledby="terms-dialog-title"
className="mx-4 max-w-lg rounded-lg border border-zinc-700 bg-zinc-900 p-6 shadow-xl"
aria-describedby="terms-dialog-body"
className="mx-4 max-w-lg rounded-lg border border-line bg-surface-sunken p-6 shadow-xl"
>
<h2 id="terms-dialog-title" className="text-lg font-semibold text-white">Terms &amp; conditions</h2>
<p className="mt-3 text-sm text-zinc-300">
Before you create an organization, please review our{" "}
<a href="/legal/terms" className="text-sky-400 underline" target="_blank" rel="noreferrer">
Terms of Service
</a>{" "}
and{" "}
<a href="/legal/privacy" className="text-sky-400 underline" target="_blank" rel="noreferrer">
Privacy Policy
</a>
. Click agree to continue.
</p>
<p className="mt-3 text-xs text-zinc-500">
By agreeing you acknowledge that workspace data is stored in AWS us-east-2 (Ohio, United States).
</p>
{error && <p role="alert" className="mt-3 text-sm text-red-400">{error}</p>}
<h2 id="terms-dialog-title" className="text-lg font-semibold text-ink">Terms &amp; conditions</h2>
<div id="terms-dialog-body">
<p className="mt-3 text-sm text-ink-mid">
Before you create an organization, please review our{" "}
<a
href="/legal/terms"
className="text-accent underline underline-offset-2 hover:text-accent-strong focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 rounded-sm"
target="_blank"
rel="noreferrer"
>
Terms of Service
</a>{" "}
and{" "}
<a
href="/legal/privacy"
className="text-accent underline underline-offset-2 hover:text-accent-strong focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 rounded-sm"
target="_blank"
rel="noreferrer"
>
Privacy Policy
</a>
. Click agree to continue.
</p>
<p className="mt-3 text-xs text-ink-soft">
By agreeing you acknowledge that workspace data is stored in AWS us-east-2 (Ohio, United States).
</p>
</div>
{error && <p role="alert" className="mt-3 text-sm text-bad">{error}</p>}
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
ref={agreeButtonRef}
onClick={accept}
disabled={submitting}
className="rounded bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-500 disabled:opacity-50"
// Hover goes DARKER, not lighter — emerald-500 on white
// text drops contrast below AA vs emerald-700. Same trap
// I fixed in ApprovalBanner + ConfirmDialog.
className="rounded bg-emerald-600 hover:bg-emerald-700 px-4 py-2 text-sm font-medium text-white disabled:opacity-50 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken"
>
{submitting ? "Saving…" : "I agree"}
</button>
+81
View File
@@ -0,0 +1,81 @@
"use client";
import { useTheme, type ThemePreference } from "@/lib/theme-provider";
const OPTIONS: { value: ThemePreference; label: string; icon: string }[] = [
// Sun: explicit light
{
value: "light",
label: "Light",
icon: "M12 3v1.5M12 19.5V21M4.22 4.22l1.06 1.06M18.72 18.72l1.06 1.06M3 12h1.5M19.5 12H21M4.22 19.78l1.06-1.06M18.72 5.28l1.06-1.06M16 12a4 4 0 11-8 0 4 4 0 018 0z",
},
// Monitor: follow OS
{
value: "system",
label: "System",
icon: "M3 5h18v11H3zM8 21h8M9 21l1-5h4l1 5",
},
// Moon: explicit dark
{
value: "dark",
label: "Dark",
icon: "M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z",
},
];
/**
* Three-way preference picker: System / Light / Dark.
*
* Highlights the user's *picked* preference, not the resolved render
* mode. So "System" stays highlighted while the screen renders dark
* (because the OS is dark) — that's the user's mental model: "I told
* the app to follow my OS."
*
* Aligned with molecule-app/components/theme-toggle.tsx so the picker
* behaves identically across surfaces.
*/
export function ThemeToggle({ className = "" }: { className?: string }) {
const { theme, setTheme } = useTheme();
return (
<div
role="radiogroup"
aria-label="Theme preference"
className={`inline-flex items-center gap-0.5 rounded-md border border-line bg-surface-sunken p-0.5 ${className}`}
>
{OPTIONS.map((opt) => {
const active = theme === opt.value;
return (
<button
key={opt.value}
type="button"
role="radio"
aria-checked={active}
aria-label={opt.label}
onClick={() => setTheme(opt.value)}
className={
"flex h-6 w-6 items-center justify-center rounded transition-colors " +
(active
? "bg-surface-elevated text-ink shadow-sm"
: "text-ink-soft hover:text-ink-mid")
}
>
<svg
width={13}
height={13}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d={opt.icon} />
</svg>
</button>
);
})}
</div>
);
}
+26 -3
View File
@@ -38,15 +38,38 @@ export function Toaster() {
};
}, []);
// Esc dismisses the newest toast — keyboard parity with the × button.
// Errors never auto-expire, so without this a keyboard-only user has to
// tab through the entire app to reach the dismiss button on a stuck error.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
setToasts((prev) => (prev.length === 0 ? prev : prev.slice(0, -1)));
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
const toastCls = (type: Toast["type"]) =>
`flex items-center gap-2 pl-4 pr-2 py-2.5 rounded-xl shadow-2xl shadow-black/40 text-sm backdrop-blur-md animate-in slide-in-from-bottom duration-200 ${
type === "success"
? "bg-emerald-950/90 border border-emerald-700/40 text-emerald-200"
: type === "error"
? "bg-red-950/90 border border-red-700/40 text-red-200"
: "bg-zinc-900/90 border border-zinc-700/40 text-zinc-200"
: "bg-surface-sunken/90 border border-line/40 text-ink"
}`;
// Success/error toasts are intentionally dark in both themes (high-vis).
// Info uses the semantic surface that flips with theme — so the dismiss
// button needs a tint that stays visible on a light bg in light mode.
const dismissCls = (type: Toast["type"]) => {
const base =
"ml-1 w-7 h-7 inline-flex items-center justify-center text-base leading-none rounded transition-colors opacity-70 hover:opacity-100 focus-visible:opacity-100 focus:outline-none focus-visible:ring-2 shrink-0";
return type === "info"
? `${base} hover:bg-ink/10 focus-visible:ring-accent/60`
: `${base} hover:bg-white/15 focus-visible:ring-white/70`;
};
const pos =
"fixed bottom-16 left-1/2 -translate-x-1/2 z-[80] flex flex-col gap-2 items-center";
@@ -66,7 +89,7 @@ export function Toaster() {
type="button"
onClick={() => dismiss(toast.id)}
aria-label="Dismiss notification"
className="ml-1 p-1 rounded hover:bg-zinc-700/50 transition-colors opacity-70 hover:opacity-100 shrink-0"
className={dismissCls(toast.type)}
>
×
</button>
@@ -94,7 +117,7 @@ export function Toaster() {
type="button"
onClick={() => dismiss(toast.id)}
aria-label="Dismiss notification"
className="ml-1 p-1 rounded hover:bg-zinc-700/50 transition-colors opacity-70 hover:opacity-100 shrink-0"
className={dismissCls(toast.type)}
>
×
</button>
+33 -29
View File
@@ -7,6 +7,7 @@ import { SettingsButton } from "@/components/settings/SettingsButton";
import { settingsGearRef } from "@/components/settings/SettingsPanel";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { showToast } from "@/components/Toaster";
import { ThemeToggle } from "@/components/ThemeToggle";
import { statusDotClass } from "@/lib/design-tokens";
export function Toolbar() {
@@ -128,13 +129,13 @@ export function Toolbar() {
return (
<div
className="fixed top-3 left-1/2 -translate-x-1/2 z-20 flex items-center gap-3 bg-zinc-900/80 backdrop-blur-md border border-zinc-800/60 rounded-xl px-4 py-2 shadow-xl shadow-black/20 transition-[margin-left] duration-200"
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"
style={toolbarOffsetStyle}
>
{/* Logo / Title */}
<div className="flex items-center gap-2 pr-3 border-r border-zinc-800/60">
<div className="flex items-center gap-2 pr-3 border-r border-line/60">
<img src="/molecule-icon.png" alt="Molecule AI" className="w-5 h-5" />
<span className="text-[11px] font-semibold text-zinc-300 tracking-wide">Molecule AI</span>
<span className="text-[11px] font-semibold text-ink-mid tracking-wide">Molecule AI</span>
</div>
{/* Status pills + workspace total in one segment — previously two
@@ -153,15 +154,15 @@ export function Toolbar() {
{counts.failed > 0 && (
<StatusPill color={statusDotClass("failed")} count={counts.failed} label="failed" />
)}
<span className="text-zinc-700" aria-hidden="true">·</span>
<span className="text-[10px] text-zinc-500 whitespace-nowrap">
<span className="text-ink-mid" aria-hidden="true">·</span>
<span className="text-[10px] text-ink-mid whitespace-nowrap">
{counts.roots} workspace{counts.roots !== 1 ? "s" : ""}
{counts.children > 0 && <span className="text-zinc-600"> + {counts.children} sub</span>}
{counts.children > 0 && <span className="text-ink-mid"> + {counts.children} sub</span>}
</span>
</div>
{/* WebSocket connection status */}
<div className="pl-3 border-l border-zinc-800/60">
<div className="pl-3 border-l border-line/60">
<WsStatusPill status={wsStatus} />
</div>
@@ -171,14 +172,14 @@ export function Toolbar() {
type="button"
onClick={stopAll}
disabled={stopping}
className="flex items-center gap-1.5 px-2.5 py-1 bg-red-950/50 hover:bg-red-900/60 border border-red-800/40 rounded-lg transition-colors disabled:opacity-50"
className="flex items-center gap-1.5 px-2.5 py-1 bg-bad/10 hover:bg-bad/20 border border-bad/40 rounded-lg transition-colors disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-bad/40"
title={`Stop all running tasks (${counts.activeTasks} active)`}
aria-label={stopping ? "Stopping all running tasks" : `Stop all running tasks (${counts.activeTasks} active)`}
>
<svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor" className="text-red-400" aria-hidden="true">
<svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor" className="text-bad" aria-hidden="true">
<rect x="2" y="2" width="12" height="12" rx="2" />
</svg>
<span className="text-[10px] text-red-300 font-medium">
<span className="text-[10px] text-bad font-medium">
{stopping ? "Stopping..." : `Stop All (${counts.activeTasks})`}
</span>
</button>
@@ -190,14 +191,14 @@ export function Toolbar() {
type="button"
onClick={() => setRestartConfirmOpen(true)}
disabled={restartingAll}
className="flex items-center gap-1.5 px-2.5 py-1 bg-amber-950/40 hover:bg-amber-900/50 border border-amber-800/40 rounded-lg transition-colors disabled:opacity-50"
className="flex items-center gap-1.5 px-2.5 py-1 bg-warm/10 hover:bg-warm/20 border border-warm/40 rounded-lg transition-colors disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-warm/40"
title={`Restart ${needsRestartNodes.length} workspace${needsRestartNodes.length === 1 ? "" : "s"} that need to pick up config or secret changes`}
aria-label={restartingAll ? "Restarting workspaces" : `Restart ${needsRestartNodes.length} workspace${needsRestartNodes.length === 1 ? "" : "s"} pending config or secret changes`}
>
<svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.8" className="text-amber-400" aria-hidden="true">
<svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.8" className="text-warm" aria-hidden="true">
<path d="M2 8a6 6 0 1 1 1.76 4.24M2 13v-3h3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span className="text-[10px] text-amber-300 font-medium">
<span className="text-[10px] text-warm font-medium">
{restartingAll ? "Restarting..." : `Restart Pending (${needsRestartNodes.length})`}
</span>
</button>
@@ -215,10 +216,10 @@ export function Toolbar() {
aria-pressed={showA2AEdges}
aria-label={showA2AEdges ? "Hide A2A edges" : "Show A2A edges"}
title={showA2AEdges ? "Hide A2A delegation edges" : "Show A2A delegation edges (last 60 min)"}
className={`flex items-center justify-center w-7 h-7 border rounded-lg transition-colors ${
className={`flex items-center justify-center w-7 h-7 border rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ${
showA2AEdges
? "bg-blue-950/50 hover:bg-blue-900/50 border-blue-800/40 text-blue-300"
: "bg-zinc-800/50 hover:bg-zinc-700/50 border-zinc-700/40 text-zinc-500 hover:text-zinc-300"
? "bg-accent/15 hover:bg-accent/25 border-accent/50 text-accent"
: "bg-surface-card hover:bg-surface-card/70 border-line text-ink-mid hover:text-ink"
}`}
>
{/* Mesh / network icon */}
@@ -254,7 +255,7 @@ export function Toolbar() {
}}
aria-label="Open audit trail for selected workspace"
title="Audit — view ledger for the selected workspace"
className="flex items-center justify-center w-7 h-7 bg-zinc-800/50 hover:bg-zinc-700/50 border border-zinc-700/40 rounded-lg transition-colors text-zinc-500 hover:text-zinc-300"
className="flex items-center justify-center w-7 h-7 bg-surface-card hover:bg-surface-card/70 border border-line rounded-lg transition-colors text-ink-mid hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
{/* Scroll / ledger icon */}
<svg
@@ -276,7 +277,7 @@ export function Toolbar() {
onClick={() => useCanvasStore.getState().setSearchOpen(true)}
aria-label="Search workspaces"
title="Search (⌘K)"
className="flex items-center justify-center w-7 h-7 bg-zinc-800/50 hover:bg-zinc-700/50 border border-zinc-700/40 rounded-lg transition-colors text-zinc-500 hover:text-zinc-300"
className="flex items-center justify-center w-7 h-7 bg-surface-card hover:bg-surface-card/70 border border-line rounded-lg transition-colors text-ink-mid hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<circle cx="7" cy="7" r="5" stroke="currentColor" strokeWidth="1.5" />
@@ -289,7 +290,7 @@ export function Toolbar() {
<button
type="button"
onClick={() => setHelpOpen((open) => !open)}
className="flex items-center justify-center w-7 h-7 bg-zinc-800/50 hover:bg-zinc-700/50 border border-zinc-700/40 rounded-lg transition-colors text-zinc-500 hover:text-zinc-300"
className="flex items-center justify-center w-7 h-7 bg-surface-card hover:bg-surface-card/70 border border-line rounded-lg transition-colors text-ink-mid hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
aria-expanded={helpOpen}
aria-label="Open quick help"
title="Help — shortcuts & quick start"
@@ -301,13 +302,13 @@ export function Toolbar() {
</button>
{helpOpen && (
<div className="absolute right-0 top-full mt-2 w-72 rounded-xl border border-zinc-700/60 bg-zinc-950/95 p-3 shadow-2xl shadow-black/50 backdrop-blur-md">
<div className="absolute right-0 top-full mt-2 w-72 rounded-xl border border-line/60 bg-surface/95 p-3 shadow-2xl shadow-black/50 backdrop-blur-md">
<div className="mb-2 flex items-center justify-between">
<span className="text-[10px] font-semibold uppercase tracking-[0.24em] text-zinc-400">Quick start</span>
<span className="text-[10px] font-semibold uppercase tracking-[0.24em] text-ink-mid">Quick start</span>
<button
type="button"
onClick={() => setHelpOpen(false)}
className="text-[10px] text-zinc-600 hover:text-zinc-300 transition-colors"
className="text-[10px] text-ink-mid hover:text-ink transition-colors focus:outline-none focus-visible:underline"
>
Close
</button>
@@ -324,6 +325,9 @@ export function Toolbar() {
)}
</div>
{/* Theme picker — System / Light / Dark */}
<ThemeToggle />
{/* Settings gear icon */}
<SettingsButton ref={settingsGearRef} />
@@ -344,7 +348,7 @@ function StatusPill({ color, count, label }: { color: string; count: number; lab
return (
<div className="flex items-center gap-1.5" title={`${count} ${label}`} aria-label={`${count} ${label}`}>
<div className={`w-1.5 h-1.5 rounded-full ${color}`} aria-hidden="true" />
<span className="text-[10px] text-zinc-400 tabular-nums" aria-hidden="true">{count}</span>
<span className="text-[10px] text-ink-mid tabular-nums" aria-hidden="true">{count}</span>
</div>
);
}
@@ -354,7 +358,7 @@ function WsStatusPill({ status }: { status: "connected" | "connecting" | "discon
return (
<div className="flex items-center gap-1.5" title="Real-time updates: connected" aria-label="Real-time updates: connected">
<div className={`w-1.5 h-1.5 rounded-full ${statusDotClass("online")}`} aria-hidden="true" />
<span className="text-[10px] text-zinc-500" aria-hidden="true">Live</span>
<span className="text-[10px] text-ink-mid" aria-hidden="true">Live</span>
</div>
);
}
@@ -362,25 +366,25 @@ function WsStatusPill({ status }: { status: "connected" | "connecting" | "discon
return (
<div className="flex items-center gap-1.5" title="Real-time updates: reconnecting…" aria-label="Real-time updates: reconnecting">
<div className="w-1.5 h-1.5 rounded-full bg-amber-400 motion-safe:animate-pulse" aria-hidden="true" />
<span className="text-[10px] text-zinc-500" aria-hidden="true">Reconnecting</span>
<span className="text-[10px] text-warm" aria-hidden="true">Reconnecting</span>
</div>
);
}
return (
<div className="flex items-center gap-1.5" title="Real-time updates: disconnected" aria-label="Real-time updates: disconnected">
<div className={`w-1.5 h-1.5 rounded-full ${statusDotClass("failed")}`} aria-hidden="true" />
<span className="text-[10px] text-zinc-500" aria-hidden="true">Offline</span>
<span className="text-[10px] text-bad" aria-hidden="true">Offline</span>
</div>
);
}
function HelpRow({ shortcut, text }: { shortcut: string; text: string }) {
return (
<div className="flex items-start gap-3 rounded-lg border border-zinc-800/70 bg-zinc-900/45 px-3 py-2">
<span className="shrink-0 rounded-md border border-zinc-700/60 bg-zinc-950/70 px-2 py-0.5 text-[9px] font-medium uppercase tracking-[0.18em] text-zinc-400">
<div className="flex items-start gap-3 rounded-lg border border-line/70 bg-surface-sunken/45 px-3 py-2">
<span className="shrink-0 rounded-md border border-line/60 bg-surface/70 px-2 py-0.5 text-[9px] font-medium uppercase tracking-[0.18em] text-ink-mid">
{shortcut}
</span>
<p className="text-[11px] leading-relaxed text-zinc-500">{text}</p>
<p className="text-[11px] leading-relaxed text-ink-mid">{text}</p>
</div>
);
}
+20 -2
View File
@@ -22,6 +22,24 @@ export function Tooltip({ text, children }: Props) {
useEffect(() => () => clearTimeout(timerRef.current), []);
// WCAG 1.4.13 (Content on Hover or Focus) — Dismissible: a mechanism
// is available to dismiss the additional content WITHOUT moving
// pointer hover or keyboard focus. Esc dismisses while the trigger
// stays focused/hovered, so a screen-magnifier user can read what
// the tooltip was covering without losing their place.
useEffect(() => {
if (!show) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
clearTimeout(timerRef.current);
setShow(false);
}
};
window.addEventListener("keydown", onKey, true);
return () => window.removeEventListener("keydown", onKey, true);
}, [show]);
const enter = useCallback(() => {
timerRef.current = setTimeout(() => {
if (triggerRef.current) {
@@ -66,10 +84,10 @@ export function Tooltip({ text, children }: Props) {
<div
id={tooltipId.current}
role="tooltip"
className="fixed z-[9999] max-w-[400px] max-h-[300px] overflow-y-auto px-3 py-2 bg-zinc-800 border border-zinc-600 rounded-lg shadow-2xl shadow-black/60 pointer-events-none"
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"
style={{ left: pos.x, top: Math.max(8, pos.y - 8), transform: "translateY(-100%)" }}
>
<div className="text-[11px] text-zinc-200 whitespace-pre-wrap break-words leading-relaxed">
<div className="text-[11px] text-ink whitespace-pre-wrap break-words leading-relaxed">
{text}
</div>
</div>,
+45 -45
View File
@@ -36,7 +36,7 @@ function EjectIcon(props: React.SVGProps<SVGSVGElement>) {
export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>) {
const statusCfg = STATUS_CONFIG[data.status] || STATUS_CONFIG.offline;
const tierCfg = TIER_CONFIG[data.tier] || { label: `T${data.tier}`, color: "text-zinc-500 bg-zinc-800" };
const tierCfg = TIER_CONFIG[data.tier] || { label: `T${data.tier}`, color: "text-ink-mid bg-surface-card border border-line" };
// Org-deploy context — four derived flags off one store subscription.
// Drives the shimmer while provisioning, the dimmed/non-draggable
// treatment on locked descendants, and the Cancel pill on the root.
@@ -69,8 +69,8 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
isVisible={isSelected}
minWidth={hasChildren ? 360 : 210}
minHeight={hasChildren ? 200 : 110}
lineClassName="!border-blue-500/40"
handleClassName="!w-2 !h-2 !bg-blue-500 !border !border-blue-300"
lineClassName="!border-accent/40"
handleClassName="!w-2 !h-2 !bg-accent !border !border-blue-300"
/>
<div
role="button"
@@ -137,13 +137,13 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
${isDragTarget
? "bg-emerald-950/40 border-2 border-emerald-400/60 ring-2 ring-emerald-400/20 scale-[1.03]"
: isBatchSelected
? "bg-zinc-900/95 border-2 border-blue-500/80 ring-2 ring-blue-500/30 shadow-lg shadow-blue-500/15"
? "bg-surface-sunken/95 border-2 border-accent/80 ring-2 ring-accent/30 shadow-lg shadow-blue-500/15"
: isSelected
? "bg-zinc-900/95 border border-blue-500/70 ring-1 ring-blue-500/30 shadow-lg shadow-blue-500/10"
: "bg-zinc-900/90 border border-zinc-700/80 hover:border-zinc-500/60 shadow-lg shadow-black/30 hover:shadow-xl hover:shadow-black/40"
? "bg-surface-sunken/95 border border-accent/70 ring-1 ring-accent/30 shadow-lg shadow-blue-500/10"
: "bg-surface-sunken/90 border border-line/80 hover:border-zinc-500/60 shadow-lg shadow-black/30 hover:shadow-xl hover:shadow-black/40"
}
backdrop-blur-sm
focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-950
focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/70 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-950
${deploy.isActivelyProvisioning ? "mol-deploy-shimmer" : ""}
${deploy.isLockedChild ? "mol-deploy-locked" : ""}
`}
@@ -165,7 +165,7 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
<Handle
type="target"
position={Position.Top}
className="!w-2.5 !h-1 !rounded-full !bg-zinc-600/80 !border-0 !-top-0.5 hover:!bg-blue-400 hover:!h-1.5 transition-all"
className="!w-2.5 !h-1 !rounded-full !bg-surface-card/80 !border-0 !-top-0.5 hover:!bg-blue-400 hover:!h-1.5 transition-all"
/>
<div className="relative px-3.5 py-2.5">
@@ -173,13 +173,13 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0">
<div className={`w-2 h-2 rounded-full shrink-0 ${statusCfg.dot} ${statusCfg.glow} shadow-sm`} />
<span className="text-[13px] font-semibold text-zinc-100 truncate leading-tight">
<span className="text-[13px] font-semibold text-ink truncate leading-tight">
{data.name}
</span>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{hasChildren && (
<span className="text-[10px] font-mono text-violet-300 bg-violet-900/40 border border-violet-700/30 px-1.5 py-0.5 rounded-md">
<span className="text-[10px] font-mono text-accent bg-accent/15 border border-accent/40 px-1.5 py-0.5 rounded-md">
{descendantCount} sub
</span>
)}
@@ -207,13 +207,13 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
<div className="mb-1 flex items-center gap-1">
{runtime === "external" ? (
<span
className="text-[7px] font-mono px-1.5 py-0.5 rounded-md text-violet-200 bg-violet-900/50 border border-violet-500/40"
className="text-[7px] font-mono px-1.5 py-0.5 rounded-md text-white bg-violet-600 border border-violet-700"
title="Phase 30 remote agent — runs outside this platform's Docker network. Lifecycle managed via heartbeat-based polling, not Docker exec."
>
REMOTE
</span>
) : (
<span className="text-[7px] font-mono px-1.5 py-0.5 rounded-md text-zinc-400 bg-zinc-800/60 border border-zinc-700/30">
<span className="text-[7px] font-mono px-1.5 py-0.5 rounded-md text-ink-mid bg-surface-card border border-line">
{runtime}
</span>
)}
@@ -226,7 +226,7 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
* grow arbitrarily tall, which wrecks the grid-slot layout
* because siblings all plan for the same CHILD_DEFAULT_HEIGHT. */}
{data.role && (
<div className="text-[10px] text-zinc-400 mb-1.5 leading-tight line-clamp-2">{data.role}</div>
<div className="text-[10px] text-ink-mid mb-1.5 leading-tight line-clamp-2">{data.role}</div>
)}
{/* Skills */}
@@ -237,15 +237,15 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
key={skill}
className={`text-[10px] px-1.5 py-0.5 rounded-md border ${
isOnline
? "text-emerald-300/80 bg-emerald-950/30 border-emerald-800/30"
: "text-zinc-400 bg-zinc-800/60 border-zinc-700/40"
? "text-good bg-good/15 border-good/40"
: "text-ink-mid bg-surface-card border-line"
}`}
>
{skill}
</span>
))}
{skills.length > 4 && (
<span className="text-[10px] text-zinc-500 self-center">
<span className="text-[10px] text-ink-mid self-center">
+{skills.length - 4}
</span>
)}
@@ -261,7 +261,7 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
<Tooltip text={String(data.currentTask)}>
<div className="flex items-center gap-1.5 mt-1 bg-amber-950/20 px-2 py-1 rounded-md border border-amber-800/20 cursor-default">
<div className="w-1.5 h-1.5 rounded-full bg-amber-400 motion-safe:animate-pulse shrink-0" />
<span className="text-[10px] text-amber-300/80 truncate">{data.currentTask}</span>
<span className="text-[10px] text-warm/80 truncate">{data.currentTask}</span>
</div>
</Tooltip>
)}
@@ -274,10 +274,10 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
e.stopPropagation();
useCanvasStore.getState().restartWorkspace(id).catch(() => showToast("Restart failed", "error"));
}}
className="flex items-center gap-1.5 mt-1 w-full bg-sky-950/30 px-2 py-1 rounded-md border border-sky-800/30 hover:bg-sky-900/40 transition-colors text-left focus-visible:ring-2 focus-visible:ring-blue-500/70 focus-visible:outline-none"
className="flex items-center gap-1.5 mt-1 w-full bg-accent/10 px-2 py-1 rounded-md border border-accent/40 hover:bg-accent/20 transition-colors text-left focus-visible:ring-2 focus-visible:ring-accent/70 focus-visible:outline-none"
>
<span className="text-[10px]"></span>
<span className="text-[10px] text-sky-300/80">Restart to apply changes</span>
<span className="text-[10px] text-accent"></span>
<span className="text-[10px] text-accent">Restart to apply changes</span>
</button>
)}
@@ -285,10 +285,10 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
<div className="flex items-center justify-between mt-0.5">
{data.status !== "online" ? (
<div className={`text-[10px] uppercase tracking-widest font-medium ${
data.status === "failed" ? "text-red-400" :
data.status === "degraded" ? "text-amber-300" :
data.status === "provisioning" ? "text-sky-400" :
"text-zinc-500"
data.status === "failed" ? "text-bad" :
data.status === "degraded" ? "text-warm" :
data.status === "provisioning" ? "text-accent" :
"text-ink-mid"
}`}>
{statusCfg.label}
</div>
@@ -296,8 +296,8 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
{data.activeTasks > 0 && (
<div className="flex items-center gap-1">
<div className="w-1 h-1 rounded-full bg-amber-400 motion-safe:animate-pulse" />
<span className="text-[10px] text-amber-300/80 tabular-nums">
<div className="w-1 h-1 rounded-full bg-warm motion-safe:animate-pulse" />
<span className="text-[10px] text-warm tabular-nums">
{data.activeTasks} task{data.activeTasks > 1 ? "s" : ""}
</span>
</div>
@@ -307,7 +307,7 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
{/* Degraded error preview */}
{data.status === "degraded" && data.lastSampleError && (
<div
className="text-[10px] text-amber-300/60 truncate mt-1 bg-amber-950/20 px-1.5 py-0.5 rounded border border-amber-800/20"
className="text-[10px] text-warm truncate mt-1 bg-warm/10 px-1.5 py-0.5 rounded border border-warm/40"
title={data.lastSampleError}
>
{data.lastSampleError}
@@ -318,7 +318,7 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
<Handle
type="source"
position={Position.Bottom}
className="!w-2.5 !h-1 !rounded-full !bg-zinc-600/80 !border-0 !-bottom-0.5 hover:!bg-blue-400 hover:!h-1.5 transition-all"
className="!w-2.5 !h-1 !rounded-full !bg-surface-card/80 !border-0 !-bottom-0.5 hover:!bg-blue-400 hover:!h-1.5 transition-all"
/>
</div>
</>
@@ -357,7 +357,7 @@ function TeamMemberChip({
}) {
const { data } = node;
const statusCfg = STATUS_CONFIG[data.status] || STATUS_CONFIG.offline;
const tierCfg = TIER_CONFIG[data.tier] || { label: `T${data.tier}`, color: "text-zinc-500 bg-zinc-800" };
const tierCfg = TIER_CONFIG[data.tier] || { label: `T${data.tier}`, color: "text-ink-mid bg-surface-card border border-line" };
const isOnline = data.status === "online";
const skills = getSkillNames(data.agentCard);
@@ -376,7 +376,7 @@ function TeamMemberChip({
role="button"
tabIndex={0}
aria-label={`Select ${data.name}`}
className="group/child relative rounded-lg bg-zinc-800/60 hover:bg-zinc-700/70 border border-zinc-700/30 hover:border-zinc-600/40 overflow-hidden transition-colors cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
className="group/child relative rounded-lg bg-surface-card/60 hover:bg-surface-card/70 border border-line/30 hover:border-line/40 overflow-hidden transition-colors cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/70"
onClick={(e) => {
e.stopPropagation();
onSelect(node.id);
@@ -402,13 +402,13 @@ function TeamMemberChip({
<div className="flex items-center justify-between gap-1 mb-0.5">
<div className="flex items-center gap-1.5 min-w-0">
<div className={`w-1.5 h-1.5 rounded-full shrink-0 ${statusCfg.dot}`} />
<span className="text-[10px] font-semibold text-zinc-200 truncate leading-tight">
<span className="text-[10px] font-semibold text-ink truncate leading-tight">
{data.name}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
{hasSubChildren && (
<span className="text-[7px] font-mono text-violet-300 bg-violet-900/40 border border-violet-700/30 px-1 py-0.5 rounded">
<span className="text-[7px] font-mono text-accent bg-accent/15 border border-accent/40 px-1 py-0.5 rounded">
{descendantCount}
</span>
)}
@@ -423,7 +423,7 @@ function TeamMemberChip({
e.stopPropagation();
onExtract(node.id);
}}
className="opacity-0 group-hover/child:opacity-100 text-zinc-500 hover:text-sky-400 transition-all focus-visible:ring-2 focus-visible:ring-blue-500/70 focus-visible:outline-none rounded"
className="opacity-0 group-hover/child:opacity-100 text-ink-mid hover:text-accent transition-all focus-visible:ring-2 focus-visible:ring-accent/70 focus-visible:outline-none rounded"
>
<EjectIcon aria-hidden="true" />
</button>
@@ -432,7 +432,7 @@ function TeamMemberChip({
{/* Role */}
{data.role && (
<div className="text-[10px] text-zinc-500 mb-1 leading-tight truncate">{data.role}</div>
<div className="text-[10px] text-ink-mid mb-1 leading-tight truncate">{data.role}</div>
)}
{/* Skills */}
@@ -443,15 +443,15 @@ function TeamMemberChip({
key={skill}
className={`text-[10px] px-1 py-0.5 rounded border ${
isOnline
? "text-emerald-300/70 bg-emerald-950/20 border-emerald-800/20"
: "text-zinc-500 bg-zinc-800/40 border-zinc-700/30"
? "text-good bg-good/15 border-good/40"
: "text-ink-mid bg-surface-card border-line"
}`}
>
{skill}
</span>
))}
{skills.length > 3 && (
<span className="text-[10px] text-zinc-400 self-center">+{skills.length - 3}</span>
<span className="text-[10px] text-ink-mid self-center">+{skills.length - 3}</span>
)}
</div>
)}
@@ -460,10 +460,10 @@ function TeamMemberChip({
<div className="flex items-center justify-between">
{data.status !== "online" ? (
<span className={`text-[10px] uppercase tracking-widest font-medium ${
data.status === "failed" ? "text-red-400" :
data.status === "degraded" ? "text-amber-300" :
data.status === "provisioning" ? "text-sky-400" :
"text-zinc-500"
data.status === "failed" ? "text-bad" :
data.status === "degraded" ? "text-warm" :
data.status === "provisioning" ? "text-accent" :
"text-ink-mid"
}`}>
{statusCfg.label}
</span>
@@ -471,7 +471,7 @@ function TeamMemberChip({
{data.activeTasks > 0 && (
<div className="flex items-center gap-0.5">
<div className="w-1 h-1 rounded-full bg-amber-400 motion-safe:animate-pulse" />
<span className="text-[10px] text-amber-300 tabular-nums">
<span className="text-[10px] text-warm tabular-nums">
{data.activeTasks}
</span>
</div>
@@ -483,15 +483,15 @@ function TeamMemberChip({
<Tooltip text={String(data.currentTask)}>
<div className="flex items-center gap-1 mt-0.5 px-1.5 py-0.5 bg-amber-950/20 rounded border border-amber-800/20 cursor-default">
<div className="w-1 h-1 rounded-full bg-amber-400 motion-safe:animate-pulse shrink-0" />
<span className="text-[10px] text-amber-300 truncate">{data.currentTask}</span>
<span className="text-[10px] text-warm truncate">{data.currentTask}</span>
</div>
</Tooltip>
)}
{/* Recursive sub-children rendered inside this card */}
{hasSubChildren && depth < MAX_NESTING_DEPTH && (
<div className="mt-1.5 pt-1.5 border-t border-zinc-700/20">
<div className="text-[10px] text-zinc-400 uppercase tracking-widest mb-1">Team</div>
<div className="mt-1.5 pt-1.5 border-t border-line/20">
<div className="text-[10px] text-ink-mid uppercase tracking-widest mb-1">Team</div>
<div className={subChildren.length >= 2 ? "grid grid-cols-2 gap-1" : "space-y-1"}>
{subChildren.map((sub) => (
<TeamMemberChip key={sub.id} node={sub} allNodes={allNodes} depth={depth + 1} onSelect={onSelect} onExtract={onExtract} />
+8 -8
View File
@@ -46,16 +46,16 @@ export function WorkspaceUsage({ workspaceId }: WorkspaceUsageProps) {
return (
<div
className="rounded-md border border-zinc-700 bg-zinc-900 p-3 space-y-2"
className="rounded-md border border-line bg-surface-sunken p-3 space-y-2"
data-testid="workspace-usage"
>
<div className="flex items-center justify-between">
<h4 className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">
<h4 className="text-xs font-semibold text-ink-mid uppercase tracking-wider">
Usage
</h4>
{!loading && metrics && (
<span
className="text-[10px] text-zinc-600 font-mono"
className="text-[10px] text-ink-soft font-mono"
data-testid="usage-period"
>
{formatPeriod(metrics.period_start, metrics.period_end)}
@@ -71,7 +71,7 @@ export function WorkspaceUsage({ workspaceId }: WorkspaceUsageProps) {
<SkeletonRow />
</>
) : error ? (
<p className="text-xs text-red-400" data-testid="usage-error">
<p className="text-xs text-bad" data-testid="usage-error">
{error}
</p>
) : metrics ? (
@@ -114,8 +114,8 @@ function SkeletonRow() {
className="flex justify-between items-center animate-pulse"
data-testid="usage-skeleton-row"
>
<div className="h-3 w-20 rounded bg-zinc-700" />
<div className="h-3 w-16 rounded bg-zinc-700" />
<div className="h-3 w-20 rounded bg-surface-card" />
<div className="h-3 w-16 rounded bg-surface-card" />
</div>
);
}
@@ -131,8 +131,8 @@ function StatRow({
}) {
return (
<div className="flex justify-between items-center" data-testid={testId}>
<span className="text-xs text-zinc-500">{label}</span>
<span className="text-xs text-zinc-400 font-mono">{value}</span>
<span className="text-xs text-ink-soft">{label}</span>
<span className="text-xs text-ink-mid font-mono">{value}</span>
</div>
);
}
@@ -296,4 +296,75 @@ describe("A2ATopologyOverlay component", () => {
// setA2AEdges should still be called with an empty array
expect(mockStoreState.setA2AEdges).toHaveBeenCalled();
});
// Regression for the 2026-05-04 render-loop incident:
// tenant heartbeats / status flips / peer-discovery writes mutated
// canvas store .nodes ~5x/sec. Previously visibleIds was useMemo'd on
// [nodes] so the array reference recreated on every store mutation,
// causing fetchAndUpdate to recreate, the useEffect to re-fire, and
// the 60-second polling fan-out to fire on EVERY store update. With
// 5 visible workspaces and 5 store updates/sec, the canvas hammered
// /workspaces/<id>/activity?type=delegation 25×/sec until edge rate
// -limit returned 429 (per browser console captured by user).
//
// Fix: select a stable string key (sorted CSV of IDs) from Zustand
// so the selector's shallow-equal short-circuit prevents re-renders
// when the actual ID set hasn't changed.
//
// This test verifies the fetch fires ONCE on mount + only re-fires
// when the visible ID set actually changes, NOT on every nodes[]
// reference change.
it("does not re-fetch when nodes[] reference changes but visible IDs are the same", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue([] as any);
const { rerender } = render(<A2ATopologyOverlay />);
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
const callsAfterMount = mockGet.mock.calls.length;
// Sanity: 2 visible nodes (ws-a, ws-b) → 2 fan-out requests on mount
expect(callsAfterMount).toBe(2);
// Simulate a store mutation that changes the nodes array reference
// (e.g. status flip on a node) WITHOUT changing the set of visible
// IDs. Pre-fix: this triggered a re-fetch storm. Post-fix: the
// sorted-CSV selector returns the same key, Zustand's shallow-equal
// short-circuits, useMemo keeps the same visibleIds, fetchAndUpdate
// keeps the same identity, useEffect does NOT re-fire.
mockStoreState.nodes = [
{ id: "ws-a", hidden: false, data: { newStatus: "online" } }, // mutated
{ id: "ws-b", hidden: false, data: {} },
{ id: "ws-hidden", hidden: true, data: {} },
];
rerender(<A2ATopologyOverlay />);
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
// No additional fetches should have fired.
expect(mockGet.mock.calls.length).toBe(callsAfterMount);
});
it("re-fetches when the visible ID set actually changes", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue([] as any);
const { rerender } = render(<A2ATopologyOverlay />);
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
const callsAfterMount = mockGet.mock.calls.length;
expect(callsAfterMount).toBe(2);
// Add a new visible workspace — the visible-ID-set actually changed.
mockStoreState.nodes = [
{ id: "ws-a", hidden: false, data: {} },
{ id: "ws-b", hidden: false, data: {} },
{ id: "ws-c", hidden: false, data: {} }, // NEW
{ id: "ws-hidden", hidden: true, data: {} },
];
rerender(<A2ATopologyOverlay />);
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
// Should have fetched the additional workspace + the existing two
// (the effect re-fires once with the new ID set). Total: 2 + 3 = 5.
expect(mockGet.mock.calls.length).toBe(callsAfterMount + 3);
const allPaths = mockGet.mock.calls.map(([p]) => p as string);
expect(allPaths.some((p) => p.includes("ws-c"))).toBe(true);
});
});
@@ -51,7 +51,7 @@ describe("AuthGate — loading state", () => {
</AuthGate>
);
const overlay = container.querySelector(".bg-zinc-950.fixed.inset-0");
const overlay = container.querySelector(".bg-surface.fixed.inset-0");
expect(overlay).not.toBeNull();
expect(overlay?.getAttribute("aria-hidden")).toBe("true");
});
@@ -130,6 +130,26 @@ describe("BatchActionBar", () => {
const toolbar = screen.getByRole("toolbar");
expect(toolbar.getAttribute("aria-label")).toBe("Batch workspace actions");
});
it("Esc clears the selection — matches the deselect button title", () => {
// The deselect button has been promising "Clear selection (Escape)"
// since the bar shipped, but no handler was wired. This pins the
// contract.
mockSelectedNodeIds = new Set(["ws-1", "ws-2"]);
render(<BatchActionBar />);
fireEvent.keyDown(window, { key: "Escape" });
expect(mockClearSelection).toHaveBeenCalled();
});
it("Esc is a no-op when nothing is selected", () => {
mockSelectedNodeIds = new Set<string>();
render(<BatchActionBar />);
fireEvent.keyDown(window, { key: "Escape" });
// The early-return at count===0 prevents the bar from mounting at all,
// so the keydown listener never registers. clearSelection must NOT be
// called.
expect(mockClearSelection).not.toHaveBeenCalled();
});
});
/**
@@ -0,0 +1,178 @@
// @vitest-environment jsdom
/**
* CommunicationOverlay tests — pin the rate-limit fix shipped 2026-05-04.
*
* The overlay polls /workspaces/:id/activity?limit=5 for each online
* workspace. Pre-fix it (a) polled regardless of visibility and (b)
* fanned out to 6 workspaces every 10s. With 8+ workspaces a user
* triggered sustained 429s (server-side rate limit is 600 req/min/IP).
*
* These tests pin:
* 1. Fan-out cap of 3 — even with 6 online nodes, only 3 fetches
* 2. Visibility gate — when collapsed, no polling
*
* If a future refactor pushes either dial back up, CI fails before
* the regression hits a paying tenant.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, cleanup, act, fireEvent } from "@testing-library/react";
// ── Mocks (hoisted before imports) ────────────────────────────────────────────
vi.mock("@/lib/api", () => ({
api: { get: vi.fn() },
}));
// Six online nodes — enough to verify the cap of 3.
const mockStoreState = {
selectedNodeId: null as string | null,
nodes: [
{ id: "ws-1", data: { status: "online", name: "ws-1" } },
{ id: "ws-2", data: { status: "online", name: "ws-2" } },
{ id: "ws-3", data: { status: "online", name: "ws-3" } },
{ id: "ws-4", data: { status: "online", name: "ws-4" } },
{ id: "ws-5", data: { status: "online", name: "ws-5" } },
{ id: "ws-6", data: { status: "online", name: "ws-6" } },
{ id: "ws-offline", data: { status: "offline", name: "off" } },
],
};
vi.mock("@/store/canvas", () => ({
useCanvasStore: vi.fn(
(selector: (s: typeof mockStoreState) => unknown) =>
selector(mockStoreState)
),
}));
// design-tokens has named exports — keep the shape minimal.
vi.mock("@/lib/design-tokens", () => ({
COMM_TYPE_LABELS: {
a2a_send: "→",
a2a_receive: "←",
task_update: "✓",
},
}));
// ── Imports (after mocks) ─────────────────────────────────────────────────────
import { api } from "@/lib/api";
import { CommunicationOverlay } from "../CommunicationOverlay";
const mockGet = vi.mocked(api.get);
// ── Setup ─────────────────────────────────────────────────────────────────────
beforeEach(() => {
vi.useFakeTimers();
mockGet.mockReset();
mockGet.mockResolvedValue([]);
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
// ── Tests ─────────────────────────────────────────────────────────────────────
describe("CommunicationOverlay — fan-out cap", () => {
it("polls at most 3 of 6 online workspaces (rate-limit floor)", async () => {
await act(async () => {
render(<CommunicationOverlay />);
});
// Mount fires the first poll synchronously (no interval tick yet).
// Pre-fix: 6 calls. Post-fix: 3.
expect(mockGet).toHaveBeenCalledTimes(3);
// Verify the calls are for the FIRST 3 online nodes (slice order).
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-1/activity?limit=5");
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-2/activity?limit=5");
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-3/activity?limit=5");
});
it("never polls offline workspaces", async () => {
await act(async () => {
render(<CommunicationOverlay />);
});
expect(mockGet).not.toHaveBeenCalledWith(
"/workspaces/ws-offline/activity?limit=5",
);
});
});
describe("CommunicationOverlay — cadence", () => {
it("uses 30s interval cadence (was 10s pre-fix)", async () => {
await act(async () => {
render(<CommunicationOverlay />);
});
expect(mockGet).toHaveBeenCalledTimes(3); // initial mount poll
// Advance 10s — pre-fix this would fire another poll. Post-fix: silent.
await act(async () => {
vi.advanceTimersByTime(10_000);
});
expect(mockGet).toHaveBeenCalledTimes(3);
// Advance to 30s — interval fires.
await act(async () => {
vi.advanceTimersByTime(20_000);
});
expect(mockGet).toHaveBeenCalledTimes(6); // +3 from second tick
});
});
describe("CommunicationOverlay — visibility gate", () => {
// The visibility gate is the dial that drops collapsed-panel polling
// to ZERO. The cadence test above can't catch its removal — if a
// refactor dropped `if (!visible) return`, the cadence test would
// still pass because the effect would still fire every 30s.
//
// Direct probe: render with comms-returning mock so the panel
// actually renders (close button only exists in the expanded panel,
// not the collapsed button-state). Click close, advance the clock,
// assert no further fetches.
it("stops polling after the user collapses the panel", async () => {
// Mock returns one a2a_send so comms.length > 0 → panel renders →
// close button accessible.
mockGet.mockResolvedValue([
{
id: "act-1",
workspace_id: "ws-1",
activity_type: "a2a_send",
source_id: "ws-1",
target_id: "ws-2",
summary: "test",
status: "completed",
duration_ms: 100,
created_at: new Date().toISOString(),
},
]);
const { getByLabelText } = await act(async () => {
return render(<CommunicationOverlay />);
});
// Drain pending microtasks (resolves the await in fetchComms) so
// setComms lands and the panel renders. Don't advance time — that
// would fire the next interval tick and pollute the assertion.
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
// Initial mount polled 3 workspaces.
expect(mockGet).toHaveBeenCalledTimes(3);
mockGet.mockClear();
// Click the close button. Synchronous getByLabelText avoids
// findBy's internal setTimeout (deadlocks under useFakeTimers).
const closeBtn = getByLabelText("Close communications panel");
await act(async () => {
fireEvent.click(closeBtn);
});
// Advance well past the 30s cadence — gate should suppress the tick.
await act(async () => {
vi.advanceTimersByTime(60_000);
});
expect(mockGet).not.toHaveBeenCalled();
});
});
@@ -40,7 +40,7 @@ afterEach(() => {
describe("CookieConsent", () => {
it("renders the banner when no decision is stored", () => {
render(<CookieConsent />);
expect(screen.getByRole("dialog")).toBeTruthy();
expect(screen.getByRole("region")).toBeTruthy();
expect(screen.getByRole("button", { name: "Accept all" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Necessary only" })).toBeTruthy();
});
@@ -48,7 +48,7 @@ describe("CookieConsent", () => {
it("stores 'accepted' and hides the banner when user clicks Accept all", () => {
render(<CookieConsent />);
fireEvent.click(screen.getByRole("button", { name: "Accept all" }));
expect(screen.queryByRole("dialog")).toBeNull();
expect(screen.queryByRole("region")).toBeNull();
const raw = window.localStorage.getItem(STORAGE_KEY);
expect(raw).not.toBeNull();
@@ -61,7 +61,7 @@ describe("CookieConsent", () => {
it("stores 'rejected' and hides the banner when user clicks Necessary only", () => {
render(<CookieConsent />);
fireEvent.click(screen.getByRole("button", { name: "Necessary only" }));
expect(screen.queryByRole("dialog")).toBeNull();
expect(screen.queryByRole("region")).toBeNull();
const parsed = JSON.parse(window.localStorage.getItem(STORAGE_KEY)!);
expect(parsed.decision).toBe("rejected");
@@ -73,7 +73,7 @@ describe("CookieConsent", () => {
JSON.stringify({ decision: "accepted", decidedAt: new Date().toISOString(), version: 1 }),
);
render(<CookieConsent />);
expect(screen.queryByRole("dialog")).toBeNull();
expect(screen.queryByRole("region")).toBeNull();
});
it("re-prompts when the stored decision is on an older policy version", () => {
@@ -82,13 +82,13 @@ describe("CookieConsent", () => {
JSON.stringify({ decision: "accepted", decidedAt: new Date().toISOString(), version: 0 }),
);
render(<CookieConsent />);
expect(screen.getByRole("dialog")).toBeTruthy();
expect(screen.getByRole("region")).toBeTruthy();
});
it("re-prompts when localStorage contains invalid JSON", () => {
window.localStorage.setItem(STORAGE_KEY, "{not json");
render(<CookieConsent />);
expect(screen.getByRole("dialog")).toBeTruthy();
expect(screen.getByRole("region")).toBeTruthy();
});
it("exposes a privacy-policy link with target='_blank'", () => {
@@ -99,11 +99,19 @@ describe("CookieConsent", () => {
expect(link.getAttribute("rel")).toContain("noreferrer");
});
it("uses role=dialog with aria-labelledby and aria-describedby for screen readers", () => {
it("uses role=region (NOT dialog) with aria-labelledby/describedby — banner is informational, not modal", () => {
// Regression guard: an earlier version claimed role="dialog"
// aria-modal="true" without a focus trap. That falsely told screen
// readers the rest of the page was inert, trapping AT users in a
// banner they couldn't escape. role="region" lets assistive tech
// navigate around it normally; the banner stays informational.
render(<CookieConsent />);
const dialog = screen.getByRole("dialog");
expect(dialog.getAttribute("aria-labelledby")).toBe("cookie-consent-title");
expect(dialog.getAttribute("aria-describedby")).toBe("cookie-consent-body");
const banner = screen.getByRole("region");
expect(banner.getAttribute("aria-labelledby")).toBe("cookie-consent-title");
expect(banner.getAttribute("aria-describedby")).toBe("cookie-consent-body");
// No aria-modal claim — explicit guard against regression.
expect(banner.getAttribute("aria-modal")).toBeNull();
expect(screen.queryByRole("dialog")).toBeNull();
});
it("does NOT render on local dev (non-SaaS hostname)", () => {
@@ -116,7 +124,7 @@ describe("CookieConsent", () => {
value: { ...window.location, hostname: "localhost" },
});
render(<CookieConsent />);
expect(screen.queryByRole("dialog")).toBeNull();
expect(screen.queryByRole("region")).toBeNull();
});
it("does NOT render on a LAN hostname (192.168.*, *.local)", () => {
@@ -125,7 +133,7 @@ describe("CookieConsent", () => {
value: { ...window.location, hostname: "192.168.1.74" },
});
render(<CookieConsent />);
expect(screen.queryByRole("dialog")).toBeNull();
expect(screen.queryByRole("region")).toBeNull();
});
});
@@ -0,0 +1,229 @@
// @vitest-environment jsdom
/**
* Provider→model cascade in the deploy modal.
*
* Original bug (2026-05-02 hongming Hermes Agent):
* 1. Modal pre-fills MODEL with template default (e.g. MiniMax-M2.7-highspeed)
* 2. Provider radio defaults to providers[0] (Anthropic) — wrong vendor
* 3. ENV-VAR input shows ANTHROPIC_API_KEY
* 4. User pastes a key, deploys
* 5. Workspace boots with model=MiniMax + ANTHROPIC_API_KEY → adapter
* crashes before /registry/register → WORKSPACE_PROVISION_FAILED.
*
* Fix: pre-deploy modal back-derives provider from initialModel and pins
* the selector to the matching vendor. The dropdown UI (replacing the
* old radios in PR shipped 2026-05-02) keeps the same invariant.
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { MissingKeysModal, providerIdForModel } from "../MissingKeysModal";
import { buildProviderCatalog } from "../ProviderModelSelector";
import type { ModelSpec, ProviderChoice } from "@/lib/deploy-preflight";
vi.mock("@/lib/api", () => ({
api: { get: vi.fn(), put: vi.fn() },
}));
vi.mock("@/lib/deploy-preflight", async () => {
const actual = await vi.importActual<typeof import("@/lib/deploy-preflight")>(
"@/lib/deploy-preflight",
);
return actual;
});
// Hermes-shaped fixture: 3 providers, multiple models per provider, one
// "no required_env" local model that should never block a deploy.
const HERMES_PROVIDERS: ProviderChoice[] = [
{
id: "ANTHROPIC_API_KEY",
label: "Anthropic (8 models)",
envVars: ["ANTHROPIC_API_KEY"],
},
{
id: "MINIMAX_API_KEY",
label: "MiniMax (2 models)",
envVars: ["MINIMAX_API_KEY"],
},
{
id: "OPENROUTER_API_KEY",
label: "OpenRouter (14 models)",
envVars: ["OPENROUTER_API_KEY"],
},
];
const HERMES_MODELS: ModelSpec[] = [
{ id: "claude-sonnet-4-6", required_env: ["ANTHROPIC_API_KEY"] },
{ id: "claude-opus-4-7", required_env: ["ANTHROPIC_API_KEY"] },
{ id: "MiniMax-M2.7-highspeed", required_env: ["MINIMAX_API_KEY"] },
{ id: "MiniMax-M2.7", required_env: ["MINIMAX_API_KEY"] },
{ id: "openrouter/anthropic/claude-3.5-sonnet", required_env: ["OPENROUTER_API_KEY"] },
// Local/self-hosted endpoint — no required_env. Picker should
// never snap on this one because there's no provider to snap to.
{ id: "local-llama3", required_env: [] },
];
/** Resolve the selector option-value for a given vendor against the
* vendor-aware catalog. Catalog ids are `${vendor}|${sortedEnv}`, so
* test code shouldn't hard-code them. */
function providerIdForVendor(vendor: string): string {
const catalog = buildProviderCatalog(HERMES_MODELS);
const entry = catalog.find((p) => p.vendor === vendor);
if (!entry) throw new Error(`vendor "${vendor}" not in catalog`);
return entry.id;
}
describe("providerIdForModel (legacy helper, still exported for tests)", () => {
it("returns the provider id (sorted+joined required_env) for a known model", () => {
expect(providerIdForModel("MiniMax-M2.7-highspeed", HERMES_MODELS)).toBe(
"MINIMAX_API_KEY",
);
expect(providerIdForModel("claude-opus-4-7", HERMES_MODELS)).toBe(
"ANTHROPIC_API_KEY",
);
});
it("sorts required_env so the id matches providersFromTemplate's formula", () => {
const models: ModelSpec[] = [
{ id: "weird", required_env: ["Z_KEY", "A_KEY"] },
];
expect(providerIdForModel("weird", models)).toBe("A_KEY|Z_KEY");
});
it("trims whitespace before lookup so a stray space doesn't miss a match", () => {
expect(providerIdForModel(" MiniMax-M2.7 ", HERMES_MODELS)).toBe(
"MINIMAX_API_KEY",
);
});
it("returns null for empty / undefined / whitespace-only model id", () => {
expect(providerIdForModel("", HERMES_MODELS)).toBeNull();
expect(providerIdForModel(" ", HERMES_MODELS)).toBeNull();
});
it("returns null when models are not provided (free-text mode)", () => {
expect(providerIdForModel("anything", undefined)).toBeNull();
});
it("returns null when model isn't in the registry (free-text)", () => {
expect(providerIdForModel("not-a-listed-model", HERMES_MODELS)).toBeNull();
});
it("returns null when the model has no required_env (local endpoint)", () => {
expect(providerIdForModel("local-llama3", HERMES_MODELS)).toBeNull();
});
});
describe("ProviderPickerModal — model→provider cascade (dropdown UI)", () => {
afterEach(() => cleanup());
// The headline bug: opening the modal with the MiniMax default
// pre-filled should NOT leave the selector on Anthropic just because
// Anthropic was first in providers[]. Back-derivation snaps it on
// first paint to the MiniMax vendor entry.
it("snaps provider selector to MiniMax when initialModel is a MiniMax model", () => {
render(
<MissingKeysModal
open
missingKeys={["ANTHROPIC_API_KEY", "MINIMAX_API_KEY", "OPENROUTER_API_KEY"]}
providers={HERMES_PROVIDERS}
runtime="hermes"
modelSuggestions={HERMES_MODELS.map((m) => m.id)}
models={HERMES_MODELS}
initialModel="MiniMax-M2.7-highspeed"
onKeysAdded={vi.fn()}
onCancel={vi.fn()}
/>,
);
const providerSelect = screen.getByTestId("provider-select") as HTMLSelectElement;
expect(providerSelect.value).toBe(providerIdForVendor("minimax"));
// The env-var input underneath should be for MINIMAX_API_KEY,
// not ANTHROPIC_API_KEY — that's the load-bearing UX win. The
// entry uses a password input with a fixed "sk-..." placeholder
// when the key name contains "API_KEY"; assert exactly ONE such
// input exists, which proves only the selected provider's envVars
// were rendered into entries[].
const apiKeyInputs = screen.getAllByPlaceholderText("sk-...");
expect(apiKeyInputs).toHaveLength(1);
});
// Mid-flow change: user starts with the pre-filled MiniMax model and
// switches the provider dropdown to Anthropic. Env-var rows below
// re-render to show ANTHROPIC_API_KEY only. Same shape-pin as above.
it("re-renders credential entries when provider is switched", () => {
render(
<MissingKeysModal
open
missingKeys={["ANTHROPIC_API_KEY", "MINIMAX_API_KEY", "OPENROUTER_API_KEY"]}
providers={HERMES_PROVIDERS}
runtime="hermes"
modelSuggestions={HERMES_MODELS.map((m) => m.id)}
models={HERMES_MODELS}
initialModel="MiniMax-M2.7-highspeed"
onKeysAdded={vi.fn()}
onCancel={vi.fn()}
/>,
);
const providerSelect = screen.getByTestId("provider-select") as HTMLSelectElement;
fireEvent.change(providerSelect, {
target: { value: providerIdForVendor("anthropic") },
});
expect(providerSelect.value).toBe(providerIdForVendor("anthropic"));
// Exactly one password input means only the selected provider's
// envVars landed in entries[].
expect(screen.getAllByPlaceholderText("sk-...")).toHaveLength(1);
});
// Backwards-compat: callers that don't pass `models` (legacy
// call sites) fall back to a synthesized catalog from `providers`
// — selector still works, but vendor split is degraded to env-tuple
// grouping (one entry per ProviderChoice).
it("falls back to providers[] when models prop is omitted", () => {
render(
<MissingKeysModal
open
missingKeys={["ANTHROPIC_API_KEY", "MINIMAX_API_KEY", "OPENROUTER_API_KEY"]}
providers={HERMES_PROVIDERS}
runtime="hermes"
modelSuggestions={HERMES_MODELS.map((m) => m.id)}
// models intentionally omitted — legacy caller shape.
onKeysAdded={vi.fn()}
onCancel={vi.fn()}
/>,
);
// Without `models`, no back-derivation: selector defaults to
// providers[0] (Anthropic). Dropdown still populated with all 3
// entries — synthesized catalog uses `${vendor}|${envTuple}` ids
// (matching the selector's own catalog shape), so the value is
// "anthropic|ANTHROPIC_API_KEY", not the raw "ANTHROPIC_API_KEY".
const providerSelect = screen.getByTestId("provider-select") as HTMLSelectElement;
expect(providerSelect.value).toBe("anthropic|ANTHROPIC_API_KEY");
expect(providerSelect.options.length).toBeGreaterThanOrEqual(4); // 3 providers + the disabled placeholder
});
// configuredKeys interaction: when a provider's keys are already
// saved globally, the picker pre-selects that satisfied provider.
// BUT the model-derived snap still wins — the user explicitly
// picked a model, that intent overrides "you already have this key".
it("model-derived selection beats configuredKeys-satisfied default", () => {
render(
<MissingKeysModal
open
missingKeys={["ANTHROPIC_API_KEY", "MINIMAX_API_KEY", "OPENROUTER_API_KEY"]}
providers={HERMES_PROVIDERS}
runtime="hermes"
// User has Anthropic globally. Without back-derivation,
// selector would land on Anthropic. WITH it, the typed
// MiniMax model wins.
configuredKeys={new Set(["ANTHROPIC_API_KEY"])}
modelSuggestions={HERMES_MODELS.map((m) => m.id)}
models={HERMES_MODELS}
initialModel="MiniMax-M2.7-highspeed"
onKeysAdded={vi.fn()}
onCancel={vi.fn()}
/>,
);
const providerSelect = screen.getByTestId("provider-select") as HTMLSelectElement;
expect(providerSelect.value).toBe(providerIdForVendor("minimax"));
});
});
@@ -0,0 +1,294 @@
// @vitest-environment jsdom
/**
* ProviderModelSelector — vendor detection + dropdown cascade.
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import {
ProviderModelSelector,
buildProviderCatalog,
inferVendor,
findProviderForModel,
type SelectorModel,
type SelectorValue,
} from "../ProviderModelSelector";
afterEach(() => cleanup());
// Fixture mirrors the real claude-code-default config.yaml — covers
// the env-collision scenario (9 models share ANTHROPIC_AUTH_TOKEN
// but represent 4 distinct vendors).
const CLAUDE_CODE_MODELS: SelectorModel[] = [
{ id: "sonnet", name: "Claude Sonnet (OAuth)", required_env: ["CLAUDE_CODE_OAUTH_TOKEN"] },
{ id: "opus", name: "Claude Opus (OAuth)", required_env: ["CLAUDE_CODE_OAUTH_TOKEN"] },
{ id: "haiku", name: "Claude Haiku (OAuth)", required_env: ["CLAUDE_CODE_OAUTH_TOKEN"] },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (API)", required_env: ["ANTHROPIC_API_KEY"] },
{ id: "claude-opus-4-7", name: "Claude Opus 4.7 (API)", required_env: ["ANTHROPIC_API_KEY"] },
{ id: "mimo-v2-flash", name: "Xiaomi MiMo Flash", required_env: ["ANTHROPIC_API_KEY"] },
{ id: "mimo-v2-pro", name: "Xiaomi MiMo Pro", required_env: ["ANTHROPIC_API_KEY"] },
{ id: "MiniMax-M2", name: "MiniMax M2", required_env: ["ANTHROPIC_AUTH_TOKEN"] },
{ id: "MiniMax-M2.7", name: "MiniMax M2.7", required_env: ["ANTHROPIC_AUTH_TOKEN"] },
{ id: "GLM-4.6", name: "Z.ai GLM-4.6", required_env: ["ANTHROPIC_AUTH_TOKEN"] },
{ id: "kimi-k2", name: "Moonshot Kimi K2", required_env: ["ANTHROPIC_AUTH_TOKEN"] },
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", required_env: ["ANTHROPIC_AUTH_TOKEN"] },
];
const HERMES_MODELS: SelectorModel[] = [
{ id: "nousresearch/hermes-4-70b", name: "Hermes 4 70B", required_env: ["HERMES_API_KEY"] },
{ id: "anthropic/claude-sonnet-4-5", name: "Claude Sonnet (direct)", required_env: ["ANTHROPIC_API_KEY"] },
{ id: "openai/gpt-5", name: "GPT-5 via OR", required_env: ["OPENROUTER_API_KEY"] },
{ id: "huggingface/*", name: "Any HF model", required_env: ["HF_TOKEN"] },
{ id: "openrouter/*", name: "Any OpenRouter model", required_env: ["OPENROUTER_API_KEY"] },
{ id: "custom/*", name: "Self-hosted endpoint", required_env: [] },
];
describe("inferVendor", () => {
it("uses slash prefix when present", () => {
expect(inferVendor({ id: "nousresearch/hermes-4-70b", required_env: ["HERMES_API_KEY"] }))
.toBe("nousresearch");
expect(inferVendor({ id: "anthropic/claude-sonnet-4-5", required_env: ["ANTHROPIC_API_KEY"] }))
.toBe("anthropic");
expect(inferVendor({ id: "openai/gpt-5", required_env: ["OPENROUTER_API_KEY"] }))
.toBe("openai");
});
it("infers vendor from bare-id pattern when no slash", () => {
expect(inferVendor({ id: "MiniMax-M2.7", required_env: ["ANTHROPIC_AUTH_TOKEN"] })).toBe("minimax");
expect(inferVendor({ id: "GLM-4.6", required_env: ["ANTHROPIC_AUTH_TOKEN"] })).toBe("zai");
expect(inferVendor({ id: "kimi-k2", required_env: ["ANTHROPIC_AUTH_TOKEN"] })).toBe("moonshot");
expect(inferVendor({ id: "deepseek-v4-pro", required_env: ["ANTHROPIC_AUTH_TOKEN"] })).toBe("deepseek");
expect(inferVendor({ id: "mimo-v2-flash", required_env: ["ANTHROPIC_API_KEY"] })).toBe("xiaomi-mimo");
expect(inferVendor({ id: "claude-sonnet-4-6", required_env: ["ANTHROPIC_API_KEY"] })).toBe("anthropic");
});
it("treats bare sonnet/opus/haiku as anthropic-oauth ONLY when env demands OAuth", () => {
expect(inferVendor({ id: "sonnet", required_env: ["CLAUDE_CODE_OAUTH_TOKEN"] }))
.toBe("anthropic-oauth");
expect(inferVendor({ id: "opus", required_env: ["CLAUDE_CODE_OAUTH_TOKEN"] }))
.toBe("anthropic-oauth");
// Hypothetical sonnet alias against API key — must NOT be tagged OAuth.
expect(inferVendor({ id: "sonnet", required_env: ["ANTHROPIC_API_KEY"] }))
.toBe("anthropic");
});
it("falls back to env namespace for unknown vendors", () => {
expect(inferVendor({ id: "unknown-id", required_env: ["OPENROUTER_API_KEY"] }))
.toBe("openrouter");
expect(inferVendor({ id: "unknown-id", required_env: ["HERMES_API_KEY"] }))
.toBe("hermes");
});
});
describe("buildProviderCatalog", () => {
it("splits ANTHROPIC_AUTH_TOKEN models by vendor (not just env)", () => {
const catalog = buildProviderCatalog(CLAUDE_CODE_MODELS);
const vendors = catalog.map((p) => p.vendor).sort();
// The 4 third-party vendors that share ANTHROPIC_AUTH_TOKEN must
// all appear as separate entries.
expect(vendors).toContain("minimax");
expect(vendors).toContain("zai");
expect(vendors).toContain("moonshot");
expect(vendors).toContain("deepseek");
// Plus the OAuth, Anthropic API, and Xiaomi MiMo entries.
expect(vendors).toContain("anthropic-oauth");
expect(vendors).toContain("anthropic");
expect(vendors).toContain("xiaomi-mimo");
});
it("buckets models under the correct vendor", () => {
const catalog = buildProviderCatalog(CLAUDE_CODE_MODELS);
const minimax = catalog.find((p) => p.vendor === "minimax");
expect(minimax).toBeDefined();
expect(minimax!.models.map((m) => m.id).sort()).toEqual(["MiniMax-M2", "MiniMax-M2.7"]);
const oauth = catalog.find((p) => p.vendor === "anthropic-oauth");
expect(oauth!.models.map((m) => m.id).sort()).toEqual(["haiku", "opus", "sonnet"]);
});
it("flags wildcard providers", () => {
const catalog = buildProviderCatalog(HERMES_MODELS);
const hf = catalog.find((p) => p.vendor === "huggingface");
expect(hf?.wildcard).toBe(true);
const custom = catalog.find((p) => p.vendor === "custom");
expect(custom?.wildcard).toBe(true);
const nous = catalog.find((p) => p.vendor === "nousresearch");
expect(nous?.wildcard).toBe(false);
});
it("decorates label with model count when ≥2 concrete models", () => {
const catalog = buildProviderCatalog(CLAUDE_CODE_MODELS);
const oauth = catalog.find((p) => p.vendor === "anthropic-oauth");
expect(oauth?.label).toMatch(/3 models/);
// Wildcard buckets don't get the count suffix.
const hfCatalog = buildProviderCatalog(HERMES_MODELS);
const hf = hfCatalog.find((p) => p.vendor === "huggingface");
expect(hf?.label).not.toMatch(/models\)/);
});
});
describe("findProviderForModel", () => {
const catalog = buildProviderCatalog(HERMES_MODELS);
it("matches concrete model ids directly", () => {
expect(findProviderForModel(catalog, "nousresearch/hermes-4-70b")?.vendor)
.toBe("nousresearch");
expect(findProviderForModel(catalog, "openai/gpt-5")?.vendor).toBe("openai");
});
it("matches wildcard providers by prefix", () => {
expect(findProviderForModel(catalog, "huggingface/meta-llama/Meta-Llama-3-70B")?.vendor)
.toBe("huggingface");
expect(findProviderForModel(catalog, "openrouter/anthropic/claude-3.5-sonnet")?.vendor)
.toBe("openrouter");
expect(findProviderForModel(catalog, "custom/local-vllm")?.vendor).toBe("custom");
});
it("returns null on no match", () => {
expect(findProviderForModel(catalog, "")).toBeNull();
expect(findProviderForModel(catalog, "unknown-model-xyz")).toBeNull();
});
});
// -----------------------------------------------------------------------------
// Component behavior
// -----------------------------------------------------------------------------
function setup(overrides?: Partial<{ value: SelectorValue; models: SelectorModel[]; onChange: (v: SelectorValue) => void }>) {
const onChange = overrides?.onChange ?? vi.fn();
const value: SelectorValue = overrides?.value ?? { providerId: "", model: "", envVars: [] };
render(
<ProviderModelSelector
models={overrides?.models ?? CLAUDE_CODE_MODELS}
value={value}
onChange={onChange}
/>,
);
return { onChange };
}
describe("<ProviderModelSelector>", () => {
it("renders provider dropdown with all vendor options", () => {
setup();
const select = screen.getByTestId("provider-select") as HTMLSelectElement;
const optionTexts = Array.from(select.options).map((o) => o.text);
expect(optionTexts).toContain("Claude Code subscription (3 models)");
expect(optionTexts.some((t) => t.startsWith("MiniMax"))).toBe(true);
expect(optionTexts.some((t) => t.startsWith("Z.ai"))).toBe(true);
});
it("model dropdown is disabled until provider is picked", () => {
setup();
const modelSelect = screen.getByTestId("model-select") as HTMLSelectElement;
expect(modelSelect.disabled).toBe(true);
});
it("picking a multi-model provider emits onChange with empty model (forces explicit pick)", () => {
const { onChange } = setup();
const providerSelect = screen.getByTestId("provider-select");
const catalog = buildProviderCatalog(CLAUDE_CODE_MODELS);
const minimax = catalog.find((p) => p.vendor === "minimax")!;
// MiniMax bucket holds 2 models (MiniMax-M2 + MiniMax-M2.7). Auto-
// picking the first one used to bite a real user (2026-05-03):
// they wanted M2.7 but the silent default put M2 in the deploy
// payload. Now the model field must come back empty so the next
// dropdown is required-empty and Save/Deploy stay disabled until
// the user picks.
fireEvent.change(providerSelect, { target: { value: minimax.id } });
expect(onChange).toHaveBeenCalledWith({
providerId: minimax.id,
model: "",
envVars: ["ANTHROPIC_AUTH_TOKEN"],
});
});
it("picking a single-model provider auto-fills the model (no choice to make)", () => {
const { onChange } = setup();
const providerSelect = screen.getByTestId("provider-select");
const catalog = buildProviderCatalog(CLAUDE_CODE_MODELS);
// GLM-4.6 is the only model under the zai vendor in the fixture —
// a "0 vs many" boundary check. With only one option, forcing the
// user to re-pick adds friction without preventing any error.
const zai = catalog.find((p) => p.vendor === "zai")!;
expect(zai.models.length).toBe(1);
fireEvent.change(providerSelect, { target: { value: zai.id } });
expect(onChange).toHaveBeenCalledWith({
providerId: zai.id,
model: "GLM-4.6",
envVars: ["ANTHROPIC_AUTH_TOKEN"],
});
});
it("picking provider then model emits combined value", () => {
const catalog = buildProviderCatalog(CLAUDE_CODE_MODELS);
const minimax = catalog.find((p) => p.vendor === "minimax")!;
const onChange = vi.fn();
setup({
value: { providerId: minimax.id, model: "MiniMax-M2", envVars: ["ANTHROPIC_AUTH_TOKEN"] },
onChange,
});
const modelSelect = screen.getByTestId("model-select");
fireEvent.change(modelSelect, { target: { value: "MiniMax-M2.7" } });
expect(onChange).toHaveBeenCalledWith({
providerId: minimax.id,
model: "MiniMax-M2.7",
envVars: ["ANTHROPIC_AUTH_TOKEN"],
});
});
it("wildcard provider switches model UI to free-text input", () => {
const catalog = buildProviderCatalog(HERMES_MODELS);
const hf = catalog.find((p) => p.vendor === "huggingface")!;
setup({
models: HERMES_MODELS,
value: { providerId: hf.id, model: "", envVars: hf.envVars },
});
expect(screen.queryByTestId("model-select")).toBeNull();
expect(screen.queryByTestId("model-input")).not.toBeNull();
});
it("wildcard input emits typed value as model", () => {
const catalog = buildProviderCatalog(HERMES_MODELS);
const openrouter = catalog.find((p) => p.vendor === "openrouter")!;
const onChange = vi.fn();
setup({
models: HERMES_MODELS,
value: { providerId: openrouter.id, model: "", envVars: openrouter.envVars },
onChange,
});
const input = screen.getByTestId("model-input");
fireEvent.change(input, { target: { value: "openrouter/anthropic/claude-3.5-sonnet" } });
expect(onChange).toHaveBeenCalledWith({
providerId: openrouter.id,
model: "openrouter/anthropic/claude-3.5-sonnet",
envVars: ["OPENROUTER_API_KEY"],
});
});
it("renders required env hint for selected provider", () => {
const catalog = buildProviderCatalog(CLAUDE_CODE_MODELS);
const oauth = catalog.find((p) => p.vendor === "anthropic-oauth")!;
setup({
value: { providerId: oauth.id, model: "sonnet", envVars: oauth.envVars },
});
expect(screen.getByText(/requires:/).textContent).toMatch(/CLAUDE_CODE_OAUTH_TOKEN/);
});
it("switching to a multi-model provider clears the stale model id", () => {
const catalog = buildProviderCatalog(CLAUDE_CODE_MODELS);
const oauth = catalog.find((p) => p.vendor === "anthropic-oauth")!;
const minimax = catalog.find((p) => p.vendor === "minimax")!;
const onChange = vi.fn();
setup({
value: { providerId: oauth.id, model: "sonnet", envVars: oauth.envVars },
onChange,
});
fireEvent.change(screen.getByTestId("provider-select"), { target: { value: minimax.id } });
// Empty rather than auto-picked — see "picking a multi-model
// provider …" test above for the user-facing rationale.
expect(onChange).toHaveBeenCalledWith({
providerId: minimax.id,
model: "",
envVars: ["ANTHROPIC_AUTH_TOKEN"],
});
});
});
@@ -155,18 +155,31 @@ describe("SearchDialog — keyboard accessibility", () => {
expect(selectNode).not.toHaveBeenCalled();
});
it("typing a new query resets focusedIndex to -1", () => {
it("typing a query that matches auto-highlights the first result", () => {
// Replaces the older "resets to -1" assertion. New behavior: a query
// with at least one match pins the highlight to row 0 so Enter picks
// a result instead of being a no-op. Empty-query case is covered by
// "Enter at focusedIndex=-1 does not select anything" above.
render(<SearchDialog />);
const input = screen.getByRole("combobox");
fireEvent.change(input, { target: { value: "Alpha" } });
const options = screen.getAllByRole("option");
expect(options[0].getAttribute("aria-selected")).toBe("true");
// Enter on the auto-highlighted match should select it without
// needing a manual ArrowDown first.
fireEvent.keyDown(input, { key: "Enter" });
expect(selectNode).toHaveBeenCalledWith("ws-1");
});
it("typing a query that matches NOTHING resets focusedIndex to -1", () => {
render(<SearchDialog />);
const input = screen.getByRole("combobox");
fireEvent.keyDown(input, { key: "ArrowDown" }); // focusedIndex → 0
// Verify selection before reset
expect(screen.getAllByRole("option")[0].getAttribute("aria-selected")).toBe("true");
// Change query — triggers the useEffect that resets focusedIndex
fireEvent.change(input, { target: { value: "Alpha" } });
// After reset all options must have aria-selected="false"
screen.getAllByRole("option").forEach((opt) => {
expect(opt.getAttribute("aria-selected")).toBe("false");
});
fireEvent.change(input, { target: { value: "zzz-no-match" } });
// No options remain, so nothing to assert on aria-selected directly —
// the empty-state message takes over. But Enter should be a no-op.
fireEvent.keyDown(input, { key: "Enter" });
expect(selectNode).not.toHaveBeenCalled();
});
it("aria-activedescendant matches the focused option's id", () => {
@@ -0,0 +1,82 @@
// @vitest-environment jsdom
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { Toaster, showToast } from "../Toaster";
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
describe("Toaster keyboard a11y", () => {
it("Esc dismisses the most recent toast", () => {
render(<Toaster />);
act(() => {
showToast("first", "info");
showToast("second", "info");
});
expect(screen.getByText("first")).toBeTruthy();
expect(screen.getByText("second")).toBeTruthy();
act(() => {
fireEvent.keyDown(window, { key: "Escape" });
});
expect(screen.queryByText("second")).toBeNull();
expect(screen.getByText("first")).toBeTruthy();
});
it("Esc dismisses persistent error toasts", () => {
render(<Toaster />);
act(() => {
showToast("boom", "error");
});
expect(screen.getByText("boom")).toBeTruthy();
act(() => {
fireEvent.keyDown(window, { key: "Escape" });
});
expect(screen.queryByText("boom")).toBeNull();
});
it("Esc with no toasts is a no-op", () => {
render(<Toaster />);
act(() => {
fireEvent.keyDown(window, { key: "Escape" });
});
// no throw, nothing rendered
expect(screen.queryAllByRole("button", { name: "Dismiss notification" })).toHaveLength(0);
});
it("dismiss button has accessible label and is keyboard reachable", () => {
render(<Toaster />);
act(() => {
showToast("hi", "info");
});
const btn = screen.getByRole("button", { name: "Dismiss notification" });
expect(btn).toBeTruthy();
// Native <button> defaults to keyboard-focusable; explicit assertion guards
// against a future regression where someone adds tabindex=-1.
expect(btn.getAttribute("tabindex")).not.toBe("-1");
});
it("dismiss button click removes that specific toast", () => {
render(<Toaster />);
act(() => {
showToast("a", "info");
showToast("b", "info");
});
const buttons = screen.getAllByRole("button", { name: "Dismiss notification" });
expect(buttons).toHaveLength(2);
// Click the first dismiss → "a" goes away, "b" stays
act(() => {
fireEvent.click(buttons[0]);
});
expect(screen.queryByText("a")).toBeNull();
expect(screen.getByText("b")).toBeTruthy();
});
});
+2 -2
View File
@@ -89,7 +89,7 @@ function A2AEdgeImpl({
// The edge stroke color matches what buildA2AEdges sets on the SVG
// path style. Mirror it on the badge border so the visual identity
// (hot=violet vs warm=blue) carries to the clickable label.
const accent = isHot ? "border-violet-500/60" : "border-blue-500/60";
const accent = isHot ? "border-violet-500/60" : "border-accent/60";
const accentText = isHot ? "text-violet-200" : "text-blue-200";
const ariaLabel = `${count} delegation${count === 1 ? "" : "s"} from ${
edgeData.label?.split(" · ")[1] ?? "recent"
@@ -119,7 +119,7 @@ function A2AEdgeImpl({
onClick={handleClick}
aria-label={ariaLabel}
title="Open source workspace's activity feed"
className={`px-2 py-0.5 rounded-full bg-zinc-900/95 border ${accent} ${accentText} text-[10px] font-medium shadow-md shadow-black/40 backdrop-blur-sm hover:bg-zinc-800 hover:border-opacity-100 transition-colors cursor-pointer`}
className={`px-2 py-0.5 rounded-full bg-surface-sunken/95 border ${accent} ${accentText} text-[10px] font-medium shadow-md shadow-black/40 backdrop-blur-sm hover:bg-surface-card hover:border-opacity-100 transition-colors cursor-pointer`}
>
{labelText}
</button>
@@ -112,10 +112,10 @@ export function OrgCancelButton({ rootId, rootName, workspaceCount }: Props) {
if (confirming) {
return (
<div
className="nodrag absolute -top-10 right-0 z-20 flex items-center gap-1.5 rounded-lg bg-zinc-900/95 px-2 py-1 shadow-lg border border-red-800/60"
className="nodrag absolute -top-10 right-0 z-20 flex items-center gap-1.5 rounded-lg bg-surface-sunken/95 px-2 py-1 shadow-lg border border-red-800/60"
onClick={(e) => e.stopPropagation()}
>
<span className="text-[10px] text-zinc-300">
<span className="text-[10px] text-ink-mid">
Delete {workspaceCount} workspace{workspaceCount === 1 ? "" : "s"}?
</span>
<button
@@ -130,7 +130,7 @@ export function OrgCancelButton({ rootId, rootName, workspaceCount }: Props) {
type="button"
onClick={() => setConfirming(false)}
disabled={submitting}
className="px-2 py-0.5 rounded bg-zinc-700/80 hover:bg-zinc-600 text-[10px] text-zinc-200"
className="px-2 py-0.5 rounded bg-surface-card/80 hover:bg-surface-card text-[10px] text-ink"
>
No
</button>
@@ -168,7 +168,10 @@ describe("A2AEdge — render", () => {
/>,
);
const btn = screen.getByRole("button");
expect(btn.className).toContain("border-blue-500/60");
// Warm-paper migration: blue-500 border was mapped to the semantic
// accent token; the text-blue-200 literal is intentionally retained
// because tinted-state pill text reads in both themes.
expect(btn.className).toContain("border-accent/60");
expect(btn.className).toContain("text-blue-200");
});
+17 -17
View File
@@ -105,11 +105,11 @@ export function OrgTokensTab() {
<div className="p-4 space-y-4">
<div>
<div className="flex items-center justify-between mb-1">
<h3 className="text-sm font-semibold text-zinc-200">
<h3 className="text-sm font-semibold text-ink">
Organization API Keys
</h3>
</div>
<p className="text-[10px] text-zinc-500 leading-relaxed">
<p className="text-[10px] text-ink-soft leading-relaxed">
Full-admin bearer tokens for this organization. Use with external
integrations, CLI tools, or AI agents that need to manage
workspaces, settings, and secrets. Each key has the same
@@ -126,12 +126,12 @@ export function OrgTokensTab() {
placeholder="Label (e.g. zapier, my-ci)"
maxLength={100}
aria-label="Organization API key label"
className="flex-1 text-[11px] bg-zinc-900/60 border border-zinc-700/50 rounded px-2 py-1.5 text-zinc-200 placeholder-zinc-600"
className="flex-1 text-[11px] bg-surface-sunken/60 border border-line/50 rounded px-2 py-1.5 text-ink placeholder-zinc-600"
/>
<button
onClick={handleCreate}
disabled={creating}
className="px-3 py-1.5 bg-blue-600/20 hover:bg-blue-600/30 border border-blue-500/30 rounded-lg text-[11px] text-blue-300 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
className="px-3 py-1.5 bg-accent-strong/20 hover:bg-accent-strong/30 border border-accent/30 rounded-lg text-[11px] text-accent font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
>
{creating ? (
<>
@@ -147,10 +147,10 @@ export function OrgTokensTab() {
{newToken && (
<div className="bg-emerald-950/30 border border-emerald-800/40 rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2">
<span className="text-[10px] text-emerald-400 font-semibold uppercase tracking-wider">
<span className="text-[10px] text-good font-semibold uppercase tracking-wider">
{newTokenName ? `New Key: ${newTokenName}` : 'New Key Created'}
</span>
<span className="text-[9px] text-emerald-500/70">
<span className="text-[9px] text-good/70">
Copy now it won't be shown again
</span>
</div>
@@ -160,14 +160,14 @@ export function OrgTokensTab() {
</code>
<button
onClick={handleCopy}
className="shrink-0 px-2 py-1.5 bg-emerald-800/40 hover:bg-emerald-700/50 border border-emerald-700/40 rounded text-[10px] text-emerald-300 transition-colors"
className="shrink-0 px-2 py-1.5 bg-emerald-800/40 hover:bg-emerald-700/50 border border-emerald-700/40 rounded text-[10px] text-good transition-colors"
>
{copied ? 'Copied' : 'Copy'}
</button>
</div>
<button
onClick={() => setNewToken(null)}
className="text-[9px] text-emerald-500/60 hover:text-emerald-400 transition-colors"
className="text-[9px] text-good/60 hover:text-good transition-colors"
>
Dismiss
</button>
@@ -175,20 +175,20 @@ export function OrgTokensTab() {
)}
{error && (
<div className="px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-[10px] text-red-400">
<div className="px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-[10px] text-bad">
{error}
</div>
)}
{/* Token list */}
{loading ? (
<div className="flex items-center justify-center gap-2 py-6 text-zinc-500 text-xs">
<div role="status" aria-live="polite" className="flex items-center justify-center gap-2 py-6 text-ink-soft text-xs">
<Spinner /> Loading keys...
</div>
) : tokens.length === 0 ? (
<div className="text-center py-6">
<p className="text-xs text-zinc-500">No active keys</p>
<p className="text-[10px] text-zinc-600 mt-1">
<p className="text-xs text-ink-soft">No active keys</p>
<p className="text-[10px] text-ink-soft mt-1">
Create a key above to authenticate API calls to this organization.
</p>
</div>
@@ -197,19 +197,19 @@ export function OrgTokensTab() {
{tokens.map((t) => (
<div
key={t.id}
className="flex items-center justify-between bg-zinc-800/40 border border-zinc-700/30 rounded-lg px-3 py-2"
className="flex items-center justify-between bg-surface-card/40 border border-line/30 rounded-lg px-3 py-2"
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<code className="text-[11px] font-mono text-zinc-300 bg-zinc-900/60 px-1.5 py-0.5 rounded shrink-0">
<code className="text-[11px] font-mono text-ink-mid bg-surface-sunken/60 px-1.5 py-0.5 rounded shrink-0">
{t.prefix}...
</code>
<div className="flex flex-col min-w-0">
{t.name && (
<span className="text-[11px] text-zinc-200 truncate">
<span className="text-[11px] text-ink truncate">
{t.name}
</span>
)}
<div className="text-[9px] text-zinc-500 space-x-3">
<div className="text-[9px] text-ink-soft space-x-3">
<span>Created {formatAge(t.created_at)}</span>
{t.last_used_at && (
<span>Last used {formatAge(t.last_used_at)}</span>
@@ -219,7 +219,7 @@ export function OrgTokensTab() {
</div>
<button
onClick={() => setRevokeTarget(t)}
className="text-[10px] text-red-400/70 hover:text-red-400 transition-colors px-2 py-1 shrink-0"
className="text-[10px] text-bad/70 hover:text-bad transition-colors px-2 py-1 shrink-0"
>
Revoke
</button>
+15 -15
View File
@@ -80,15 +80,15 @@ export function TokensTab({ workspaceId }: TokensTabProps) {
<div className="p-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-zinc-200">API Tokens</h3>
<p className="text-[10px] text-zinc-500 mt-0.5">
<h3 className="text-sm font-semibold text-ink">API Tokens</h3>
<p className="text-[10px] text-ink-soft mt-0.5">
Bearer tokens for authenticating API calls to this workspace.
</p>
</div>
<button
onClick={handleCreate}
disabled={creating}
className="px-3 py-1.5 bg-blue-600/20 hover:bg-blue-600/30 border border-blue-500/30 rounded-lg text-[11px] text-blue-300 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
className="px-3 py-1.5 bg-accent-strong/20 hover:bg-accent-strong/30 border border-accent/30 rounded-lg text-[11px] text-accent font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
>
{creating ? <><Spinner size="sm" /> Creating...</> : '+ New Token'}
</button>
@@ -98,8 +98,8 @@ export function TokensTab({ workspaceId }: TokensTabProps) {
{newToken && (
<div className="bg-emerald-950/30 border border-emerald-800/40 rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2">
<span className="text-[10px] text-emerald-400 font-semibold uppercase tracking-wider">New Token Created</span>
<span className="text-[9px] text-emerald-500/70">Copy now it won't be shown again</span>
<span className="text-[10px] text-good font-semibold uppercase tracking-wider">New Token Created</span>
<span className="text-[9px] text-good/70">Copy now it won't be shown again</span>
</div>
<div className="flex items-center gap-2">
<code className="flex-1 text-[11px] text-emerald-200 bg-emerald-950/50 px-2 py-1.5 rounded font-mono break-all select-all">
@@ -107,14 +107,14 @@ export function TokensTab({ workspaceId }: TokensTabProps) {
</code>
<button
onClick={handleCopy}
className="shrink-0 px-2 py-1.5 bg-emerald-800/40 hover:bg-emerald-700/50 border border-emerald-700/40 rounded text-[10px] text-emerald-300 transition-colors"
className="shrink-0 px-2 py-1.5 bg-emerald-800/40 hover:bg-emerald-700/50 border border-emerald-700/40 rounded text-[10px] text-good transition-colors"
>
{copied ? 'Copied' : 'Copy'}
</button>
</div>
<button
onClick={() => setNewToken(null)}
className="text-[9px] text-emerald-500/60 hover:text-emerald-400 transition-colors"
className="text-[9px] text-good/60 hover:text-good transition-colors"
>
Dismiss
</button>
@@ -122,20 +122,20 @@ export function TokensTab({ workspaceId }: TokensTabProps) {
)}
{error && (
<div className="px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-[10px] text-red-400">
<div className="px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-[10px] text-bad">
{error}
</div>
)}
{/* Token list */}
{loading ? (
<div className="flex items-center justify-center gap-2 py-6 text-zinc-500 text-xs">
<div role="status" aria-live="polite" className="flex items-center justify-center gap-2 py-6 text-ink-soft text-xs">
<Spinner /> Loading tokens...
</div>
) : tokens.length === 0 ? (
<div className="text-center py-6">
<p className="text-xs text-zinc-500">No active tokens</p>
<p className="text-[10px] text-zinc-600 mt-1">
<p className="text-xs text-ink-soft">No active tokens</p>
<p className="text-[10px] text-ink-soft mt-1">
Create a token to authenticate API calls.
</p>
</div>
@@ -144,13 +144,13 @@ export function TokensTab({ workspaceId }: TokensTabProps) {
{tokens.map((t) => (
<div
key={t.id}
className="flex items-center justify-between bg-zinc-800/40 border border-zinc-700/30 rounded-lg px-3 py-2"
className="flex items-center justify-between bg-surface-card/40 border border-line/30 rounded-lg px-3 py-2"
>
<div className="flex items-center gap-3 min-w-0">
<code className="text-[11px] font-mono text-zinc-300 bg-zinc-900/60 px-1.5 py-0.5 rounded">
<code className="text-[11px] font-mono text-ink-mid bg-surface-sunken/60 px-1.5 py-0.5 rounded">
{t.prefix}...
</code>
<div className="text-[9px] text-zinc-500 space-x-3">
<div className="text-[9px] text-ink-soft space-x-3">
<span>Created {formatAge(t.created_at)}</span>
{t.last_used_at && (
<span>Last used {formatAge(t.last_used_at)}</span>
@@ -159,7 +159,7 @@ export function TokensTab({ workspaceId }: TokensTabProps) {
</div>
<button
onClick={() => setRevokeTarget(t)}
className="text-[10px] text-red-400/70 hover:text-red-400 transition-colors px-2 py-1"
className="text-[10px] text-bad/70 hover:text-bad transition-colors px-2 py-1"
>
Revoke
</button>
+43 -40
View File
@@ -24,18 +24,18 @@ const FILTERS: { id: FilterType; label: string; icon: string }[] = [
];
const TYPE_COLORS: Record<string, { text: string; bg: string; border: string }> = {
a2a_receive: { text: "text-blue-400", bg: "bg-blue-950/30", border: "border-blue-800/30" },
a2a_receive: { text: "text-accent", bg: "bg-blue-950/30", border: "border-blue-800/30" },
a2a_send: { text: "text-cyan-400", bg: "bg-cyan-950/30", border: "border-cyan-800/30" },
task_update: { text: "text-amber-400", bg: "bg-amber-950/30", border: "border-amber-800/30" },
task_update: { text: "text-warm", bg: "bg-amber-950/30", border: "border-amber-800/30" },
skill_promotion: { text: "text-violet-300", bg: "bg-violet-950/30", border: "border-violet-800/30" },
agent_log: { text: "text-zinc-400", bg: "bg-zinc-800/30", border: "border-zinc-700/30" },
error: { text: "text-red-400", bg: "bg-red-950/30", border: "border-red-800/30" },
agent_log: { text: "text-ink-mid", bg: "bg-surface-card/30", border: "border-line/30" },
error: { text: "text-bad", bg: "bg-red-950/30", border: "border-red-800/30" },
};
const STATUS_ICONS: Record<string, { icon: string; color: string }> = {
ok: { icon: "✓", color: "text-emerald-400" },
error: { icon: "✕", color: "text-red-400" },
timeout: { icon: "⏱", color: "text-amber-400" },
ok: { icon: "✓", color: "text-good" },
error: { icon: "✕", color: "text-bad" },
timeout: { icon: "⏱", color: "text-warm" },
};
export function ActivityTab({ workspaceId }: Props) {
@@ -75,7 +75,7 @@ export function ActivityTab({ workspaceId }: Props) {
return (
<div className="flex flex-col h-full">
{/* Filter bar */}
<div className="px-3 pt-3 pb-2 border-b border-zinc-800/40">
<div className="px-3 pt-3 pb-2 border-b border-line/40">
<div className="flex items-center gap-1 flex-wrap">
{FILTERS.map((f) => (
<button
@@ -84,8 +84,8 @@ export function ActivityTab({ workspaceId }: Props) {
aria-pressed={filter === f.id}
className={`px-2 py-1 text-[11px] rounded-md font-medium transition-all ${
filter === f.id
? "bg-zinc-700 text-zinc-100 ring-1 ring-zinc-600"
: "text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800/60"
? "bg-surface-card text-ink ring-1 ring-zinc-600"
: "text-ink-soft hover:text-ink-mid hover:bg-surface-card/60"
}`}
>
<span className="mr-0.5 opacity-60">{f.icon}</span> {f.label}
@@ -96,7 +96,7 @@ export function ActivityTab({ workspaceId }: Props) {
onClick={() => setAutoRefresh(!autoRefresh)}
aria-pressed={autoRefresh}
className={`text-[11px] px-1.5 py-0.5 rounded ${
autoRefresh ? "text-emerald-400 bg-emerald-950/30" : "text-zinc-500"
autoRefresh ? "text-good bg-emerald-950/30" : "text-ink-soft"
}`}
title={autoRefresh ? "Auto-refresh ON" : "Auto-refresh OFF"}
>
@@ -104,20 +104,23 @@ export function ActivityTab({ workspaceId }: Props) {
</button>
<button
onClick={() => setTraceOpen(true)}
className="px-2 py-1 bg-blue-900/40 hover:bg-blue-800/50 text-[11px] rounded text-blue-300 border border-blue-800/30"
className="px-2 py-1 bg-blue-900/40 hover:bg-blue-800/50 text-[11px] rounded text-accent border border-blue-800/30"
title="View full conversation trace across all workspaces"
>
Full Trace
</button>
<button
type="button"
onClick={loadActivities}
className="px-2 py-1 bg-zinc-700 hover:bg-zinc-600 text-[11px] rounded text-zinc-300"
// hover:bg-surface-card on top of itself was a no-op;
// lift to surface-elevated + focus-visible ring.
className="px-2 py-1 bg-surface-card hover:bg-surface-elevated hover:text-ink text-[11px] rounded text-ink-mid transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
>
Refresh
</button>
</div>
</div>
<div className="mt-1.5 text-[10px] text-zinc-500">
<div className="mt-1.5 text-[10px] text-ink-soft">
{activities.length} {filter === "all" ? "activities" : filter.replace("_", " ") + " entries"}
</div>
</div>
@@ -125,19 +128,19 @@ export function ActivityTab({ workspaceId }: Props) {
{/* Activity list */}
<div className="flex-1 overflow-y-auto p-3 space-y-1.5">
{loading && activities.length === 0 && (
<div className="text-xs text-zinc-500 text-center py-8">Loading activity...</div>
<div className="text-xs text-ink-soft text-center py-8">Loading activity...</div>
)}
{error && (
<div className="px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-red-400">
<div className="px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">
{error}
</div>
)}
{!loading && !error && activities.length === 0 && (
<div className="text-center py-8">
<div className="text-zinc-600 text-xs">No activity recorded yet</div>
<div className="text-zinc-700 text-[9px] mt-1">
<div className="text-ink-soft text-xs">No activity recorded yet</div>
<div className="text-ink-soft text-[9px] mt-1">
Activity logs appear when agents communicate or perform tasks
</div>
</div>
@@ -184,7 +187,7 @@ function ActivityRow({
className={`rounded-lg border transition-colors ${
isError
? "bg-red-950/20 border-red-900/30"
: "bg-zinc-800/60 border-zinc-700/40"
: "bg-surface-card/60 border-line/40"
}`}
>
<button type="button" onClick={onToggle} className="w-full text-left px-3 py-2">
@@ -195,7 +198,7 @@ function ActivityRow({
</span>
{entry.method && (
<span className="text-[10px] font-mono text-zinc-300 truncate">
<span className="text-[10px] font-mono text-ink-mid truncate">
{entry.method}
</span>
)}
@@ -205,23 +208,23 @@ function ActivityRow({
</span>
{entry.duration_ms != null && (
<span className="text-[8px] text-zinc-500 font-mono tabular-nums shrink-0">
<span className="text-[8px] text-ink-soft font-mono tabular-nums shrink-0">
{entry.duration_ms}ms
</span>
)}
<span className="text-[8px] text-zinc-500 shrink-0">
<span className="text-[8px] text-ink-soft shrink-0">
{formatTime(entry.created_at)}
</span>
<span className="text-[9px] text-zinc-500">
<span className="text-[9px] text-ink-soft">
{expanded ? "▼" : "▶"}
</span>
</div>
{/* Summary — replace raw IDs with workspace names */}
{entry.summary && (
<div className="text-[10px] text-zinc-400 mt-1 truncate">
<div className="text-[10px] text-ink-mid mt-1 truncate">
{entry.summary
.replace(entry.source_id || "", resolveName(entry.source_id))
.replace(entry.target_id || "", resolveName(entry.target_id))}
@@ -236,9 +239,9 @@ function ActivityRow({
{resolveName(entry.source_id)}
</span>
)}
<span className="text-[9px] text-zinc-500"></span>
<span className="text-[9px] text-ink-soft"></span>
{entry.target_id && (
<span className="text-[9px] text-blue-400/80 truncate max-w-[140px]" title={entry.target_id}>
<span className="text-[9px] text-accent/80 truncate max-w-[140px]" title={entry.target_id}>
{resolveName(entry.target_id)}
</span>
)}
@@ -247,7 +250,7 @@ function ActivityRow({
{/* Error detail */}
{isError && entry.error_detail && (
<div className="text-[9px] text-red-400/80 mt-1 truncate">
<div className="text-[9px] text-bad/80 mt-1 truncate">
{entry.error_detail}
</div>
)}
@@ -255,7 +258,7 @@ function ActivityRow({
{/* Expanded details */}
{expanded && (
<div className="px-3 pb-3 space-y-2 border-t border-zinc-700/30 mt-1 pt-2">
<div className="px-3 pb-3 space-y-2 border-t border-line/30 mt-1 pt-2">
{entry.source_id && (
<Detail label="Source" value={`${resolveName(entry.source_id)} (${entry.source_id.slice(0, 8)})`} />
)}
@@ -278,7 +281,7 @@ function ActivityRow({
{entry.response_body && (
<JsonBlock label="Response" data={entry.response_body} />
)}
<div className="text-[8px] text-zinc-500 font-mono select-all">
<div className="text-[8px] text-ink-soft font-mono select-all">
ID: {entry.id}
</div>
</div>
@@ -298,10 +301,10 @@ function A2AErrorPreview({ label, raw }: { label: string; raw: string }) {
const hint = inferA2AErrorHint(detail);
return (
<div>
<div className="text-[8px] text-red-400/80 uppercase tracking-wider mb-1">{label} delivery failed</div>
<div className="text-[10px] text-red-300 bg-red-950/30 border border-red-800/40 rounded p-2 space-y-1.5">
<div className="text-[8px] text-bad/80 uppercase tracking-wider mb-1">{label} delivery failed</div>
<div className="text-[10px] text-bad bg-red-950/30 border border-red-800/40 rounded p-2 space-y-1.5">
<div className="font-mono whitespace-pre-wrap break-words max-h-32 overflow-y-auto">{detail}</div>
<div className="text-[9px] text-red-300/70 leading-relaxed border-t border-red-800/30 pt-1.5">{hint}</div>
<div className="text-[9px] text-bad/70 leading-relaxed border-t border-red-800/30 pt-1.5">{hint}</div>
</div>
</div>
);
@@ -326,8 +329,8 @@ function MessagePreview({ label, body }: { label: string; body: Record<string, u
}
return (
<div>
<div className="text-[8px] text-zinc-500 uppercase tracking-wider mb-1">{label}</div>
<div className="text-[10px] text-zinc-300 bg-zinc-900/60 rounded p-2 max-h-32 overflow-y-auto whitespace-pre-wrap break-words">
<div className="text-[8px] text-ink-soft uppercase tracking-wider mb-1">{label}</div>
<div className="text-[10px] text-ink-mid bg-surface-sunken/60 rounded p-2 max-h-32 overflow-y-auto whitespace-pre-wrap break-words">
{text.slice(0, 2000)}
</div>
</div>
@@ -369,8 +372,8 @@ function MessagePreview({ label, body }: { label: string; body: Record<string, u
return (
<div>
<div className="text-[8px] text-zinc-500 uppercase tracking-wider mb-1">{label}</div>
<div className="text-[10px] text-zinc-300 bg-zinc-900/60 rounded p-2 max-h-32 overflow-y-auto whitespace-pre-wrap break-words">
<div className="text-[8px] text-ink-soft uppercase tracking-wider mb-1">{label}</div>
<div className="text-[10px] text-ink-mid bg-surface-sunken/60 rounded p-2 max-h-32 overflow-y-auto whitespace-pre-wrap break-words">
{text.slice(0, 2000)}
</div>
</div>
@@ -380,8 +383,8 @@ function MessagePreview({ label, body }: { label: string; body: Record<string, u
function Detail({ label, value, mono, error: isError }: { label: string; value: string; mono?: boolean; error?: boolean }) {
return (
<div className="flex items-start gap-2">
<span className="text-[8px] text-zinc-500 uppercase tracking-wider w-14 shrink-0 pt-0.5">{label}</span>
<span className={`text-[9px] break-all ${isError ? "text-red-400" : "text-zinc-300"} ${mono ? "font-mono" : ""}`}>
<span className="text-[8px] text-ink-soft uppercase tracking-wider w-14 shrink-0 pt-0.5">{label}</span>
<span className={`text-[9px] break-all ${isError ? "text-bad" : "text-ink-mid"} ${mono ? "font-mono" : ""}`}>
{value}
</span>
</div>
@@ -391,8 +394,8 @@ function Detail({ label, value, mono, error: isError }: { label: string; value:
function JsonBlock({ label, data }: { label: string; data: Record<string, unknown> }) {
return (
<div>
<div className="text-[8px] text-zinc-500 uppercase tracking-wider mb-1">{label}</div>
<pre className="text-[9px] text-zinc-300 bg-zinc-900/80 rounded p-2 overflow-x-auto max-h-48 font-mono">
<div className="text-[8px] text-ink-soft uppercase tracking-wider mb-1">{label}</div>
<pre className="text-[9px] text-ink-mid bg-surface-sunken/80 rounded p-2 overflow-x-auto max-h-48 font-mono">
{JSON.stringify(data, null, 2)}
</pre>
</div>
+16 -16
View File
@@ -116,10 +116,10 @@ export function BudgetSection({ workspaceId }: Props) {
<div className="space-y-3" data-testid="budget-section">
{/* Section header */}
<div>
<h3 className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">
<h3 className="text-xs font-semibold text-ink-mid uppercase tracking-wider">
Budget
</h3>
<p className="text-[11px] text-zinc-400 mt-0.5">
<p className="text-[11px] text-ink-mid mt-0.5">
Limit total message credits for this workspace
</p>
</div>
@@ -129,7 +129,7 @@ export function BudgetSection({ workspaceId }: Props) {
<div
role="alert"
data-testid="budget-exceeded-banner"
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-zinc-950 border border-amber-700/50 text-amber-400 text-xs font-medium"
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-surface border border-amber-700/50 text-warm text-xs font-medium"
>
<svg
width="13"
@@ -158,21 +158,21 @@ export function BudgetSection({ workspaceId }: Props) {
{/* Usage stats */}
{loading ? (
<p className="text-xs text-zinc-500" data-testid="budget-loading">
<p className="text-xs text-ink-soft" data-testid="budget-loading">
Loading
</p>
) : fetchError ? (
<p className="text-xs text-red-400" data-testid="budget-fetch-error">
<p className="text-xs text-bad" data-testid="budget-fetch-error">
{fetchError}
</p>
) : budget ? (
<div className="space-y-2">
{/* Stats row */}
<div className="flex items-baseline justify-between" data-testid="budget-stats-row">
<span className="text-xs text-zinc-400">Credits used</span>
<span className="text-xs font-mono text-zinc-300">
<span className="text-xs text-ink-mid">Credits used</span>
<span className="text-xs font-mono text-ink-mid">
<span data-testid="budget-used-value">{(budget.budget_used ?? 0).toLocaleString()}</span>
<span className="text-zinc-500 mx-1">/</span>
<span className="text-ink-soft mx-1">/</span>
<span data-testid="budget-limit-value">
{budget.budget_limit != null
? budget.budget_limit.toLocaleString()
@@ -189,11 +189,11 @@ export function BudgetSection({ workspaceId }: Props) {
aria-valuenow={progressPct}
aria-valuemin={0}
aria-valuemax={100}
className="h-1.5 w-full rounded-full bg-zinc-800 overflow-hidden"
className="h-1.5 w-full rounded-full bg-surface-card overflow-hidden"
>
<div
data-testid="budget-progress-fill"
className="h-full rounded-full bg-blue-500 transition-all duration-300"
className="h-full rounded-full bg-accent transition-all duration-300"
style={{ width: `${progressPct}%` }}
/>
</div>
@@ -201,7 +201,7 @@ export function BudgetSection({ workspaceId }: Props) {
{/* Remaining credits */}
{budget.budget_remaining != null && (
<p className="text-[11px] text-zinc-500" data-testid="budget-remaining">
<p className="text-[11px] text-ink-soft" data-testid="budget-remaining">
{budget.budget_remaining.toLocaleString()} credits remaining
</p>
)}
@@ -212,7 +212,7 @@ export function BudgetSection({ workspaceId }: Props) {
<div className="space-y-1.5 pt-1">
<label
htmlFor={`budget-limit-input-${workspaceId}`}
className="text-[11px] text-zinc-400 block"
className="text-[11px] text-ink-mid block"
>
Budget limit (credits)
</label>
@@ -225,15 +225,15 @@ export function BudgetSection({ workspaceId }: Props) {
onChange={(e) => setLimitInput(e.target.value)}
placeholder="e.g. 1000 — blank for unlimited"
data-testid="budget-limit-input"
className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm text-zinc-300 placeholder-zinc-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-colors"
className="w-full bg-surface-card border border-line rounded-lg px-3 py-2 text-sm text-ink-mid placeholder-zinc-500 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30 transition-colors"
/>
<p className="text-xs text-zinc-500">Leave blank for unlimited</p>
<p className="text-xs text-ink-soft">Leave blank for unlimited</p>
{saveError && (
<div
role="alert"
data-testid="budget-save-error"
className="px-3 py-1.5 rounded-lg bg-red-950/40 border border-red-800/50 text-xs text-red-400"
className="px-3 py-1.5 rounded-lg bg-red-950/40 border border-red-800/50 text-xs text-bad"
>
{saveError}
</div>
@@ -243,7 +243,7 @@ export function BudgetSection({ workspaceId }: Props) {
onClick={handleSave}
disabled={saving}
data-testid="budget-save-btn"
className="px-4 py-1.5 bg-blue-600 hover:bg-blue-500 active:bg-blue-700 rounded-lg text-xs font-medium text-white disabled:opacity-50 transition-colors"
className="px-4 py-1.5 bg-accent-strong hover:bg-accent active:bg-accent-strong rounded-lg text-xs font-medium text-white disabled:opacity-50 transition-colors"
>
{saving ? "Saving…" : "Save"}
</button>

Some files were not shown because too many files have changed in this diff Show More