Compare commits

...

59 Commits

Author SHA1 Message Date
documentation-specialist bd145dcec6 docs(workspace-runtime): migrate github.com refs at source so mirror inherits Gitea links (internal#41)
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 4s
CI / Detect changes (pull_request) Successful in 5s
E2E API Smoke Test / detect-changes (pull_request) Successful in 6s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 6s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 6s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 6s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
CI / Platform (Go) (pull_request) Successful in 3s
CI / Canvas (Next.js) (pull_request) Successful in 4s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 4s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 8s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 5s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 4s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Failing after 12s
CI / Python Lint & Test (pull_request) Failing after 12s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Failing after 11s
CodeQL / Analyze (${{ matrix.language }}) (go) (pull_request) Failing after 41s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (pull_request) Failing after 1m18s
CodeQL / Analyze (${{ matrix.language }}) (python) (pull_request) Failing after 1m21s
The molecule-ai-workspace-runtime mirror is regenerated on every
runtime-v* tag from this monorepo's workspace/. Per saved memory
reference_runtime_repo_is_mirror_only, mirror-guard rejects direct
PRs to the mirror; edit at source.

Source-side files that propagate to the mirror's published README +
read by users of the in-monorepo workspace-runtime docs:

- scripts/build_runtime_package.py (the README generator):
  * line 281 README_TEMPLATE: 'Shared workspace runtime for Molecule
    AI' link → Gitea
  * line 399 doc-link to workspace-runtime-package.md → Gitea path
    (with /src/branch/main/ shape)
  LEFT AS-IS (per Q3 audit-trail decision):
  * lines 379, 392 historical issue cross-refs (#2936, #2937)

- workspace/build-all.sh:5 — comment block linking to template-*
  repos. Migrated to Gitea path-shape.

- docs/workspace-runtime-package.md:
  * lines 101-108 adapter→repo table (8 templates, all PUBLIC on
    Gitea) — Gitea URLs
  * line 247 starter-repo link — substituted host + added inline
    note that starter doesn't survive the suspension migration
    (recreation pending; cross-link to this issue)
  * line 259 generic git clone command for new templates → Gitea
  * line 289 second starter mention — same handling as 247

Files NOT touched in this PR:
- workspace/ Python source code (.py files) — those use github
  paths in docstrings + a few log strings; fix bundled with the
  cross-repo Go-module-style migration (per #37 Q5 + parked
  follow-ups).
- 'Writing a new adapter' section's `gh repo create` command (line
  254-256) — gh CLI doesn't talk to Gitea (per #45 parked follow-up).
- 'Writing a new adapter' section's ghcr.io image ref (line 276) —
  per #46 ghcr→ECR migration (separate concern).

After this PR merges to staging + a runtime-v* tag is pushed, the
mirror's published README will inherit the Gitea link. Until then
the mirror's README continues to reference github.com/Molecule-AI
(stale but historical-marker-correct since the mirror existed
pre-suspension).

Refs: molecule-ai/internal#41, molecule-ai/internal#37,
molecule-ai/internal#38, molecule-ai/internal#42,
molecule-ai/internal#45, molecule-ai/internal#46
2026-05-07 00:48:04 -07:00
claude-ceo-assistant f92ba492de Merge pull request 'test(org_import): tighten sqlmock regex on lookupExistingChild (#2872 PR-B)' (#3) from fix/2872-sqlmock-regex-tightening into staging
Harness Replays / detect-changes (push) Successful in 6s
Harness Replays / Harness Replays (push) Failing after 43s
publish-workspace-server-image / build-and-push (push) Failing after 2m17s
Auto-sync main → staging / sync-staging (push) Successful in 6s
CI / Detect changes (push) Successful in 6s
E2E API Smoke Test / detect-changes (push) Successful in 6s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 6s
Handlers Postgres Integration / detect-changes (push) Successful in 5s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 5s
CI / Shellcheck (E2E scripts) (push) Failing after 4s
Block internal-flavored paths / Block forbidden paths (push) Successful in 26s
CI / Python Lint & Test (push) Failing after 10s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Failing after 10s
CodeQL / Analyze (${{ matrix.language }}) (go) (push) Failing after 48s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Failing after 27s
Handlers Postgres Integration / Handlers Postgres Integration (push) Failing after 40s
Secret scan / Scan diff for credential-shaped strings (push) Failing after 1m11s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (push) Failing after 1m23s
CodeQL / Analyze (${{ matrix.language }}) (python) (push) Failing after 1m24s
CI / Canvas (Next.js) (push) Failing after 1m57s
CI / Canvas Deploy Reminder (push) Has been skipped
CI / Platform (Go) (push) Failing after 2m27s
E2E API Smoke Test / E2E API Smoke Test (push) Failing after 4m45s
SECRET_PATTERNS drift lint / Detect SECRET_PATTERNS drift (push) Failing after 14s
Canary — staging SaaS smoke (every 30 min) / Canary smoke (push) Failing after 16s
2026-05-07 00:19:40 +00:00
Hongming Wang 00cfe51df7 test(org_import): tighten sqlmock regex on lookupExistingChild (#2872 PR-B)
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 6s
CI / Detect changes (pull_request) Successful in 6s
E2E API Smoke Test / detect-changes (pull_request) Successful in 6s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 5s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 4s
Harness Replays / detect-changes (pull_request) Successful in 5s
CodeQL / Analyze (${{ matrix.language }}) (go) (pull_request) Failing after 41s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 6s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 3s
CodeQL / Analyze (${{ matrix.language }}) (python) (pull_request) Failing after 1m23s
CI / Python Lint & Test (pull_request) Successful in 31s
CI / Canvas (Next.js) (pull_request) Successful in 52s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 40s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 4s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Failing after 40s
Harness Replays / Harness Replays (pull_request) Failing after 43s
CI / Platform (Go) (pull_request) Failing after 2m23s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Failing after 4m47s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (pull_request) Failing after 14m23s
The five `mock.ExpectQuery(\`SELECT id FROM workspaces\`)` sites used a
loose substring regex that silent-passed three regression shapes #2872
called out:

  1. `WHERE parent_id = $2` (drops `IS NOT DISTINCT FROM` — breaks
     NULL-parent root matching)
  2. `WHERE name = $1` only (drops parent_id check entirely — hijacks
     siblings of the same name across different parents)
  3. Drops `AND status != 'removed'` (blocks re-import after Collapse)

Extracts a `lookupChildSQLRE` const that anchors all four load-bearing
tokens (the SELECT/FROM, the name predicate, the IS NOT DISTINCT FROM
predicate, and the status filter). All five ExpectQuery sites now use
the same const so a future schema/predicate change fails one place.

Mutation-tested per memory feedback_assert_exact_not_substring.md:
- Replacing `IS NOT DISTINCT FROM` with `=` fails
  TestLookupExistingChild_NilParent_MatchesRoot.
- Dropping `AND status != 'removed'` fails
  TestLookupExistingChild_Found_ReturnsIDAndTrue.

Note: #2872 PR-A (AST gate strengthening) is already addressed inline —
findWorkspacesInsertSQL + TestCreateWorkspaceTree_InsertUsesOnConflictDoNothing
pin the ON CONFLICT DO NOTHING shape, which is a strictly stronger
gate than the original lookup-before-insert ordering check.
2026-05-06 16:43:42 -07:00
claude-ceo-assistant 55ef3176ed feat(provisioner): env-driven RegistryPrefix() for workspace template images (#6)
Block internal-flavored paths / Block forbidden paths (push) Successful in 4s
CI / Detect changes (push) Successful in 5s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 6s
Handlers Postgres Integration / detect-changes (push) Successful in 5s
Harness Replays / detect-changes (push) Successful in 5s
E2E API Smoke Test / detect-changes (push) Successful in 9s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 5s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 5s
CI / Shellcheck (E2E scripts) (push) Successful in 2s
CI / Python Lint & Test (push) Successful in 30s
CodeQL / Analyze (${{ matrix.language }}) (go) (push) Failing after 48s
CI / Canvas (Next.js) (push) Successful in 48s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 49s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 4s
CI / Canvas Deploy Reminder (push) Has been skipped
CodeQL / Analyze (${{ matrix.language }}) (python) (push) Failing after 1m19s
Handlers Postgres Integration / Handlers Postgres Integration (push) Failing after 39s
Harness Replays / Harness Replays (push) Failing after 37s
CI / Platform (Go) (push) Failing after 2m8s
publish-workspace-server-image / build-and-push (push) Failing after 2m39s
E2E API Smoke Test / E2E API Smoke Test (push) Failing after 4m46s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (push) Failing after 13m21s
Allows MOLECULE_IMAGE_REGISTRY env override on the tenant workspace-server. Used to flip from ghcr.io/molecule-ai → private ECR mirror after the GitHub org suspension on 2026-05-06. Default unchanged for OSS users.

Closes #6.
2026-05-06 22:51:53 +00:00
claude-ceo-assistant 4b074f631b feat(provisioner): env-driven RegistryPrefix() for workspace template images (#6)
pr-guards / disable-auto-merge-on-push (pull_request) Failing after 0s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Failing after 41s
Harness Replays / Harness Replays (pull_request) Failing after 30s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Platform (Go) (pull_request) Failing after 3m8s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Failing after 5m7s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (pull_request) Failing after 14m4s
CodeQL / Analyze (${{ matrix.language }}) (go) (pull_request) Failing after 14m36s
CodeQL / Analyze (${{ matrix.language }}) (python) (pull_request) Failing after 14m30s
Block internal-flavored paths / Block forbidden paths (pull_request) Has been cancelled
CI / Python Lint & Test (pull_request) Has been cancelled
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Has been cancelled
CI / Canvas (Next.js) (pull_request) Has been cancelled
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Has been cancelled
CI / Detect changes (pull_request) Has been cancelled
Secret scan / Scan diff for credential-shaped strings (pull_request) Has been cancelled
E2E API Smoke Test / detect-changes (pull_request) Has been cancelled
Runtime PR-Built Compatibility / detect-changes (pull_request) Has been cancelled
Harness Replays / detect-changes (pull_request) Has been cancelled
Handlers Postgres Integration / detect-changes (pull_request) Has been cancelled
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Has been cancelled
CI / Shellcheck (E2E scripts) (pull_request) Has been cancelled
Add MOLECULE_IMAGE_REGISTRY env var to override the registry prefix used
by all workspace-template image references. Defaults to ghcr.io/molecule-ai
(unchanged for OSS users); set to an ECR URI in production tenants when
mirroring to AWS.

Why this matters: GitHub suspended the Molecule-AI org on 2026-05-06 with
no warning. Production tenants kept running because they had images cached
locally, but any tenant restart (AWS health event, redeploy, OS reboot)
would have failed at `docker pull ghcr.io/molecule-ai/...` because GHCR
returned 401. This change introduces the seam needed to point new pulls at
a registry we control (AWS ECR) by flipping a single env var on Railway.

Design (RFC: molecule-ai/internal#6):

- New `RegistryPrefix()` function in `provisioner/registry.go` reads
  MOLECULE_IMAGE_REGISTRY, falls back to "ghcr.io/molecule-ai".
- New `RuntimeImage(runtime)` returns the canonical ref using the prefix.
- `RuntimeImages` map computed at init via `computeRuntimeImages()` so
  existing callers that range over it still work.
- `DefaultImage` likewise computed via `RuntimeImage(defaultRuntime)`.
- `handlers.TemplateImageRef()` switched from hardcoded format string to
  `provisioner.RegistryPrefix()`.
- `runtime_image_pin.go::resolveRuntimeImage()` automatically inherits
  the prefix change because it reads from `provisioner.RuntimeImages[]`
  and only re-formats the tag suffix to a digest pin.

Alternatives rejected (see RFC):

- Multi-registry fallback chain (try ECR, fall back to GHCR): GHCR is
  locked from outbound for our org, so the fallback never works for us.
  Adds code complexity for no benefit.
- Hardcoded ECR-only switch: couples production code to a specific
  deployment environment. OSS users self-hosting Molecule would need
  the upstream GHCR.
- Self-hosted Harbor / registry-on-Hetzner: adds a component to operate.
  Not justified at 3-tenant scale; AWS ECR is mature and IAM-integrated.

Auth — deliberately NOT changed in this commit:

- For GHCR, the existing `ghcrAuthHeader()` reads GHCR_USER/GHCR_TOKEN.
- For ECR, EC2 user-data installs `amazon-ecr-credential-helper` and adds
  a `credHelpers` entry in `~/.docker/config.json` so the daemon resolves
  ECR credentials via the EC2 instance role on every pull. The Go code
  needs no auth change. This keeps the diff minimal.

Backwards compatibility:

- Additive: env unset → identical behavior to today (GHCR).
- Existing tests reference literal `ghcr.io/molecule-ai/...` strings;
  they continue to pass under the default prefix.
- `RuntimeImages` map preserved for callers that iterate it.
- No interface, schema, API, or migration version bump needed.

Security review:

- No untrusted input: MOLECULE_IMAGE_REGISTRY is set at deploy time
  (Railway env, EC2 user-data), not by users.
- No expanded data collection or logging changes.
- No new permissions: ECR pull permission is a future user-data + IAM
  role change, separate from this code change.
- Worst-case: an attacker who already compromises Railway can swap the
  registry prefix to a malicious URI — same blast radius as compromising
  Railway today, no expansion.

Tests:

- 9 new unit tests in `registry_test.go` covering: default fallback,
  env override, empty env, all 9 known runtimes, unknown runtime,
  override-applies-to-all, computeRuntimeImages map population, env
  reflection, alphabetical ordering pin.
- All existing provisioner + handlers tests continue to pass.
- Mutation-tested mentally: deleting `if v := os.Getenv(...)` makes
  TestRegistryPrefix_RespectsEnv fail. Deleting `for _, r := range
  knownRuntimes` makes TestRuntimeImage_AllKnownRuntimes fail. The test
  suite would catch a regression of the original failure mode.

Rollout plan: this PR is safe to merge with no env change. Production
cutover happens by setting MOLECULE_IMAGE_REGISTRY on Railway after
the AWS ECR mirror is populated (separate ops change, tracked in
issue #6 phases 3b–3f).

Tracking:
- RFC: molecule-ai/internal#6
- Tasks: #97 (ECR setup), #98 (CP fallback)
- Tech debt: runbooks/hetzner-rollout-tech-debt-2026-05-06.md item 7

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:23:01 -07:00
hongming-personal 50c3bdfd6c Merge pull request #3028 from Molecule-AI/rfc-2945-pr-d-message-store
CI / Detect changes (push) Successful in 11s
Handlers Postgres Integration / detect-changes (push) Successful in 7s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 16s
E2E API Smoke Test / detect-changes (push) Successful in 17s
Block internal-flavored paths / Block forbidden paths (push) Successful in 25s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 13s
Secret scan / Scan diff for credential-shaped strings (push) Failing after 53s
CI / Shellcheck (E2E scripts) (push) Failing after 6s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 29s
CI / Python Lint & Test (push) Failing after 39s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 4s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 24s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 53s
CI / Canvas (Next.js) (push) Failing after 3m21s
CI / Canvas Deploy Reminder (push) Has been skipped
CI / Platform (Go) (push) Failing after 3m47s
CodeQL / Analyze (${{ matrix.language }}) (go) (push) Failing after 14m15s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (push) Failing after 14m33s
CodeQL / Analyze (${{ matrix.language }}) (python) (push) Failing after 14m34s
feat(messagestore): MessageStore interface + Postgres impl (RFC #2945 PR-D)
2026-05-06 06:42:13 +00:00
hongming-personal a33c879017 feat(messagestore): MessageStore interface + Postgres impl (RFC #2945 PR-D)
Closes #3026. Final piece of RFC #2945.

## What's new

New package internal/messagestore/ holds:

  - MessageStore interface — single read-side contract operators
    implement to plug in alternative chat-history backends.
  - ChatMessage / ChatAttachment / ListOptions types — canonical data
    shapes returned by any impl, mirrors canvas's TS ChatMessage.
  - PostgresMessageStore — platform-default impl wrapping the
    activity_logs query + A2A-envelope parser ported in PR-C.
    Behavior is byte-identical to the pre-PR-D handler.

## What moves

The activity_logs query, the parser (activityRowToChatMessages,
extractRequestText, extractChatResponseText, extractFilesFromTask,
etc.), and the internal-self-message predicate all migrate from
internal/handlers/chat_history.go into the new package. handlers/
chat_history.go becomes a thin HTTP-shape adapter:

  parse query params → store.List(ctx, workspaceID, opts) → emit JSON

Compile-time interface assertion in postgres_store.go catches future
drift if the interface evolves and the impl falls behind.

## Why this PR

OSS operators wanting to:

  - Tier hot/warm/cold storage (recent in Postgres, archival in S3)
  - Use a vector store with hybrid search (Pinecone, Weaviate)
  - Run an in-memory store for ephemeral test environments
  - Federate history across regions

…had no extension point — they'd have to fork the handler. This PR
makes that a constructor swap at router.go.

## Tests

  Parser-level (22 tests, MOVED to internal/messagestore/postgres_
  store_test.go): every TS test case in
  canvas/src/components/tabs/chat/__tests__/historyHydration.test.ts
  has a Go counterpart. Timestamp preservation, user/agent extraction,
  internal-self filter, role decision (status=error vs agent-error
  prefix), v0/v1 file shapes, malformed JSON resilience.

  Handler-level (9 NEW tests in internal/handlers/chat_history_test.go):
  thin adapter coverage using a fake MessageStore. UUID validation,
  before_ts RFC3339 validation, default limit, max-limit clamp,
  invalid-limit fallback, before_ts passthrough, empty-array (not
  null) JSON shape, attachment shape preservation, store-error → 502
  mapping.

  Compile-time interface conformance: PostgresMessageStore satisfies
  MessageStore, fakeStore (test fake) satisfies MessageStore.

  Mutation-tested. Removed UUID validation in the handler; confirmed
  TestChatHistoryHandler_RejectsNonUUIDWorkspaceID fires red (status
  200 instead of 400, non-UUID reaches the store). Restored, all
  green.

  Full handlers + messagestore + router test runs green; full repo
  go test ./... green.

## SSOT decision

ChatMessage / ChatAttachment / parser / DB query all live in
internal/messagestore/ ONLY. handlers/chat_history.go imports the
package and uses the types via messagestore.ChatMessage etc. — no
re-declaration anywhere.

## Three weakest spots (hostile-reviewer self-pass)

1. The internal-self prefix list (Delegation results are ready...) is
   a package var in messagestore/postgres_store.go. A future impl
   that wants to override the predicate must reach into the package
   to use IsInternalSelfMessage or define its own. Acceptable: the
   predicate is part of the contract; if an impl wants different
   semantics it owns that decision explicitly.

2. ListOptions has Limit + BeforeTS + HasBefore; future paging needs
   (after_ts, peer_id filter, role filter) require additive struct
   field additions, which is a soft API break for any impl that
   handles ListOptions positionally. Mitigated by Go's struct-literal
   convention (named fields by default); also flagged in the
   interface comment for impl authors.

3. The handler does NOT log when a store returns an error — it just
   maps to 502. An impl that wants to surface its error class up the
   stack can't, today. If/when an impl needs that, the interface can
   add a typed-error contract in a follow-up. Today's coverage is
   sufficient: most ops issues land in the store impl's own logs.

## Security review

  - Untrusted input? Same as PR-C — agent-emitted JSON parsed
    defensively. New fakeStore in tests can't reach production.
  - Trust boundary? Same. Interface lives BEHIND wsAuth; impls only
    see workspace IDs already authenticated.
  - Auth/authz? Inherited from handler; the interface doesn't
    authenticate.
  - PII / secrets in logs? Documented in the interface contract:
    impls MUST NOT log full message bodies / attachment URIs. The
    Postgres impl logs nothing on the happy path.
  - Output sanitization? Same plain-text + opaque-URI surface as
    PR-C. Canvas validates attachment-URI schemes.

No security-relevant changes beyond what /chat-history already
exposes via PR-C. Considered, not skipped.

## Versioning / backwards compat

  - New internal package. Zero public API change.
  - Single caller site in router.go updated (one-line constructor
    change). NewChatHistoryHandler() → NewChatHistoryHandler(store).
  - No schema change, no migration.
  - Existing /chat-history endpoint unchanged on the wire — clients
    don't notice the refactor.

## Phasing

This is the final RFC #2945 piece. Follow-ups parked:

  - PR-C-2 (canvas migration): swap canvas loadMessagesFromDB to call
    /chat-history instead of /activity. Independent of this PR;
    blocked only by canvas team's calendar.
  - Sample alternative impls (S3, in-memory) for OSS docs: separate
    PR when the first OSS consumer materializes; demonstration code
    untested against a real workload is anti-pattern.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-05 23:38:14 -07:00
hongming-personal e91186c4bf Merge pull request #3020 from Molecule-AI/rfc-2945-pr-c-chat-history
feat(workspace-server): server-side chat-history endpoint (RFC #2945 PR-C)
2026-05-06 06:23:12 +00:00
hongming-personal 089be695a9 Merge staging into rfc-2945-pr-c-chat-history 2026-05-05 23:18:52 -07:00
hongming-personal dcc870a6b7 feat(workspace-server): server-side chat-history endpoint (RFC #2945 PR-C)
Closes the SSOT gap for chat-history hydration: today every consumer
(canvas TS) re-implements an A2A-envelope walk to map activity_logs
rows into rendered ChatMessage objects. This PR moves that walk into
the server.

## What's added

GET /workspaces/:id/chat-history?limit=N&before_ts=T

Returns:

  {
    "messages": [
      {"id": "<uuid>", "role": "user"|"agent"|"system",
       "content": "...", "attachments": [...], "timestamp": "<RFC3339>"}
    ],
    "reached_end": false
  }

Auth chain: same wsAuth as /workspaces/:id/activity (tenant ADMIN_TOKEN
+ X-Molecule-Org-Id). No new trust boundary.

Filter: a2a_receive rows with source_id IS NULL — same canvas-source
filter the canvas applies via /activity?type=a2a_receive&source=canvas,
centralized so future API consumers don't need to know it.

## What's mirrored from canvas TS

Direct port of canvas/src/components/tabs/chat/historyHydration.ts
+ message-parser.ts:

  - extractRequestText / extractFilesFromUserMessage — user-side parts
    walk through request_body.params.message.parts[]
  - extractChatResponseText — agent-side response_body collector across
    the four shapes (string, A2A JSON-RPC parts, older nested
    parts.root.text, task artifacts) joined with "\n" (matches canvas
    multi-source collector — claude-code emits multiple text parts;
    hermes emits summary+artifacts)
  - extractFilesFromResponse / extractFilesFromTask — file walk across
    parts[] + artifacts[].parts[] + status.message.parts[] +
    message.parts[]
  - v0 hot path ({kind:"file", file:{...}}) AND v1 protobuf flat shape
    ({url, filename, mediaType}) both supported
  - Role decision: status='error' OR text starts with "agent error"
    (case-insensitive) → "system", else "agent"
  - isInternalSelfMessage prefix filter (Delegation results are
    ready...)
  - Timestamp pinned to row.created_at (regression cover for
    2026-04-25 bubble-collapse bug)

## Tests

22 unit tests in chat_history_test.go, every TS test case in
historyHydration.test.ts has a Go counterpart:

  Timestamp preservation (3): user/agent pin to created_at, two-rows
  produce two distinct timestamps.

  User-message extraction (5): text-only, internal-self skip,
  null body, attachments hydrated, attachments-only-when-text-empty,
  internal-self suppresses even with attachments.

  Agent-message extraction (4): result-string, status=error→system,
  agent-error-prefix→system, response_body.parts attachments,
  null body, no-text-no-files-no-bubble.

  End-to-end (1): paired user+agent same timestamp.

  Go-specific (5): malformed JSON returns empty (no panic), v1
  protobuf flat shape extraction, task-artifacts extraction, older
  nested root.text shape, basename helper edge cases.

  isInternalSelfMessage predicate (1): prefix match, non-prefix non-
  match, empty-text non-match.

Mutation-tested. Removed the role-promotion branch (status=error +
agent-error prefix → system); confirmed both
TestChatHistory_RoleSystemWhenStatusError and
TestChatHistory_RoleSystemWhenAgentErrorPrefix fire red. Restored.
Both green.

Full handlers test suite (4.3s) green; full repo `go test ./...` green.

## SSOT decision

Parsing logic lives in workspace-server/internal/handlers/chat_history.go
ONLY. Canvas keeps historyHydration.ts + message-parser.ts during the
transition because:

  - PR-C-2 (follow-up): canvas loadMessagesFromDB swaps to new
    endpoint. Today's canvas still calls /activity for backward
    compatibility.
  - The TS parsers are still load-bearing for LIVE message handling
    (WebSocket A2A_RESPONSE events) until RFC #2945 PR-B-2 mirrors
    the typed event payloads to canvas consumers.

Canvas's TS path will be deleted in a separate PR after a one-week
observation window confirms no live-message consumers depend on it.

## Security review

  - Untrusted input? YES — request_body and response_body come from
    agents (potentially OSS / third-party). Defensive: any malformed
    JSON returns empty content + no attachments, no panic. Tested
    via TestChatHistory_MalformedJSONInRequestBodyReturnsEmpty.
  - Trust boundary? Same as today: agent → workspace-server.
    No new boundary; reuses existing wsAuth middleware.
  - Auth/authz? Inherits wsAuth chain. Cross-workspace access blocked
    by existing TenantGuard middleware.
  - PII / secrets in logs? None. The handler logs nothing on the
    happy path; errors log 502 without body content.
  - Output sanitization? ChatMessage.content is plain text returned
    as-is; canvas already sanitizes via ReactMarkdown. Attachment
    URIs are agent-provided (workspace: / platform-pending: /
    https:); canvas's existing scheme allow-list still applies.

## Versioning / backwards compatibility

  - New endpoint /chat-history. /activity unchanged.
  - Canvas historyHydration.ts + message-parser.ts intact during
    transition (will be removed in PR-C-2 follow-up).
  - No public API consumer of /activity is broken — added route is
    additive.
  - No semver bump (server is internal versioning).

## Three weakest spots (hostile-reviewer self-pass)

1. extractRequestText returns ONLY parts[0].text. If a user message
   contains multiple text parts (uncommon — canvas only ever emits
   one), we lose later parts. Matches canvas exactly today, but a
   future change that emits multi-text user messages needs both
   parsers updated. Documented in code; covered by test if/when
   added.

2. activityRowToChatMessages rebuilds ChatMessage IDs every call (no
   caching). Each chat reload mints fresh UUIDs. This is fine because
   canvas dedupes by (role, content, timestamp window) not id, but a
   future API consumer that DID rely on id stability would break.
   Documented in the ChatMessage struct comment.

3. The handler scopes to source_id IS NULL only (canvas-source rows).
   A future "show all messages, including agent-to-agent" mode would
   need a new endpoint or a parameter. Out of scope for PR-C; canvas's
   /activity?source=canvas already enforces the same filter.

Closes #3017. Unblocks RFC #2945 PR-D (MessageStore interface) which
returns []ChatMessage typed values.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:17:26 -07:00
hongming-personal d144dcc700 Merge pull request #3016 from Molecule-AI/fix/textutil-ssot-truncate-2962
fix(textutil): SSOT for rune-safe string truncation, fix 3 audit-gap bugs (#2962)
2026-05-06 06:05:00 +00:00
Hongming Wang 656a02fae4 fix(textutil): SSOT for rune-safe string truncation, fix 3 audit-gap bugs
Closes #2962.

## Why

Six per-package `truncate` helpers had drifted into independent
re-implementations of the same idea. Three of them (delegation.go,
memory/client/client.go, memory-backfill/verify.go) used
`s[:max] + "…"` byte-slice form, which on a multi-byte codepoint at
byte `max` produces invalid UTF-8 → Postgres `text`/`jsonb` rejects
the INSERT silently → `delegation` / `activity_logs` row never lands
→ audit gap.

Three other helpers (delegation_ledger.go #2962, agent_message_writer.go
#2959, scheduler.go #2026) had each been fixed in isolation with three
slightly different rune-safe shapes — confirming this is a class of
bug, not a single instance.

## What

New package `internal/textutil` with three rune-safe functions:

- `TruncateBytes(s, maxBytes)` — byte-cap, "…" marker. Used by 5
  callers writing into byte-bounded columns / log lines.
- `TruncateBytesNoMarker(s, maxBytes)` — byte-cap, no marker. Used by
  delegation_ledger.go where the storage already conveys "preview"
  and an extra ellipsis would push the result over the column cap.
- `TruncateRunes(s, maxRunes)` — rune-cap, "…" marker. Used by
  agent_message_writer.go where the cap is in display chars (UI
  summary), not bytes.

All three guarantee `utf8.ValidString(out)` for any `utf8.ValidString(in)`.
Inputs already invalid go through `sanitizeUTF8` at the call site
boundary (scheduler.go preserved this defense-in-depth).

## Migration map

| Old | New | Behavior change |
|---|---|---|
| `delegation_ledger.truncatePreview` | `textutil.TruncateBytesNoMarker(s, 4096)` | none |
| `agent_message_writer.truncatePreviewRunes` | `textutil.TruncateRunes(s, n)` | none |
| `scheduler.truncate` | `textutil.TruncateBytes(s, n)` | "..." → "…" (3 bytes either way; single-glyph display) |
| `delegation.truncate` | `textutil.TruncateBytes(s, n)` | bug fix + ellipsis swap |
| `memory/client.truncate` | `textutil.TruncateBytes(s, n)` | bug fix |
| `memory-backfill.truncate` | `textutil.TruncateBytes(s, n)` | bug fix |

Five separate `truncate*` helpers + their per-package tests removed.
Net: 12 files / +427 / -255.

## Tests

- `internal/textutil/truncate_test.go` — 27 table-test cases + 145
  fuzz-invariant cases asserting `utf8.ValidString` and byte-cap
  invariants on every output.
- `delegation_ledger_test.go TestLedgerInsert_TruncatesOversizedPreview`
  strengthened with `capValidUTF8Matcher` so the SQL-write argument
  is asserted to be valid UTF-8 + within cap (not just `AnyArg()`).
  Mutation-tested: replacing the SSOT call with byte-slice form makes
  this test fail loud.

## Compatibility

- All callers internal; no external API surface change.
- Ellipsis swap "..." → "…": same byte budget (3 bytes), single-glyph
  display. No alerting/grep on either marker in this codebase
  (verified). Canvas renders both correctly.
- DB column widths unchanged (4096 / 80 / 200 / 256 / 300 — all
  preserved in the migrations).

## Security

Fixes a silent INSERT-failure mode that hid `activity_logs` /
`delegations` rows containing peer-controlled text. The class of input
that triggered it (CJK, emoji, accented Latin) is normal user content,
not malicious — but the symptom (audit gap) makes incident
reconstruction harder. Helper is pure-function over `string`; no
secrets / PII / auth handling involved. Untrusted input is handled
identically to before, just rune-aligned now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:01:21 -07:00
hongming-personal c53155ec5f Merge pull request #3014 from Molecule-AI/test/cross-table-atomicity-integ-149-followup
test(chat-uploads): integration test for cross-table atomicity (#149 follow-up)
2026-05-06 05:05:49 +00:00
Hongming Wang debe29c889 ci(handlers-postgres-integration): apply legacy *.sql migrations too
The migration-replay step globbed only *.up.sql, silently skipping
the older flat-naming migrations (001_workspaces.sql,
009_activity_logs.sql, etc.). Fine while no integration test
depended on those tables; broke when the #149 cross-table
atomicity test came in needing both workspaces (FK target for
activity_logs) and activity_logs themselves.

Switch to globbing *.sql + sorted lex-order, excluding *.down.sql
so up/down pairs don't undo themselves mid-run. Add a sanity check
for workspaces + activity_logs + pending_uploads alongside the
existing delegations gate so a future migration drift fails loud
instead of silently skipping the regressed test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:02:24 -07:00
Hongming Wang 7a39a08837 test(chat-uploads): integration test for cross-table atomicity (#149 follow-up)
Adds two real-Postgres tests under //go:build integration:

- TestIntegration_PollUpload_AtomicRollback_AcrossBothTables exercises
  the helpers in the same Tx shape uploadPollMode does (PutBatchTx +
  LogActivityTx + Rollback) and asserts COUNT(*)=0 on BOTH
  pending_uploads AND activity_logs after the rollback. Failure
  injection: NUL byte in `summary` triggers lib/pq protocol rejection
  on the second activity insert — same trick the existing PutBatch
  AtomicRollback test uses.

- TestIntegration_PollUpload_HappyPath_AcrossBothTables is the positive
  counterpart — Commit lands N rows in both tables.

Coverage rationale (post-PR-3010 review):
- sqlmock unit test (TestPollUpload_AtomicRollbackOnActivityInsertFailure)
  proved the handler calls Begin/Exec/Exec-fail/Rollback in order.
- Existing PutBatch integration test proved Postgres honors rollback
  for pending_uploads alone.
- New tests close the cross-table gap: prove LogActivityTx + PutBatchTx
  + real Postgres MVCC compose correctly under rollback.

A regression that made LogActivityTx silently route through db.DB
instead of the passed tx would still pass the sqlmock test (the
Begin/Commit/Rollback shape would look right) but would fail this
integration test (the activity_logs row would survive the rollback).

Verified locally: postgres:15-alpine + all migrations applied, both
tests pass in 0.1s. Skips cleanly without INTEGRATION_DB_URL — CI
already runs this file via the Handlers Postgres Integration job.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:57:56 -07:00
hongming-personal bb9bf85dbd Merge pull request #3011 from Molecule-AI/rfc-2872-workspaces-uniq-toctou
fix(workspace-server): close TOCTOU race on workspaces(parent_id, name) (#2872 Critical 1)
2026-05-06 04:51:01 +00:00
hongming-personal ff21bbb876 Merge staging into rfc-2872-workspaces-uniq-toctou to clear BEHIND 2026-05-05 21:46:33 -07:00
hongming-personal da3cb4c098 fix(workspace-server): close TOCTOU race on workspaces(parent_id, name) (#2872 Critical 1)
## Bug

`/org/import` had no per-tenant mutex, advisory lock, or DB-level
uniqueness on (parent_id, name). The pattern was lookup-then-insert:

    existingID, existing, err := h.lookupExistingChild(...)  // SELECT
    if existing { return /* skip */ }
    db.DB.ExecContext(ctx, `INSERT INTO workspaces ...`)     // INSERT

Two concurrent admin POSTs (rapid double-click in canvas, retry-after-
timeout, two operators on the same template) both saw "not found" in
the SELECT and both INSERT'd the same (parent_id, name).

Captured impact: tenant-hongming accumulated 72 stale child workspaces
in 4 days from repeated org-template spawns of the same template
(see #2857 phase 4 sweeper for the cleanup; #2872 for the prevention RFC).

## Fix

Two-layer fix — DB-level backstop AND application-level happy path:

1. **Migration** `20260506000000_workspaces_unique_parent_name.up.sql`

   ```sql
   CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS workspaces_parent_name_uniq
     ON workspaces (
       COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid),
       name
     )
     WHERE status != 'removed';
   ```

   * COALESCE(parent_id, sentinel) collapses NULLs so root workspaces
     also collide pairwise.
   * `WHERE status != 'removed'` lets a tombstoned row be replaced
     by a same-named re-import (preserves existing org-import semantics).
   * CONCURRENTLY avoids ACCESS EXCLUSIVE on production tenants under
     live traffic; IF NOT EXISTS makes the migration resumable.
   * Down migration drops CONCURRENTLY symmetrically.

2. **`org_import.go` swap**

   Replace lookup-then-insert with `INSERT ... ON CONFLICT DO NOTHING
   RETURNING id`. On the skip path (RETURNING returns 0 rows →
   sql.ErrNoRows), re-select the existing id to recurse children:

       INSERT INTO workspaces (...) VALUES (...)
       ON CONFLICT (COALESCE(parent_id, ...), name)
       WHERE status != 'removed'
       DO NOTHING
       RETURNING id;

   The ON CONFLICT target predicate matches the partial-index predicate
   exactly — required for Postgres to consider the index applicable.

   Existing `lookupExistingChild` helper kept (still used on the skip
   path); semantics unchanged.

## Test coverage

* AST gate refreshed to assert the workspaces INSERT contains the
  ON CONFLICT pattern (`onConflictDoNothingRE`) instead of the now-obsolete
  "lookup-before-insert" ordering. Per behavior-based gating
  (memory: feedback_behavior_based_ast_gates.md), the new gate pins
  the actual TOCTOU-resolution behavior.
* Companion `TestGate_FailsWhenInsertOmitsOnConflict` proves the gate
  catches the bug shape on synthetic source.
* All existing `lookupExistingChild` unit tests (no-rows, found,
  nil-parent, DB error, wrapped no-rows) still pass — helper is
  unchanged and still load-bearing on the skip path.
* Live Postgres E2E coverage runs via the existing
  "Handlers Postgres Integration" CI job, which applies migrations
  to a real PG and exercises the INSERT path.

## Why ship the migration + swap together (not stacked)

The migration alone provides a DB-level backstop, but without the
handler swap a UNIQUE-violation surfaces as a 500 to the user. The
handler swap alone has no enforceable target until the migration
applies. Shipped together they give graceful skip + atomic backstop.

Migration is CONCURRENTLY + IF NOT EXISTS, safe to apply even on
tenants where the sweeper (#2860) hasn't run yet — the index just
declines to build until conflicting rows are reconciled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:43:49 -07:00
hongming-personal ef9bd1e0e2 Merge pull request #3010 from Molecule-AI/fix/activity-row-tx-atomicity-149
fix(chat-uploads): activity rows commit atomically with PutBatch (#149)
2026-05-06 04:37:55 +00:00
Hongming Wang b759548822 fix(chat-uploads): activity rows commit atomically with PutBatch
Closes #149.

uploadPollMode for poll-mode chat uploads previously committed N
pending_uploads rows in one Tx (PutBatch), then wrote N activity_logs
rows individually outside any Tx. A per-row failure on activity row K
left rows 1..K-1 committed and pending_uploads orphaned until the 24h
TTL — not data-loss because the platform's fetcher handled the
half-state cleanly, but the user never saw file K in the canvas and
the inconsistency surfaced as an "uploaded but invisible" complaint
class.

Thread one Tx through PutBatchTx + N × LogActivityTx + Commit so all
or none commit. Broadcasts are deferred until after Commit — emitting
an ACTIVITY_LOGGED event for a row that ends up rolled back would
paint a ghost message into the canvas's optimistic UI. A new
LogActivityTx returns a commitHook the caller invokes post-Commit;
the existing fire-and-forget LogActivity is unchanged for the 4 other
production callers (a2a_proxy_helpers + activity.go report path).

Storage interface gains PutBatchTx; PostgresStorage.PutBatch is
refactored to share the validation + insert path. inMemStorage and
fakeSweepStorage delegate or no-op for PutBatchTx (the in-mem fake
can't model Tx state — DB-level atomicity is verified by the existing
real-Postgres integration test for PutBatch + the new unit test
asserting the Go handler calls Rollback on activity-insert failure).

Tests:
- TestPollUpload_AtomicRollbackOnActivityInsertFailure pins the new
  contract via sqlmock — second activity insert errors → Rollback
  expected, Commit must NOT be called.
- TestLogActivityTx_DefersBroadcastUntilCommitHook +
  _InsertError_NoHook_NoBroadcast + _NilTx_Errors cover the new API.
- TestPutBatchTx_HappyPath / _EmptyItems / _ValidationFails /
  _PerRowErrorPropagates cover Tx-aware storage layer.
- 7 existing TestPollUpload_* tests updated to mock Begin + Commit
  (or Begin + Rollback for failure paths) since the handler now
  opens a Tx around PutBatch + activity inserts.

All workspace-server tests pass; integration tag also clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:34:28 -07:00
hongming-personal cce2050b6a Merge pull request #2997 from Molecule-AI/rfc-2991-pr-1-image-preview-lightbox
feat(canvas/chat): inline image preview + fullscreen lightbox (RFC #2991 PR-1)
2026-05-06 04:28:03 +00:00
hongming-personal e87df906bd Merge staging into rfc-2991-pr-1 to clear BEHIND (post PR-2993 + PR-3005) 2026-05-05 21:24:20 -07:00
hongming-personal c60e2b5fa2 chore(canvas/chat): drop unused downloadChatFile import in AttachmentImage
github-code-quality bot flagged this as the last unresolved review thread
blocking the merge queue. The function is referenced in comments but
never called from this file (download is dispatched via the lightbox /
AttachmentChip path). Removing the import resolves the bot thread and
clears the staging branch-protection 'all conversations resolved' gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:23:46 -07:00
hongming-personal 143fbb91ff Merge pull request #3005 from Molecule-AI/ux/files-tab-drag-drop-upload
ux(canvas/files): drag-drop upload to target folder (#2999 PR-D)
2026-05-06 03:52:10 +00:00
hongming-personal 1b29b24e83 Merge staging into rfc-2991-pr-1 to clear BEHIND state 2026-05-05 20:50:55 -07:00
hongming-personal 6033179f48 Merge pull request #3006 from Molecule-AI/rfc-2991-pr-3-pdf-text-preview
feat(canvas/chat): inline PDF + text/code preview (RFC #2991 PR-3)
2026-05-05 20:49:53 -07:00
Hongming Wang ab1acff2d2 ux(canvas/files): drag-drop upload to target folder (#2999 PR-D)
User asked for VSCode-style drag-drop upload (#2999): "drag local to
upload to target folder just like vscode does". Today the only upload
path is the toolbar's Upload button (folder picker). Drag-drop lets
users grab files from Finder/Explorer and drop them directly on a
specific subdirectory in the tree.

1. New `uploadDataTransferItems(items, targetDir)` in `useFilesApi`
   — walks the HTML5 DataTransferItemList via `webkitGetAsEntry()`,
   recursing folders to a flat (relativePath, file) list, then PUTs
   each via the existing /files/<path> endpoint. The walker (also
   exported via `__testables`) calls `readEntries()` in a loop until
   empty so multi-batch folders (browsers cap each call at ~100
   entries) aren't silently truncated.

2. `uploadFiles` (folder-picker path) gained an optional `targetDir`
   parameter. Same prefixing semantics so future surfaces (e.g. an
   "upload here" toolbar button on a row) can reuse it.

3. `FileTree` directory rows gained `onDragOver` / `onDragEnter` /
   `onDragLeave` / `onDrop` handlers + a hover-target highlight
   (accent-tinted background + outline). dragLeave uses
   `currentTarget.contains(relatedTarget)` to suppress the flicker
   that fires when the cursor crosses any child of the row (icon,
   label, ✕ button) — without this the highlight strobes on every
   sub-element transition.

4. `FilesTab` wraps the tree column in an outer drop zone for
   "drop on root" — drops outside any specific subdir row land at
   root. The empty-state placeholder copy now includes a
   "drag files here to upload" hint when the active root is
   /configs (the only writable root today).

5. Both the row drop and the root drop are gated on
   `root === "/configs"` (the same gate that already blocks the
   toolbar's New / Upload / Clear). Other roots ignore the drag
   entirely (no highlight, no drop), so the user doesn't get a
   misleading drag affordance followed by a "switch root" toast.

`dragDropUpload.test.tsx` (9 tests, two layers):

Walker tests (pure function, no DOM):
- `walkEntry` collects a single dropped file with correct relpath.
- `walkEntry` walks a folder + preserves folder name in the path.
- **Multi-batch loop**: a fake reader that emits two batches of 2
  + an empty terminator must yield 4 files. A walker that called
  readEntries once would see only 2 — this is the load-bearing
  assertion against silent folder truncation.
- Nested directories: outer/inner/file.md → "outer/inner/file.md".

FileTree drag-drop wiring (DOM):
- `dragover` on a directory row preventDefault's (load-bearing —
  without it the drop event never fires).
- `drop` on a directory row fires `onDropToTarget(path, items)`.
- `drop` on a FILE row does NOT fire (only directories are valid
  drop targets).
- `drop` with no DataTransferItems does NOT fire (defensive guard
  against text-only drags).
- `dragenter` adds the highlight class to the directory row.

1. The 1MB per-file size cap is inherited from the existing
   `uploadFiles`. A user dropping a 5MB skill bundle silently
   skips the file (the loop's `continue` on `file.size >
   1_000_000`). Same behavior as the toolbar Upload, so consistent
   if not great. Surfacing skipped-files would be a UX improvement
   tracked separately — not load-bearing for this PR.

2. Drop-zone highlight on the column wrapper uses an outline that
   sits inside the column's overflow-y-auto scroll container. If
   the user drags onto a row that's mid-scroll, the highlight may
   clip slightly at the scroll boundary. Cosmetic only; the drop
   still works.

3. The `?root=` query is NOT passed on the underlying writeFile
   call (matches the existing uploadFiles behavior). On a backend
   without #2999 PR-A, this means uploads always land in /configs
   regardless of selected root — but we already gated drop on
   `root === "/configs"` so the practical effect is nil today.
   Once PR-A merges and the canvas threads ?root= through writes
   (separate follow-up), drops on /home etc. would be enableable
   by lifting the canDelete-style gate.

- `npx tsc --noEmit` clean
- 177/177 canvas tab tests pass
- Manual on local dev: drag a file from Finder onto /configs/skills
  row → file appears under /configs/skills/<name>. Drag a folder of
  3 files onto root area → 3 files uploaded with folder structure
  preserved. Drag onto /home tree → no highlight, no drop.

Refs #2999. Pairs with PR-A (backend EIC) — without PR-A the tree
is empty on SaaS and there's nothing to drop ONTO; PR-D still works
on self-hosted today.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-05 20:47:47 -07:00
hongming-personal 19df43e3da Merge pull request #2993 from Molecule-AI/rfc-2945-pr-b-1-migrate-bare-event-strings
refactor(events): migrate 18 producers to typed EventType constants (RFC #2945 PR-B-1)
2026-05-06 03:45:47 +00:00
hongming-personal dcece2762b feat(canvas/chat): inline PDF + text/code preview (RFC #2991 PR-3)
Adds two new arms to the AttachmentPreview kind dispatcher:

* PDF — chip in the bubble, click opens the shared AttachmentLightbox
  with a browser-native <embed type="application/pdf"> at 95vw/90vh.
  Fetch+Blob+ObjectURL auth path matches AttachmentImage / Video. PDF.js
  not pulled in; browser viewer is good enough for the desktop chat MVP
  (Slack/Linear/Notion all gate full-page PDF behind a click for the
  same reason). Falls back to AttachmentChip on fetch error.

* Text/code/JSON/YAML — first 10 lines in monospace <pre><code> right
  in the bubble, "Show all N lines" expands to full content, with a
  filename + ⬇ download header. Streams up to 256 KB then marks
  truncated and offers a download chip; large logs don't crash the
  bubble. No syntax highlighting in v1 — shiki adds 200-500 KB and is
  pure polish.

Coverage: 5 new dispatch tests (PDF success → embed in lightbox,
PDF fetch fail → chip fallback, text inline render, text long content
→ Show-all-N-lines expand button, text fetch fail → chip fallback).
All 19 AttachmentPreview tests pass; tsc --noEmit clean.

Stacked on rfc-2991-pr-1-image-preview-lightbox (PR-2 already merged
into PR-1's branch). PR-1 ships first; this rebases onto staging
once it lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:43:46 -07:00
hongming-personal 57bfa40990 Merge pull request #3004 from Molecule-AI/ux/files-tab-context-menu
ux(canvas/files): right-click context menu — Open / Download / Delete (#2999 PR-C)
2026-05-06 03:37:16 +00:00
Hongming Wang d88fbb90fb ux(canvas/files): right-click context menu — Open / Download / Delete (#2999 PR-C)
## Why

User asked for a VSCode-style right-click menu on file rows (#2999):
"right click to have a menu to download". Today the only download
affordance is the toolbar's Export-all (bulk JSON dump), and the
inline ✕ button is the only delete UX (small click target, easy to
miss).

## Fix

1. New `FileTreeContextMenu` component — fixed-position popover with
   Open / Download / Delete items composed per-row (files get all
   three; directories get Delete only since "open a directory in the
   editor" doesn't apply). Esc + outside-click + Tab + scroll
   dismiss. ↓/↑ arrow keys rove focus between menu items. role=menu
   + role=menuitem + autofocus on first item for a11y.

2. Menu state lifted to the top-level `FileTree` (not per-row) so
   opening a second row's menu auto-closes the first — only one
   menu open at a time, matching VSCode/Theia. Pinned by the
   `replaces the first` test.

3. New `downloadFileByPath(path)` in `useFilesApi` — fetches via the
   existing GET /workspaces/<id>/files/<path>?root= endpoint and
   triggers a browser download. Distinct from the existing
   `handleDownloadFile` which downloads the in-editor buffer
   (round-trips unsaved edits to disk); the context-menu download
   targets arbitrary tree rows the user hasn't opened.

4. `canDelete` prop threaded from FilesTab → FileTree → menu →
   item. Same gate as the toolbar (Clear/New/Upload all gated to
   /configs); context menu's Delete renders as disabled with a
   muted background on other roots, matching the "feature exists
   but isn't applicable here" pattern.

## Test coverage

`FileTreeContextMenu.test.tsx` (8 tests):

- File row → menu opens with Open + Download + Delete.
- Directory row → menu opens with Delete only.
- Click Download → onDownload(path) fires + menu closes.
- Click Delete (canDelete=true) → onDelete(path) fires.
- Click Delete (canDelete=false) → onDelete NOT called + menu stays
  open (disabled-state UX).
- Esc dismisses.
- Outside-click (mousedown on document.body) dismisses.
- Opening second context menu replaces the first (only-one-open
  invariant).

Each test uses fireEvent + screen.getByRole, so they fail on a
deleted-code regression — none would pass on the pre-PR shape.

## Three weakest spots (hostile self-review)

1. The menu is positioned at `clientX/clientY` without viewport
   clamping. If the user right-clicks at the very bottom-right of
   the panel, part of the menu may overflow off-screen. VSCode
   handles this by flipping the anchor; we don't yet. Acceptable
   v1 because the FilesTab is fixed-width (≤ side-panel width)
   and the menu is small (140×~80px); the overflow would be a few
   pixels of one item. Filed as a follow-up.

2. Auto-focus on the first item shifts keyboard focus away from
   the row that opened the menu. Closing with Esc returns focus
   to the body, not the row. Same behavior as TerminalTab's
   placeholder + the canvas's other context menus; consistent
   isn't ideal but at least uniform. Documented inline.

3. The download request reuses the API client's 15s default
   timeout — large config files (multi-MB skill bundles) on a
   slow connection could time out. Same risk applies to the
   existing toolbar Export. If we see real download failures we
   can add a `timeoutMs` override at the call site without
   touching the menu.

## Verification

- `npx tsc --noEmit` clean
- 176/176 canvas tab tests pass
- Manual on local dev: right-click a config.yaml row → menu opens
  → click Download → file lands in Downloads. Right-click on
  /home root → Delete renders disabled.

Refs #2999. Pairs with PR-A (backend EIC) — without PR-A the tree
is empty and there's nothing to right-click on a SaaS workspace.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-05 20:26:04 -07:00
hongming-personal 2e6bed71b9 Merge pull request #3003 from Molecule-AI/ux/files-tab-external-not-available
ux(canvas/files): "Files not available" banner for external runtimes (#2999 PR-B)
2026-05-06 03:24:45 +00:00
hongming-personal 030377bb84 Merge pull request #3002 from Molecule-AI/fix/files-eic-list-delete-symmetry
fix(workspace files API): EIC parity for ListFiles + DeleteFile (#2999 PR-A)
2026-05-06 03:22:45 +00:00
Hongming Wang f93957e982 ux(canvas/files): "Files not available" banner for external runtimes (#2999 PR-B)
## Why

Reported by user (issue #2999): external workspaces (mac laptop, mac
mini, hermes-on-home-server — runtime="external") render the FilesTab
identically to the SaaS empty-listing bug, showing "0 files / No
config files yet" even though the platform doesn't actually own the
filesystem of these workspaces. Visually indistinguishable from the
broken state, reads as a bug.

## Fix

Mirror the affordance TerminalTab adopted in PR #2830 for runtimes
without a TTY:

1. New `NotAvailablePanel` in `canvas/src/components/tabs/FilesTab/`
   — folder-with-slash icon + "Files not available" headline + body
   text that names the runtime and points the user at Chat.

2. `FilesTab` now takes optional `data?: WorkspaceNodeData`. When
   `data.runtime` is in `RUNTIMES_WITHOUT_FILES` (currently just
   "external"), early-return the placeholder before mounting the
   useFilesApi hook. Mirrors TerminalTab's prop shape exactly so the
   review pattern is uniform across tabs.

3. SidePanel passes `node.data` to FilesTab (matches existing pattern
   for ChatTab / TerminalTab).

## Test coverage

`FilesTab.notAvailable.test.tsx` (4 tests):

- external runtime → banner renders with runtime name + Chat-tab
  guidance copy.
- external runtime → NO `/files` API request fires (asserted by
  inspecting the mocked api.get call log).
- claude-code runtime → no banner, normal mount proceeds (toolbar's
  root selector is the discriminator).
- data prop omitted → falls through to normal mount (back-compat
  with any caller that doesn't thread data through, e.g. legacy
  tests).

Each branch is independent and discriminating — none would pass on
a code-deleted version of the early-return.

## Three weakest spots (hostile self-review)

1. `RUNTIMES_WITHOUT_FILES` is a hardcoded set in this file. If a
   future runtime joins (e.g. a "byok-claude" that runs on user
   hardware), someone has to remember to add it here. Reviewed
   alternatives: pull from a runtime-capabilities registry — same
   shape as `RUNTIMES_WITHOUT_TERMINAL` already in TerminalTab. We
   chose the parallel pattern over a new abstraction; consolidating
   into a shared registry can land if/when a third tab grows the
   same gate (rule of three). Documented inline.

2. The placeholder is a static panel — no retry, no "report bug"
   link. Same as TerminalTab's. Acceptable because the absence is
   intentional, not transient.

3. Chat-tab guidance is hardcoded English. No i18n in canvas yet;
   matches the rest of the codebase. Will move with the i18n
   migration when that lands.

## Verification

- `npx tsc --noEmit` clean
- 54/54 canvas tab + SidePanel tests pass
- Will be live-verified on staging post-merge: open Files tab on an
  external workspace (mac laptop) → expect placeholder; open on a
  platform-owned workspace (Hongming Personal Brand Agent) → expect
  normal tree (assuming PR-A also lands).

Refs #2999. Pairs with PR-A (backend EIC fix) — without PR-A the
platform-owned path still shows "0 files" because the backend never
returns rows.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-05 20:21:45 -07:00
hongming-personal b530c147de Merge pull request #3000 from Molecule-AI/rfc-2991-pr-2-video-audio-preview
feat(canvas/chat): inline video + audio HTML5 players (RFC #2991 PR-2)
2026-05-05 20:18:36 -07:00
Hongming Wang f39b595a9c fix(workspace files API): EIC parity for ListFiles + DeleteFile (closes #2999 PR-A)
## User-visible bug

Canvas Files tab returns "0 files / No config files yet" for every
SaaS workspace, every root (/configs, /home, /workspace, /plugins).
Reported by user (canvas screenshot, hongming.moleculesai.app,
Hongming Personal Brand Agent — claude-code, T4, online).

## Root cause

`ListFiles` (templates.go) was missing the SSH-via-EIC branch that
ReadFile (PR #2785) and WriteFile (PR #1702) already have. On SaaS,
dockerCli is nil → findContainer returns "" → falls through to
host-side resolveTemplateDir which only matches baked-in template
names. For a user-named workspace it matches nothing, so the handler
silently returns []fileEntry{}.

DeleteFile had the same gap — right-click delete (introduced in PR-C
of this issue) would silently no-op once #1 was fixed.

## Fix

1. Extracted shared EIC plumbing into `withEICTunnel` (closure-based,
   single SSOT for keypair → key push → tunnel → port-wait → cleanup).
   Refactored writeFileViaEIC + readFileViaEIC to use it. Added
   listFilesViaEIC + deleteFileViaEIC on the same scaffold. The
   `LogLevel=ERROR` shim from PR #2822 now lives in one
   `eicSSHSession.sshArgs()` helper instead of being duplicated per
   helper — the next time we need to tweak ssh options, one place.

2. Factored remote shell strings into pure functions
   (buildInstallShell / buildCatShell / buildRmShell / buildFindShell
   + parseFindOutput) so the wire shape can be pinned without booting
   a real EIC tunnel.

3. Refactored `resolveWorkspaceFilePath(runtime, root, relPath)` to
   honor `?root=`. New rule: `/configs` (or empty / unrecognized) →
   runtime managed-config dir via workspaceFilePathPrefix (preserves
   the v1 ReadFile/WriteFile behaviour where canvas's Config tab
   GETs/PUTs config.yaml without specifying a root and lands in the
   right per-runtime dir); `/home`, `/workspace`, `/plugins` →
   literal absolute path on the EC2 host. List/Read/Write/Delete now
   agree on what file a tree row points to — pre-fix List would say
   "/home contents" but Read/Write would route to /configs.

4. ListFiles + DeleteFile dispatch on instance_id != "" → EIC helper.
   Errors from the EIC path produce 500 (not silent fall-through to
   local-Docker, which would mask the failure as "0 files" — the
   exact user-visible symptom).

5. Added ?root= validation gate to WriteFile + DeleteFile so an
   out-of-allowlist root is rejected before the resolver runs.

## Test coverage

- TestResolveWorkspaceFilePath_RuntimeIndirection — pins the
  /configs → runtime prefix translation per-runtime (hermes,
  claude-code, langgraph, external, unknown). Catches the regression
  where a future edit accidentally drops the runtime indirection.

- TestResolveWorkspaceFilePath_LiteralRoots — pins /home,
  /workspace, /plugins as literal pass-through regardless of
  runtime. Catches the symmetric regression where the literal roots
  start getting rewritten to the runtime prefix (which would mean
  the FilesTab "/home" selector silently routes to /configs on
  hermes).

- TestResolveWorkspaceRootPath — directory-only translation used
  by listFilesViaEIC, same indirection rules.

- TestSSHArgs_HardenedFlags — pins the centralised ssh option set
  (LogLevel=ERROR + hardening). Catches drift in the
  one-place-where-ssh-flags-live.

- TestEicSSHSessionSingleSourceForSSHFlags — behaviour-based AST
  gate (per memory). Counts s.sshArgs() callers (must be ≥4 —
  list/read/write/delete) and asserts LogLevel=ERROR appears
  exactly once in the source. Fires if anyone copy-pastes a raw
  ssh args slice instead of going through the helper.

- TestBuildInstallShell / TestBuildCatShell / TestBuildRmShell /
  TestBuildFindShell — pure-function tests pinning the remote
  command shape. Catches regression like "rm -f silently becomes
  rm -rf" or "find loses node_modules pruning" without needing a
  real EC2.

- TestBuildFindShell_DepthForwarding — catches a regression where
  the helper hard-codes a depth instead of using the caller's value.

- TestParseFindOutput / TestParseFindOutput_EmptyInput — pin the
  TYPE|SIZE|REL parser. Empty-input case explicitly returns []
  not nil so the JSON wire shape stays a list.

- TestListFiles_EICDispatch_Success / Error — sqlmock-driven
  handler test. Verifies instance_id != "" routes to listFilesViaEIC
  and surfaces errors as 500 (does NOT silently fall through to
  local-Docker, which is the exact regression-mode of the original
  bug).

- TestListFiles_EICBranch_NotTakenForSelfHosted — back-compat
  guard: instance_id == "" must NOT enter the EIC branch (would
  break self-hosted operators).

- TestDeleteFile_EICDispatch_Success / Error — same shape for
  DeleteFile.

- TestListFiles_RootValidation / TestDeleteFile_RootValidation —
  ?root=/etc must 400 before any DB query or EIC call.

## Verification

- `go build ./...` clean
- `go test ./...` clean (full workspace-server suite)
- Will be live-verified against staging on hongming.moleculesai.app
  after merge: open Files tab → expect populated /home + /configs +
  /workspace listings (not "0 files"); right-click delete on
  /configs/old.yaml → expect file removed on the EC2 host.

## Three weakest spots (hostile self-review)

1. The LogLevel=ERROR drift gate counts source occurrences. A
   future refactor that intentionally moves the literal somewhere
   else (e.g. into a constant) would trigger a false positive. The
   gate's failure message points to the load-bearing constraint
   (must appear in sshArgs); operator can adjust.

2. `eicFileWriteTimeout` constant kept as an alias for back-compat
   with prior tests. Documented as intentional + safe to remove on
   the next pass.

3. The resolver tests pin the runtime → prefix map values
   (`/home/ubuntu/.hermes`, `/configs`, etc.). A future runtime
   addition that ships a new prefix needs the test updated. This
   is intentional — silent prefix changes orphan saved files, so a
   test failure on map edit IS the right signal.

## Follow-up (RFC #2312 subtask 2)

Long-term the right fix is to drop EIC entirely and HTTP-forward to
the workspace's own URL (RFC #2312). That's a substantially larger
refactor across 5 surfaces (chat upload, files, templates, plugins,
terminal) and out of scope for this bug-fix PR. Tracked separately
under that RFC.

Refs #2999.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:18:05 -07:00
hongming-personal 95fdf86187 feat(canvas/chat): inline video + audio HTML5 native players (RFC #2991 PR-2)
Second specialized renderer pair landing under RFC #2991. Stacks on
PR-1 (#2997) — extends the AttachmentPreview dispatcher with video/
audio cases.

Why HTML5-native (not custom JS player)
---------------------------------------

- Browser vendors ship hardware-accelerated decoders, captions,
  pinch + scrub UX, and fullscreen UI. We get all of it for free.
- Native fullscreen via the <video> control bar — no
  AttachmentLightbox needed for video (the browser's built-in
  fullscreen handles it).
- Mobile-friendly without us writing the touch handlers.

Auth model
----------

Identical to AttachmentImage (PR-1): platform-auth URIs need our
cookie/token, so we fetch the bytes, wrap in a Blob, hand the
browser an ObjectURL via <video src=> / <audio src=>. External
http(s) URIs skip the fetch.

Memory caveat: a Blob holds the entire media in JS memory until the
bubble unmounts. The server's 25MB single-file cap (chat_files.go)
bounds this; v2 can switch to MediaSource + streaming if larger
files become a real shape.

Failure modes
-------------

- Fetch failure (404, 403, network) → AttachmentChip fallback.
- Bytes that aren't valid media (corrupt, wrong Content-Type) →
  <video onError> / <audio onError> swap to chip.

Tests
-----

5 new component tests in AttachmentPreview.test.tsx (now 14 total):
  - kind=video → <video controls> with blob URL src
  - kind=video fetch fails → falls back to chip
  - kind=video extension fallback (no mime) → routes to video path
  - kind=audio → <audio controls> + filename label visible
  - kind=audio fetch fails → falls back to chip

The preview-kind unit tests from PR-1 (49 cases) already cover the
MIME → video / audio dispatch logic; this PR's component tests pin
the rendered DOM shape (controls attribute, blob URL src, fallback
behavior).

Hostile self-review
-------------------

1. Memory bound: 25MB cap protects us today; documented future
   migration path (MediaSource).
2. iOS Safari autoplay: playsInline pinned on <video> so mobile
   doesn't auto-fullscreen on play.
3. Captions accessibility: <track kind="captions" /> placeholder so
   the element is tagged correctly even though we don't have caption
   files yet (forward-compatible).

Verified
- tsc --noEmit clean
- 173 chat tests green (49 unit + 14 component + 110 pre-existing)

Stacks on PR-1 (#2997). PR-3 (PDF + text/code) is the final piece.

Refs RFC #2991, PR #2997 (PR-1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:10:19 -07:00
hongming-personal 04f7a07add feat(canvas/chat): inline image preview + fullscreen lightbox (RFC #2991 PR-1)
First specialized renderer landing under RFC #2991 — chat attachment
preview. Adds the dispatch infrastructure that PR-2 (video/audio) and
PR-3 (PDF/text) will extend.

Architecture (RFC #2991 Phase 2 design)
---------------------------------------

- preview-kind.ts: pure helper that maps mimeType (+ extension fallback
  for missing/generic MIME) to one of: image | video | audio | pdf |
  text | file. Single source of truth; the dispatch axis for every
  attachment renderer.

- AttachmentPreview.tsx: SSOT dispatch component. ChatTab no longer
  imports kind-specific components — it imports AttachmentPreview,
  which switches on the kind and renders the right child.

- AttachmentImage.tsx: inline thumbnail (max 240×180) + click →
  lightbox. Auth-aware: for platform URIs (workspace: /
  platform-pending: / etc) the bytes are fetched via JS-injected
  headers, wrapped in a Blob, served as ObjectURL — bare <img src>
  would not include the cookie/token.

- AttachmentLightbox.tsx: shared fullscreen modal (image now; PDF will
  use it in PR-3). Esc / backdrop click / X button to close, focus
  trap on close button, focus restoration on close.

- AttachmentChip retained as the kind=file fallback. No breaking
  change for existing renderable shapes.

External-workspace coverage
---------------------------

The wire shape (ChatAttachment.mimeType + uri) is identical for
internal + external workspaces — both go through AgentMessageWriter
(PR #2949). External claude-code agents that attach images via
send_message_to_user automatically get the new preview surface; no
runtime-side change needed.

Failure modes
-------------

- Fetch failure (404, 403, network) → AttachmentChip fallback so the
  user still gets a working download. Pinned by tests.
- Decoded as non-image (corrupt bytes, wrong Content-Type) → onError
  on the <img> swaps to AttachmentChip. Pinned by tests.
- Non-platform URIs (http/https external image hosts) → skip the
  auth-fetch flow, use the raw URL via resolveAttachmentHref. Pinned
  by extension-fallback tests.

Tests
-----

preview-kind.test.ts (49 cases):
  - Strict MIME match across image/video/audio/pdf/text/unknown
  - Extension fallback when MIME is missing or application/octet-stream
  - URL with query string + fragment → strip before parsing
  - MIME wins over extension (regression: don't render image-named zip)
  - SVG is image (not text) despite being XML
  - Non-canonical MIME like application/javascript → text

AttachmentPreview.test.tsx (9 component tests):
  - Dispatch: kind=file → chip, kind=image → image path
  - Loading state shows placeholder, NOT chip (proves dispatch routed)
  - Extension fallback (no mimeType) routes to image path
  - Fetch fail (404) and network error → fall back to chip
  - Image success: <img> renders ObjectURL, click opens lightbox
  - Lightbox: Esc closes, backdrop click closes, content click doesn't
  - Universal fallback: unknown MIME → chip even when extension hints
    at a renderable kind

Hostile self-review (3 weakest spots, addressed)
------------------------------------------------

1. <img> auth: bare <img src="/chat/download?..."> would NOT include
   our auth headers. Resolved via fetch+Blob+ObjectURL pattern.
   Pinned by the image-success test (asserts src === "blob:test-url").

2. Server-side allowed-roots mismatch: pre-fix tests used /tmp/ paths
   which the server doesn't allow. Caught when the dispatch test
   fell into the non-platform path. Updated tests to use /workspace/
   subpaths matching templates.go's allowedRoots.

3. Bundle size creep: each kind component adds bytes. Lightbox is
   currently always-bundled. Lazy-loading is plausible but defer
   until measured-needed.

Verified
- tsc --noEmit clean
- 168 chat tests green (49 unit + 9 component + 110 pre-existing)

PR-2 (video + audio) and PR-3 (PDF + text) extend the dispatch in
AttachmentPreview.tsx with their own kind-specific components.

Refs RFC #2991.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:39:37 -07:00
hongming 3dfeb180ab Merge pull request #2995 from Molecule-AI/fix/sweep-add-orphan-tunnel-cleanup-2987
chore(sweep): add orphan-tunnel cleanup step (#2987 / #340)
2026-05-06 02:38:39 +00:00
Hongming Wang 88ff0d770b chore(sweep): add orphan-tunnel cleanup step (#2987 / #340)
The 15-min sweeper has been deleting stale e2e orgs but not the
orphan tunnels left behind when the org-delete cascade half-fails
(CP transient 5xx after the org row is gone but before the CF
tunnel delete completes). Result: tunnels accumulate in CF until
manual operator cleanup.

Add a final step that POSTs `/cp/admin/orphan-tunnels/cleanup`
every tick. Best-effort — failure doesn't fail the workflow; next
tick re-attempts. Output reports deleted_count + failed count for
ops visibility.

This is the catch-all for the orphan-tunnel class. The proper
upstream fix (transactional org delete) lives in CP and tracks as
issue #2989. Until that lands, the sweeper bounded-time-to-cleanup
keeps the leak from escalating.

Note: PR #492 (cf-tunnel silent-success fix) makes this step
actually effective — pre-fix DeleteTunnel silent-succeeded on
1022, so the cleanup endpoint reported success without deleting.
Post-fix the cleanup chains CleanupTunnelConnections + retry on
1022, which actually clears stuck-connector orphans.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-05 19:36:20 -07:00
hongming-personal 86b8d8d744 Merge pull request #2982 from Molecule-AI/fix-config-skip-yaml-for-external-runtime
fix(canvas/config): skip config.yaml fetch for external/hermes runtimes
2026-05-06 02:22:14 +00:00
hongming-personal 9b9419ad5e Merge pull request #2992 from Molecule-AI/chore/ssot-pointer-sweep-workflow
chore(sweep): note SSOT for ephemeral prefixes lives in CP
2026-05-06 02:20:35 +00:00
Hongming Wang a19ee90556 chore(sweep): note SSOT for ephemeral prefixes lives in CP
Mirrors molecule-controlplane#494: the canonical EPHEMERAL_PREFIXES
list now lives in molecule-controlplane/internal/slugs/ephemeral.go,
where redeploy-fleet reads it to skip in-flight test tenants. The
sweep workflow keeps a Python copy because GHA Python can't import
Go, but a comment now points engineers updating the list to update
both files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:18:13 -07:00
hongming bd0580f4af Merge pull request #2990 from Molecule-AI/fix/memory-v2-namespace-labels-2988
fix(memory-v2): namespace labels use display names not UUID prefixes (#2988)
2026-05-06 02:13:30 +00:00
Hongming Wang 64e58fb390 test(memory-v2-e2e): update expectChainQueryRoot for new name column
PR #2990 root cause: the resolver SQL added `name` to the SELECT for
DisplayName plumbing, but the e2e test's sqlmock fixture
(expectChainQueryRoot at swap_test.go:216) still scripts the
3-column shape. Three e2e tests fail with:

    sql: expected 3 destination arguments in Scan, not 4

Fix: bump the fixture to 4 columns (id, name, parent_id, depth) and
pass an empty name. The e2e tests don't assert on label rendering —
they pin the namespace string flow ("workspace:root-1" etc), which
is unchanged. Empty name is fine: ReadableNamespaces still emits the
correct namespace strings; only DisplayName is empty.

Caught by CI's Platform (Go) check on PR #2990 — would have been a
silent missed-coverage case in the resolver_test.go run because that
package doesn't import the e2e package.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-05 19:10:18 -07:00
hongming-personal 9ceda9d81f refactor(events): migrate 18 files to typed EventType constants (RFC #2945 PR-B-1)
Mechanical migration of bare event-name strings in BroadcastOnly /
RecordAndBroadcast call sites to the typed constants from
internal/events/types.go (RFC #2945 PR-B). Wire format unchanged
(both shapes serialize to identical WSMessage.Event literals); pinned
by TestAllEventTypes_IsSnapshot in #2965.

Migrated (18 files, scope: handlers/, scheduler/, registry/, bundle/,
channels/):
- handlers/{approvals,a2a_proxy_helpers,a2a_queue,activity,agent,
  delegation,external_rotate,org_import,registry,workspace,
  workspace_bootstrap,workspace_crud,workspace_provision_shared,
  workspace_restart}.go
- channels/manager.go (caught by hostile-reviewer pass — initial
  scope missed channels/, found via grep on the post-migration tree)
- scheduler/scheduler.go
- registry/provisiontimeout.go
- bundle/importer.go

Hostile self-review (3 weakest spots, addressed)
------------------------------------------------

1. Missed call sites — initial scope omitted channels/. Post-migration
   `grep -rEn 'BroadcastOnly\([^,]+,[^,]*"[A-Z_]+"|RecordAndBroadcast\([^,]+,[^,]*"[A-Z_]+"' internal/`
   found 2 stragglers in channels/manager.go. Migrated. Final grep
   on the same pattern returns only the docstring example in
   types.go (intentional).

2. gofmt drift — auto-import injection produced non-canonical import
   ordering. `gofmt -w` applied ONLY to the 18 modified files (NOT
   the whole tree, to avoid sweeping unrelated pre-existing drift
   into this PR's diff). Three pre-existing un-gofmt'd files in
   handlers/ (a2a_proxy.go, a2a_proxy_test.go, a2a_queue_test.go)
   left as-is — they're unchanged by this PR and their drift
   predates it.

3. Wire format — paranoia check: do the constants serialize to the
   exact strings consumers (canvas TS, hermes plugin, anything
   parsing WSMessage.Event) expect? Yes. Pinned by the snapshot
   test. The migration is name-only; not a single character of
   wire output changes.

Verified
- go build ./... clean
- go vet ./internal/... clean
- gofmt -l on the 5 migrated package dirs: only pre-existing files
- Full tests: handlers/, channels/, scheduler/, registry/, events/,
  bundle/ all green (5 ok, 0 fail)

PR-B-2 (canvas TS mirror + cross-language parity gate) remains as
the final piece of RFC #2945 PR-B. Tracked separately so this PR
stays mechanical + reviewable.

Refs RFC #2945, PR #2965 (PR-B types).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:05:03 -07:00
Hongming Wang b6310d7ebf fix(memory-v2): namespace dropdown labels use display names not UUID prefixes (#2988)
User feedback on the v2 Memory tab redesign: on a root workspace, the
namespace dropdown showed three indistinguishable entries:
  Workspace (30ba7f0b)
  Team (30ba7f0b) (team)
  Org (30ba7f0b-b303-4a20-aefe-3a4a675b8aa4) (org)

For a root workspace, the resolver collapses workspace==team==org IDs
(resolver.go:113-122 derive() degenerate case). The previous
shortID(8)-truncated UUID label scheme made all three look identical
even though the three concepts (private / team-shared / org-wide)
remain semantically distinct.

## Backend — Resolver returns DisplayName

  - SQL chain query now SELECTs workspaces.name (COALESCE → "" on NULL)
  - chainNode carries .name through walk
  - deriveNames() computes the display name for each namespace,
    mirroring derive():
      workspace: self.name
      team:      parent.name (or self.name if root — degenerate)
      org:       chain[end].name (root of tree)
  - Namespace struct gets a new DisplayName field, omitempty wire-shape

## Backend — Handler renders label from DisplayName when present

  - memories_v2.go:namespaceLabelWithName(name, kind, displayName) is
    the new SSOT label generator. Falls back to the UUID-prefix shape
    when displayName is empty so callers without name plumbing keep
    working unchanged.
  - namespacesToViews now plumbs Namespace.DisplayName into the label.
  - Old namespaceLabel(name, kind) is preserved as a thin wrapper
    around namespaceLabelWithName(_, _, "") for back-compat.
  - Custom namespaces ignore displayName by design — operator-defined
    suffixes ARE the chosen label; a name override would surprise.

## Frontend — drop redundant `(kind)` suffix

  Pre-fix: "Team (mac laptop) (team)" — kind shown twice.
  Post-fix: "Team (mac laptop)" — the prefix already conveys the kind.

## Test coverage

Resolver (3 new tests):
  - DisplayName_Root: workspace name propagates to all 3 namespaces
  - DisplayName_Child: workspace=self.name, team=parent.name, org=root.name
  - DisplayName_EmptyOnNULL: COALESCE → "" → empty fallback

Handler (3 new tests):
  - NamespaceLabelWithName_PrefersDisplayName: workspace/team/org/custom paths
  - NamespaceLabelWithName_FallsBackToUUIDPrefix: empty displayName → legacy shape
  - NamespacesToViews_PassesDisplayNameThrough: full integration on root case

Canvas: existing 30 tests still pass; suffix drop is rendering-only.

memories_v2.go function coverage: **14/14 = 100%**
- namespaceLabelWithName: 100%
- namespacesToViews: 100%
- (all 11 pre-existing functions stay at 100%)

## SSOT

The "what is this namespace called" question now has one source of
truth: namespace.Resolver.ReadableNamespaces sets DisplayName from the
canonical workspace.name column. The handler is a renderer; the
canvas is a consumer. No name-lookup logic duplicated across the
three layers.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-05 18:46:50 -07:00
molecule-ai[bot] d75b73e713 Merge pull request #2981 from Molecule-AI/staging
staging → main: auto-promote 9dd2988
2026-05-05 18:13:50 -07:00
hongming-personal 0886dbc923 Merge pull request #2978 from Molecule-AI/fix-plugins-compact-empty-state
feat(canvas/skills): compact-empty layout for Plugins section (#2971)
2026-05-06 01:12:09 +00:00
hongming-personal 7420631c32 Merge pull request #2983 from Molecule-AI/feat/auto-promote-stale-alarm-2975
feat(ops): hourly alarm for auto-promote PR stuck on REVIEW_REQUIRED (#2975)
2026-05-06 00:58:49 +00:00
Hongming Wang caf19e8980 feat(ops): hourly alarm for auto-promote PR stuck on REVIEW_REQUIRED (#2975)
Closes the silent-block failure mode that left 25 commits — including
the Memory v2 redesign and the reno-stars data-loss fix — wedged on
staging for 12+ hours behind a single missing review. The auto-promote
workflow opened the PR + armed auto-merge, but main's branch protection
required a human review and nobody noticed until a user reported
"still seeing old memory tab".

## Detection logic — `scripts/check-stale-promote-pr.sh`

Reads open PRs `base=main head=staging` and alarms on:
  - `mergeStateStatus == BLOCKED`
  - `reviewDecision == REVIEW_REQUIRED`
  - createdAt older than `STALE_HOURS` (default 4h)

Other BLOCKED reasons (DIRTY, BEHIND, failed checks) are NOT alarmed —
those are the author's signal-to-fix. This script targets the specific
"no human reviewed yet" wedge.

Output:
  - `::warning` per stale PR (visible in workflow summary + Actions UI)
  - PR comment (idempotent via marker-string detection; one alarm
    per PR, never re-spammed)
  - Exit code = count of stale PRs (capped at 125)

Logic in a script (not inline workflow YAML) so it's:
  - **Unit-testable** — tests/test-check-stale-promote-pr.sh exercises
    every branch with stubbed fixture JSON + frozen clock. 23 tests
    covering: empty list, single stale, just-under-threshold, wrong
    reviewDecision, wrong mergeStateStatus, mixed list (only matching
    PRs alarm), custom threshold via --stale-hours, exit-code-counts-
    matching-PRs, --help, unknown arg → 64, missing repo → 2.
  - **Operator-runnable ad-hoc** — `scripts/check-stale-promote-pr.sh`
    works from any shell with `gh` + `jq`.
  - **SSOT** — one detector, the workflow YAML is just schedule +
    invocation surface. Future sibling workflows that need the same
    check call the same script.

## Workflow — `.github/workflows/auto-promote-stale-alarm.yml`

Triggers:
  - cron `27 * * * *` (hourly, off-the-hour to dodge cron herd)
  - workflow_dispatch with `stale_hours` + `post_comment` overrides

Concurrency: `auto-promote-stale-alarm` group, cancel-in-progress=false
(idempotent script; no benefit to cancelling a running scan).

Permissions: `contents: read` + `pull-requests: write` (post comments).

Sparse checkout — only fetches `scripts/check-stale-promote-pr.sh`.
No node_modules, no go modules, no slow setup steps. Workflow runs
in <30s on a clean repo.

## Why "alarm + comment" not "auto-approve"

Considered options in issue #2975:
  1. Slack/email alert — picked.
  2. Bot-account auto-approve via molecule-ops — circumvents the
     human-review gate that branch protection encodes.
  3. Trusted-promote bypass via CODEOWNERS — needs Org Admin config
     change; out of scope for a workflow PR.

The comment-on-PR pattern picks (1) without external dependencies
(no Slack token, no email config). Subscribers get notified via
GitHub's existing PR notification delivery; the warning shows up in
the Actions feed.

## Why this won't false-positive on legitimate slow reviews

Threshold is 4h. Most legitimate gates clear in <1h, so 4× headroom
is plenty for slow CI. The comment is idempotent (one alarm per PR,
never re-posted) — adding noise stops at 1 comment regardless of
how long the PR sits.

## Test plan

- [x] `bash scripts/test-check-stale-promote-pr.sh` — 23/23 pass
- [x] `python3 -c 'yaml.safe_load(...)'` clean
- [x] `bash -n` clean on both scripts
- [ ] Live verification: dispatch the workflow once main has caught up,
      confirm it correctly reports zero stale PRs
2026-05-05 17:55:27 -07:00
hongming-personal 38bc27df0d fix(canvas/config): skip config.yaml fetch for external / hermes runtimes — eliminate 404 console noise
Reported on production reno-stars 2026-05-05 (browser console):

  /workspaces/d76977b1-…/files/config.yaml:1
    Failed to load resource: the server responded with a status of 404

The workspace was an external-runtime mac-mini-style agent that
doesn't use the platform's config.yaml template — every Config tab
open issued a GET that 404d cleanly, and the existing catch block
fell into the runtime-manages-own-config branch + populated the
form from workspace metadata. Functionally correct, but the request
fired anyway, surfaced as a 404 in DevTools, and burned an RTT.

Fix: branch on RUNTIMES_WITH_OWN_CONFIG BEFORE the fetch — when the
workspace's runtime is one of those (external, hermes), skip the
GET, populate the form from workspace metadata directly, set
loading=false, return. Same code path as the existing 404-catch
fallback, just skipping the wasted request.

Behavior preserved for runtimes that DO use the template
(claude-code, etc.): unchanged GET → parse → setConfig flow.

Tests: 24/24 existing ConfigTab tests pass; no behavioral change for
the documented runtimes. tsc clean.

Refs reno-stars production 2026-05-05.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:55:24 -07:00
hongming-personal 6748035720 Merge pull request #2980 from Molecule-AI/test/canvas-resolve-attachment-href-2973
test(canvas/chat): cover platform-pending: branch + isPlatformAttachment (#2973)
2026-05-06 00:54:17 +00:00
hongming-personal c74d0ecc94 test(canvas/chat): cover platform-pending: branch + isPlatformAttachment (#2973)
Closes #2973 — the followup test gap I flagged on PR #2968's review.

Pre-merge #2968 added the platform-pending: URI scheme branch to
resolveAttachmentHref + introduced the isPlatformAttachment SSOT
helper, but the existing uploads.test.ts only covered the older
workspace: / file:/// / absolute-path branches. The new branch shipped
on prod-impact (live console error on reno-stars) with manual post-
deploy verification; the regression gate was filed as a followup
(#2973) so a future canvas refactor can't silently re-break the
poll-mode chat-attachment download path.

Adds 15 new test cases across two existing describe blocks:

resolveAttachmentHref — platform-pending: scheme (poll-mode uploads):
- well-formed platform-pending:<wsid>/<fileid> resolves to the
  /pending-uploads/<file>/content endpoint
- uses the URI's wsid, NOT the chat workspace_id (cross-workspace
  forwarding case — pinning the explicit decision from #2968's
  commit message so a regression that flipped this would mis-route
  the download to the wrong workspace's pending-uploads store)
- defensive fallback to raw URI on missing slash, empty fileID,
  empty wsid (so a future "helpful" change can't synthesize a
  broken /pending-uploads// path)
- regression test against the EXACT production repro from #2968's
  body (reno-stars, 2026-05-05 console error)

isPlatformAttachment:
- positive cases for platform-pending: (well-formed and malformed),
  workspace:<allowed-root>, file:///<allowed-root>, absolute paths
  under allowed roots
- NEGATIVE cases for HTTPS/HTTP URLs to other origins (auth-leak
  class regression — a helper that always returned true would
  attach workspace tokens to third-party requests), non-allowlisted
  roots like /etc/passwd or /var/log/x, empty string, and
  unrecognised schemes (s3://, ftp://)

All 21 tests pass. The 6 pre-existing tests are unchanged. The 15
new tests are the regression gate that #2973 asked for.

Verification:
- pnpm exec vitest run src/components/tabs/chat/__tests__/uploads.test.ts
  → 21 passed
2026-05-05 17:51:28 -07:00
hongming-personal 9dd29882e2 Merge pull request #2979 from Molecule-AI/fix/a2a-poll-mode-response-shape-2967
feat(a2a): SSOT typed-variant response parser + auto-fallback for poll-mode peers (#2967)
2026-05-06 00:41:43 +00:00
Hongming Wang e342d0c5a7 fix(build): register a2a_response in TOP_LEVEL_MODULES
The drift gate caught the new SSOT parser module — without registration
the wheel ships it un-rewritten and runtime imports fail. Same pattern
as inbox_uploads, a2a_tools_delegation, a2a_tools_rbac registrations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:34:05 -07:00
Hongming Wang 166ad20cd7 test(e2e): Phase 3.5 — wheel parser classifies real server response (#2967)
Previously Phase 3 only checked the workspace-server's poll-mode short-circuit
emit shape ({"status":"queued","delivery_mode":"poll","method":"..."}); the
matching client-side classification was tested in isolation against fixture
dicts in test_a2a_response.py.

This phase closes the loop by piping the actual on-the-wire response from a
real workspace-server back through the wheel's a2a_response.parse() and
asserting it classifies as the Queued variant with the right method +
delivery_mode. A regression in EITHER the server emit shape OR the client
parser will now fail this E2E, eliminating the gap that allowed the original
"unexpected response shape" production bug to ship despite green unit tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:31:45 -07:00
hongming-personal 4a2dda7cac feat(canvas/skills): compact-empty layout for Plugins section (#2971)
Reported on production 2026-05-05:

  agent plugin tab Plugins
  0 installed
  + Install Plugin
  this part should be default compact

Pre-fix: SkillsTab always rendered the Plugins section as a full
rounded-xl panel with vertical chrome — even when zero plugins were
installed and the registry browser was closed. The empty state
gave a lot of vertical real estate for content that's just "0
installed + Install button".

Fix: when installed.length === 0 AND registry closed AND initial
load completed, collapse the section into a single inline pill
("Plugins · 0 installed · + Install Plugin"). The full panel
re-mounts when:
  - installed.length > 0 (a plugin landed → expand to surface the list)
  - showRegistry === true (user clicked + Install Plugin → registry opens)
  - !installedLoaded (avoid flash; the loading shell shows instead
    until the first /plugins fetch resolves)

Accessibility:
  - Compact pill: aria-label="Plugins (none installed)" + button
    aria-expanded="false" + aria-controls="plugins-section"
  - Full panel: button aria-expanded={showRegistry} + same aria-controls
  - Section gets id="plugins-section" so the aria-controls reference
    resolves once the section mounts

External workspaces: this is a pure canvas-frontend layout change —
applies to ALL workspace runtimes (external, claude-code, hermes,
langchain, codex, third-party MCP). No server-side change needed.

Tests
-----

SkillsTab.compactEmpty.test.tsx (4 tests):
  - Compact pill renders when installed=0, registry closed, loaded
  - Full panel renders when installed > 0
  - Click + Install Plugin from compact → expands to full panel
    (verified via aria-controls target id appearing in the DOM)
  - During initial load (installedLoaded=false), compact pill does
    NOT render — avoids a compact→full flash as the load completes

Per memory feedback_oss_design_philosophy.md: the SkillsTab is the
only tab that needs compact-empty today, but the pattern is
extractable into a shared EmptyStateCompactWrapper if Schedules /
Memories / Approvals adopt the same affordance later. Don't generalise
until the third use case (per the same memory, "every refactor toward
OSS plugin shape" without premature abstraction).

Verified
- tsc --noEmit clean
- All 4 tests pass

Refs #2971.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:26:32 -07:00
Hongming Wang 8b9f809966 fix(a2a): SSOT response parser — handle poll-mode queued envelope (#2967)
Introduce ``workspace/a2a_response.py`` as the single source of truth for
the wire shapes the workspace-server proxy can return at
``/workspaces/<id>/a2a``:

  * ``Result``    — JSON-RPC success
  * ``Error``     — JSON-RPC error or platform-level error (with
                    restart-in-progress metadata when present)
  * ``Queued``    — poll-mode short-circuit envelope: the platform
                    queued the message into the target's inbox, the
                    target will fetch via /activity poll
  * ``Malformed`` — anything the parser can't classify (logged at
                    WARNING so a future server change is loud)

``send_a2a_message`` (in ``a2a_client.py``) now dispatches via
``a2a_response.parse(data)`` instead of inline ``"result" in data`` /
``"error" in data`` sniffing. The Queued variant returns a new
``_A2A_QUEUED_PREFIX`` sentinel so callers can distinguish "delivered
async, no synchronous reply" from both success-with-text and failure.

reno-stars production data caught two intermittent failures that
both reduced to the same root cause:

  1. **File transfer announce silently failed** — when CEO Ryan PC
     (poll-mode external molecule-mcp) sent the harmi.zip
     announcement to Reno Stars Business Intelligent (also poll-mode
     external), ``send_a2a_message`` saw the platform's poll-queued
     envelope ``{"status":"queued","delivery_mode":"poll","method":"..."}``,
     didn't recognize it as the synthetic delivery-acknowledgement
     it is, and returned ``[A2A_ERROR] unexpected response shape``.
     The agent fell back to a chunk-shipping path; receiver did get
     the file but operator-facing logs showed a failure that didn't
     actually fail.

  2. **Duplicated agent comm** — same bug, inverted direction. d76
     delegated to 67d, send_a2a_message returned the unexpected-shape
     error, delegate_task wrapped it as DELEGATION FAILED, the calling
     agent retried with sharper wording, the recipient saw the same
     request twice and self-reported "二次请求 — 我先不执行".

External molecule-mcp standalone runtimes are inherently poll-mode
(they have no public URL), so every external↔external A2A pair was
hitting this on every send. The pre-fix client only handled JSON-RPC
``result``/``error`` keys and treated the queued envelope (which has
neither) as malformed. RFC #2339 PR 2 added the queued envelope on
the server side; the client never caught up.

When ``send_a2a_message`` returns the ``_A2A_QUEUED_PREFIX`` sentinel,
``tool_delegate_task`` now transparently falls back to
``_delegate_sync_via_polling`` (RFC #2829 PR-5's durable
``/delegate`` + ``/delegations`` polling path, which DOES work for
poll-mode peers because the platform's executeDelegation goroutine
writes to the inbox queue and the result row arrives when the target
picks it up + replies). The agent gets a real synchronous reply
instead of the empty queued sentinel.

  * ``test_a2a_response.py`` — 62 tests, **100% line coverage** on
    the parser (verified via ``coverage run --source=a2a_response``).
    Includes adversarial-input fuzzing across ~25 pathological
    payloads — parser must never raise.
  * ``test_a2a_client.py::TestSendA2AMessagePollMode`` — 4 tests for
    the new Queued/Error wiring in ``send_a2a_message``.
  * ``test_delegation_sync_via_polling.py::TestPollModeAutoFallback``
    — 3 tests for the auto-fallback in ``tool_delegate_task``,
    including negative cases (push-mode reply must NOT trigger
    fallback; genuine error must NOT silently retry).
  * **Verified all new tests FAIL on pre-fix source** by stashing
    a2a_client.py + a2a_tools_delegation.py and re-running — 5
    failures including ImportError for the missing
    ``_A2A_QUEUED_PREFIX``.

Per the operator-debuggability directive:

  * INFO at every Queued classification (expected variant; operator
    sees normal poll-mode-peer queueing in log stream).
  * INFO at the auto-fallback decision in ``tool_delegate_task``
    so a future operator can correlate "send returned queued →
    falling back to polling path" without reading the source.
  * WARNING at every Malformed classification (server contract
    drift; operator MUST see this immediately).
  * Existing transient-retry WARNING preserved.

  * Mirror Go-side typed model in workspace-server. The wire shape
    is documented in ``a2a_response.py``'s module docstring with
    file:line pointers to the canonical emitters; a future PR can
    introduce ``models/a2a_response.go`` without changing wire
    behavior. The fixture corpus in ``test_a2a_response.py`` is
    designed so a one-sided edit breaks CI.
  * ``send_message_to_user`` and ``chat_upload_receive`` use a
    different endpoint (``/notify``) and aren't affected by this
    bug; their parsing stays unchanged.

  * 135 tests pass across ``test_a2a_response.py`` +
    ``test_a2a_client.py`` + ``test_delegation_sync_via_polling.py``
    + ``test_a2a_tools_impl.py``.
  * ``coverage run --source=a2a_response -m pytest`` reports 100%
    line coverage with 0 missing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:21:28 -07:00
102 changed files with 9448 additions and 913 deletions
@@ -0,0 +1,83 @@
name: auto-promote-stale-alarm
# Hourly cron + on-demand alarm for the silent-block failure mode that
# motivated issue #2975:
# - The auto-promote-staging.yml workflow opened a PR + armed
# auto-merge, but main's branch protection requires a human review
# (reviewDecision=REVIEW_REQUIRED). The PR sat BLOCKED with no
# surface-up-the-stack for 12+ hours, holding 25 commits hostage
# including the Memory v2 redesign and a reno-stars data-loss fix.
#
# This workflow runs `scripts/check-stale-promote-pr.sh` against the
# repo's open auto-promote PRs (base=main head=staging). When a PR has
# been BLOCKED on REVIEW_REQUIRED for >4h, it:
# 1. Emits a workflow-level warning (visible in run summary + the
# Actions UI feed).
# 2. Posts a comment on the PR (idempotent — one alarm per PR).
#
# The detection logic lives in scripts/check-stale-promote-pr.sh so
# it's unit-testable with stubbed `gh` (see test-check-stale-promote-pr.sh).
# This file is the schedule + invocation surface only — SSOT for the
# detector itself.
on:
schedule:
# Hourly. Cheap (one `gh pr list` + jq), and 1h granularity is
# plenty for a 4h staleness threshold — operators see the alarm
# within at most 1h of crossing the threshold.
- cron: "27 * * * *" # at :27 to dodge the cron herd at :00
workflow_dispatch:
inputs:
stale_hours:
description: "Hours after which a BLOCKED+REVIEW_REQUIRED PR is stale (default 4)"
required: false
default: "4"
post_comment:
description: "Post a comment on stale PRs (default true)"
required: false
default: "true"
permissions:
contents: read
pull-requests: write # post comments on stale PRs
# Serialize so the on-demand and scheduled runs don't double-comment
# the same PR. cancel-in-progress=false because the script is idempotent
# (existing comment marker prevents dupes), but a scheduled run firing
# while a manual one runs would just re-list the same PR set.
concurrency:
group: auto-promote-stale-alarm
cancel-in-progress: false
jobs:
scan:
runs-on: ubuntu-latest
steps:
- name: Checkout (need scripts/ only)
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
sparse-checkout: |
scripts/check-stale-promote-pr.sh
sparse-checkout-cone-mode: false
- name: Run stale-PR detector
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
STALE_HOURS: ${{ inputs.stale_hours || '4' }}
POST_COMMENT: ${{ inputs.post_comment || 'true' }}
run: |
# The script's exit code reflects the count of stale PRs.
# We don't want a stale finding to fail the workflow run —
# the warning + comment are the signal, the green/red is
# noise. So convert any non-zero exit to a workflow notice
# and exit 0.
set +e
bash scripts/check-stale-promote-pr.sh
rc=$?
set -e
if [ "$rc" -ne 0 ]; then
echo "::notice::Stale PR detector found $rc PR(s) needing attention. See warnings above + comments on the PRs."
fi
# Always succeed — operator-facing surface is the warning,
# not the workflow status.
exit 0
@@ -121,8 +121,16 @@ jobs:
# Per-migration result is logged so a failed migration that
# SHOULD have been replayable surfaces in the CI log instead
# of silently failing.
# Apply both *.sql (legacy, lives next to its module) and
# *.up.sql (newer up/down convention) in a single
# lexicographically-sorted pass. Excluding *.down.sql so the
# newest-naming-convention pairs don't undo themselves mid-run.
# Pre-#149-followup this loop only globbed *.up.sql, which
# silently skipped 001_workspaces.sql + 009_activity_logs.sql
# — fine while no integration test depended on those tables,
# not fine once a cross-table atomicity test came in.
set +e
for migration in migrations/*.up.sql; do
for migration in $(ls migrations/*.sql 2>/dev/null | grep -v '\.down\.sql$' | sort); do
if psql -h localhost -U postgres -d molecule -v ON_ERROR_STOP=1 \
-f "$migration" >/dev/null 2>&1; then
echo "✓ $(basename "$migration")"
@@ -132,16 +140,19 @@ jobs:
done
set -e
# Sanity: the delegations table MUST exist for the integration
# tests to be meaningful. Hard-fail if 049 didn't land — that
# would be a real regression we want loud.
if ! psql -h localhost -U postgres -d molecule -tA \
-c "SELECT 1 FROM information_schema.tables WHERE table_name = 'delegations'" \
| grep -q 1; then
echo "::error::delegations table missing after migration replay — handler integration tests would be meaningless"
exit 1
fi
echo "✓ delegations table present"
# Sanity: the delegations + workspaces + activity_logs tables
# MUST exist for the integration tests to be meaningful. Hard-
# fail if any didn't land — that would be a real regression we
# want loud.
for tbl in delegations workspaces activity_logs pending_uploads; do
if ! psql -h localhost -U postgres -d molecule -tA \
-c "SELECT 1 FROM information_schema.tables WHERE table_name = '$tbl'" \
| grep -q 1; then
echo "::error::$tbl table missing after migration replay — handler integration tests would be meaningless"
exit 1
fi
echo "✓ $tbl table present"
done
- if: needs.detect-changes.outputs.handlers == 'true'
name: Run integration tests
+49 -1
View File
@@ -108,6 +108,14 @@ jobs:
python3 > stale_slugs.txt <<'PY'
import json, os
from datetime import datetime, timezone, timedelta
# SSOT for this list lives in the controlplane Go code:
# molecule-controlplane/internal/slugs/ephemeral.go
# (var EphemeralPrefixes). The redeploy-fleet auto-rollout
# also reads from there to SKIP these slugs — without that
# filter, fleet redeploy SSM-failed in-flight E2E tenants
# whose containers were still booting, breaking the test
# that just spun them up (molecule-controlplane#493).
# Update both files together.
EPHEMERAL_PREFIXES = ("e2e-", "rt-e2e-")
with open("orgs.json") as f:
data = json.load(f)
@@ -185,7 +193,47 @@ jobs:
# sweeper is best-effort. Next hourly tick re-attempts. We
# only fail loud at the safety-cap gate above.
- name: Sweep orphan tunnels
# Stale-org cleanup deletes the org (which cascades to tunnel
# delete inside the CP). But when that cascade fails partway —
# CP transient 5xx after the org row is deleted but before the
# CF tunnel delete completes — the tunnel persists with no
# matching org row. The reconciler in internal/sweep flags this
# as `cf_tunnel kind=orphan`, but nothing automatically reaps it.
#
# `/cp/admin/orphan-tunnels/cleanup` is the operator-triggered
# reaper. Calling it here at the end of every sweep tick
# converges the staging CF account to clean even when CP
# cascades half-fail.
#
# PR #492 made the underlying DeleteTunnel actually check
# status — pre-fix it silent-succeeded on CF code 1022
# ("active connections"), so this step would have been a no-op
# against stuck connectors. Post-fix the cleanup invokes
# CleanupTunnelConnections + retry, which actually clears the
# 1022 case. (#2987)
#
# Best-effort. Failure here doesn't fail the workflow — next
# tick re-attempts. Errors flow to step output for ops review.
if: env.DRY_RUN != 'true'
run: |
set +e
curl -sS -o /tmp/cleanup_resp -w "%{http_code}" \
--max-time 60 \
-X POST "$MOLECULE_CP_URL/cp/admin/orphan-tunnels/cleanup" \
-H "Authorization: Bearer $ADMIN_TOKEN" >/tmp/cleanup_code
set -e
http_code=$(cat /tmp/cleanup_code 2>/dev/null || echo "000")
body=$(cat /tmp/cleanup_resp 2>/dev/null | head -c 500)
if [ "$http_code" = "200" ]; then
count=$(echo "$body" | python3 -c "import sys,json; d=json.loads(sys.stdin.read() or '{}'); print(d.get('deleted_count', 0))" 2>/dev/null || echo "0")
failed_n=$(echo "$body" | python3 -c "import sys,json; d=json.loads(sys.stdin.read() or '{}'); print(len(d.get('failed') or {}))" 2>/dev/null || echo "0")
echo "Orphan-tunnel sweep: deleted=$count failed=$failed_n"
else
echo "::warning::orphan-tunnels cleanup returned HTTP $http_code — body: $body"
fi
- name: Dry-run summary
if: env.DRY_RUN == 'true'
run: |
echo "DRY RUN — would have deleted ${{ steps.identify.outputs.count }} org(s). Re-run with dry_run=false to actually delete."
echo "DRY RUN — would have deleted ${{ steps.identify.outputs.count }} org(s) AND triggered orphan-tunnels cleanup. Re-run with dry_run=false to actually delete."
@@ -325,7 +325,6 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
{dropdownOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
{opt.kind ? ` (${opt.kind})` : ''}
</option>
))}
</select>
+1 -1
View File
@@ -287,7 +287,7 @@ export function SidePanel() {
{panelTab === "config" && <ConfigTab key={selectedNodeId} workspaceId={selectedNodeId} />}
{panelTab === "schedule" && <ScheduleTab key={selectedNodeId} workspaceId={selectedNodeId} />}
{panelTab === "channels" && <ChannelsTab key={selectedNodeId} workspaceId={selectedNodeId} />}
{panelTab === "files" && <FilesTab key={selectedNodeId} workspaceId={selectedNodeId} />}
{panelTab === "files" && <FilesTab key={selectedNodeId} workspaceId={selectedNodeId} data={node.data} />}
{panelTab === "memory" && <MemoryInspectorPanel key={selectedNodeId} workspaceId={selectedNodeId} />}
{panelTab === "traces" && <TracesTab key={selectedNodeId} workspaceId={selectedNodeId} />}
{panelTab === "events" && <EventsTab key={selectedNodeId} workspaceId={selectedNodeId} />}
+4 -2
View File
@@ -8,7 +8,8 @@ import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
import { useSocketEvent } from "@/hooks/useSocketEvent";
import { type ChatMessage, type ChatAttachment, createMessage, appendMessageDeduped } from "./chat/types";
import { uploadChatFiles, downloadChatFile, isPlatformAttachment } from "./chat/uploads";
import { AttachmentChip, PendingAttachmentPill } from "./chat/AttachmentViews";
import { PendingAttachmentPill } from "./chat/AttachmentViews";
import { AttachmentPreview } from "./chat/AttachmentPreview";
import { extractFilesFromTask } from "./chat/message-parser";
import { AgentCommsPanel } from "./chat/AgentCommsPanel";
import { appendActivityLine } from "./chat/activityLog";
@@ -1137,8 +1138,9 @@ function MyChatPanel({ workspaceId, data }: Props) {
{msg.attachments && msg.attachments.length > 0 && (
<div className={`flex flex-wrap gap-1 ${msg.content ? "mt-1.5" : ""}`}>
{msg.attachments.map((att, i) => (
<AttachmentChip
<AttachmentPreview
key={`${msg.id}-${i}`}
workspaceId={workspaceId}
attachment={att}
onDownload={downloadAttachment}
tone={msg.role === "user" ? "user" : "agent"}
+21
View File
@@ -262,6 +262,27 @@ export function ConfigTab({ workspaceId }: Props) {
setOriginalProvider("");
}
// Skip the config.yaml fetch entirely for runtimes that manage
// their own config (external, hermes, etc.) — they don't have a
// platform-side template, so the GET would 404. The catch block
// below handles 404 gracefully, but issuing the request adds
// browser-console noise + a wasted RTT on every open of the
// Config tab for the affected workspaces. Reported on
// production reno-stars 2026-05-05 (workspace runtime=external,
// 404 on /files/config.yaml visible in the console even though
// the form rendered correctly).
if (RUNTIMES_WITH_OWN_CONFIG.has(wsMetadataRuntime)) {
setConfig({
...DEFAULT_CONFIG,
runtime: wsMetadataRuntime,
model: wsMetadataModel,
...(wsMetadataModel ? { runtime_config: { model: wsMetadataModel } } : {}),
...(wsMetadataTier !== null ? { tier: wsMetadataTier } : {}),
} as ConfigData);
setOriginalModel(wsMetadataModel);
setLoading(false);
return;
}
try {
const res = await api.get<{ content: string }>(`/workspaces/${workspaceId}/files/config.yaml`);
const parsed = parseYaml(res.content);
+113 -4
View File
@@ -2,9 +2,11 @@
import { useState, useEffect, useRef, useMemo } from "react";
import { showToast } from "../Toaster";
import type { WorkspaceNodeData } from "@/store/canvas";
import { FilesToolbar } from "./FilesTab/FilesToolbar";
import { FileTree } from "./FilesTab/FileTree";
import { FileEditor } from "./FilesTab/FileEditor";
import { NotAvailablePanel } from "./FilesTab/NotAvailablePanel";
import { useFilesApi } from "./FilesTab/useFilesApi";
import { buildTree } from "./FilesTab/tree";
@@ -14,9 +16,40 @@ export type { TreeNode } from "./FilesTab/tree";
interface Props {
workspaceId: string;
/** Workspace metadata from the canvas store. Optional for back-compat
* with any caller that still mounts <FilesTab workspaceId=.../> without
* threading data through (legacy tests). When present, runtime gates
* the early-return below. Mirrors TerminalTab's prop shape (#2830). */
data?: WorkspaceNodeData;
}
export function FilesTab({ workspaceId }: Props) {
/** Runtimes whose filesystem the platform doesn't own. The canvas can't
* list/read/write files on these — the agent runs on the user's own
* hardware (mac laptop, mac mini, hermes-on-home-server) and reaches
* the platform via the heartbeat-based polling Phase 30 layer.
*
* Keep narrow — only add a runtime here when its provisioner genuinely
* has no platform-owned filesystem. Otherwise the user loses access to
* a real surface (e.g. claude-code SaaS workspaces have files served
* by ListFiles via EIC; they belong on the rendering path, not here). */
const RUNTIMES_WITHOUT_FILES = new Set(["external"]);
export function FilesTab({ workspaceId, data }: Props) {
// Early-return for runtimes whose filesystem is not platform-owned.
// Skips the whole useFilesApi hook + tree render below — without this,
// mounting the tab for an external workspace would issue a GET that
// the platform can technically answer (it reads its own DB row, not
// the user's machine), but every result row is fictional. Showing
// "0 files / No config files yet" reads as a bug. The placeholder
// makes the absence intentional and points the user at the right
// surface (Chat).
if (data && RUNTIMES_WITHOUT_FILES.has(data.runtime)) {
return <NotAvailablePanel runtime={data.runtime} />;
}
return <PlatformOwnedFilesTab workspaceId={workspaceId} />;
}
function PlatformOwnedFilesTab({ workspaceId }: { workspaceId: string }) {
const [root, setRoot] = useState("/configs");
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [fileContent, setFileContent] = useState("");
@@ -45,11 +78,36 @@ export function FilesTab({ workspaceId }: Props) {
readFile,
writeFile,
deleteFile,
downloadFileByPath,
downloadAllFiles,
uploadFiles,
uploadDataTransferItems,
deleteAllFiles,
} = useFilesApi(workspaceId, root);
// PR-D: track whether the user is currently dragging files OVER
// the root area (not over a specific subdir row). Used to show
// the "Drop to upload to root" highlight on the tree column.
const [rootDragHover, setRootDragHover] = useState(false);
const handleDropToTarget = (
targetDir: string,
items: DataTransferItemList,
) => {
// canDelete is the gate proxy — same constraint as the toolbar
// Upload button (today only /configs is writable from the canvas
// surface). Without this check, dropping on /home would post
// through /workspaces/<id>/files/<path>, which the backend would
// reject only after an HTTP round-trip. Fail fast.
if (root !== "/configs") {
setError(
`Upload only allowed in /configs (current root: ${root}). Switch root or use Upload button.`,
);
return;
}
void uploadDataTransferItems(items, targetDir);
};
const tree = useMemo(() => buildTree(files), [files]);
const openFile = async (path: string) => {
@@ -190,8 +248,46 @@ export function FilesTab({ workspaceId }: Props) {
)}
<div className="flex flex-1 min-h-0">
{/* File tree */}
<div className="w-[180px] border-r border-line/40 overflow-y-auto shrink-0">
{/* File tree column. PR-D: outer div is the drop zone for
"drop on root" — when the user drags into the column area
(not over a specific subdir row), the drop targets the
current root directory. Subdirectory rows in <FileTree>
stop propagation on their own drop event so a drop on
/configs/skills doesn't ALSO fire root-area drop. */}
<div
className={`w-[180px] border-r border-line/40 overflow-y-auto shrink-0 transition-colors ${
rootDragHover ? "bg-accent/10 outline outline-1 outline-accent/40 -outline-offset-2" : ""
}`}
onDragOver={(e) => {
// Only highlight + accept the drop when uploads are
// actually allowed for the current root. Without this
// check the user gets a misleading drag affordance,
// drops, then sees the toolbar's "switch root" toast —
// bad UX.
if (root !== "/configs") return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
}}
onDragEnter={(e) => {
if (root !== "/configs") return;
e.preventDefault();
setRootDragHover(true);
}}
onDragLeave={(e) => {
const next = e.relatedTarget as Node | null;
if (!next || !(e.currentTarget as HTMLElement).contains(next)) {
setRootDragHover(false);
}
}}
onDrop={(e) => {
if (root !== "/configs") return;
e.preventDefault();
setRootDragHover(false);
if (e.dataTransfer.items?.length) {
handleDropToTarget("", e.dataTransfer.items);
}
}}
>
{/* New file input */}
{showNewFile && (
<div className="px-2 py-1 border-b border-line/40">
@@ -209,14 +305,27 @@ export function FilesTab({ workspaceId }: Props) {
{files.length === 0 ? (
<div className="px-3 py-4 text-[10px] text-ink-soft text-center">
No config files yet
{rootDragHover
? "Drop to upload to root"
: root === "/configs"
? "No config files yet — drag files here to upload"
: "No config files yet"}
</div>
) : (
<FileTree
nodes={tree}
selectedPath={selectedFile}
onSelect={openFile}
// Delete is currently gated to /configs to match the
// toolbar's New / Upload / Clear affordances. Context
// menu and inline ✕ both honour the gate. PR-A made the
// backend EIC delete work on all roots — keeping the
// canvas gate conservative until we want to expose
// /home /workspace deletion intentionally.
onDelete={root === "/configs" ? setConfirmDelete : () => {}}
onDownload={downloadFileByPath}
canDelete={root === "/configs"}
onDropToTarget={handleDropToTarget}
expandedDirs={expandedDirs}
onToggleDir={toggleDir}
loadingDir={loadingDir}
@@ -1,41 +1,129 @@
"use client";
import { useState } from "react";
import { type TreeNode, getIcon } from "./tree";
import { FileTreeContextMenu, type MenuItem } from "./FileTreeContextMenu";
interface TreeCallbacks {
selectedPath: string | null;
onSelect: (path: string) => void;
onDelete: (path: string) => void;
/** PR-C: right-click → Download. Files only — directories ignore. */
onDownload: (path: string) => void;
/** Whether the active root permits delete. Wire into the Delete
* context-menu item's `disabled` flag so the user gets the same
* affordance as the toolbar (which gates Clear/New on /configs). */
canDelete: boolean;
/** PR-D: drop files/folders from the OS onto this row. targetDir
* is the directory path (relative to the active root) under which
* the dropped contents should land; "" means root. */
onDropToTarget?: (targetDir: string, items: DataTransferItemList) => void;
expandedDirs: Set<string>;
onToggleDir: (path: string) => void;
loadingDir: string | null;
}
/**
* FileTree renders the workspace tree + owns the right-click context
* menu (PR-C) and the drop-target hover state (PR-D). Lifting the
* menu state here (vs each row) means only one menu open at a time —
* opening a new row's menu auto-closes the prior one. Same UX as
* VSCode / Theia.
*/
export function FileTree({
nodes,
selectedPath,
onSelect,
onDelete,
onDownload,
canDelete,
onDropToTarget,
expandedDirs,
onToggleDir,
loadingDir,
depth = 0,
}: TreeCallbacks & { nodes: TreeNode[]; depth?: number }) {
const [menu, setMenu] = useState<{
x: number;
y: number;
items: MenuItem[];
} | null>(null);
// PR-D: hover-target highlight state for drag-drop. Lifted next to
// the menu state so both shared-across-rows interactions live in
// one place.
const [hoverDir, setHoverDir] = useState<string | null>(null);
const openContextMenu = (e: React.MouseEvent, node: TreeNode) => {
e.preventDefault();
// Items composed per-row so the available actions reflect the
// node type (files get Open + Download; directories get Delete
// only since "open a directory in the editor" doesn't apply
// and "Export folder" is the toolbar's job).
const items: MenuItem[] = [];
if (!node.isDir) {
items.push({
id: "open",
label: "Open",
icon: "⤴",
onClick: () => onSelect(node.path),
});
items.push({
id: "download",
label: "Download",
icon: "↓",
onClick: () => onDownload(node.path),
});
}
items.push({
id: "delete",
label: "Delete",
icon: "✕",
destructive: true,
disabled: !canDelete,
onClick: () => onDelete(node.path),
});
setMenu({ x: e.clientX, y: e.clientY, items });
};
// Single state lifted to the top-level tree; nested <FileTree>s
// (rendered for expanded directories below) do NOT instantiate
// their own menus or drop-targets — they call back via prop
// drilling. This keeps "only one menu open" + "only one drop
// target highlighted" as structural invariants rather than
// render-order coincidences.
const childCallbacks: TreeCallbacks = {
selectedPath,
onSelect,
onDelete,
onDownload,
canDelete,
onDropToTarget,
expandedDirs,
onToggleDir,
loadingDir,
};
return (
<div>
{nodes.map((node) => (
<TreeItem
key={`${node.path}:${node.isDir ? "dir" : "file"}`}
node={node}
selectedPath={selectedPath}
onSelect={onSelect}
onDelete={onDelete}
expandedDirs={expandedDirs}
onToggleDir={onToggleDir}
loadingDir={loadingDir}
openContextMenu={openContextMenu}
hoverDir={hoverDir}
setHoverDir={setHoverDir}
depth={depth}
{...childCallbacks}
/>
))}
{menu && (
<FileTreeContextMenu
x={menu.x}
y={menu.y}
items={menu.items}
onClose={() => setMenu(null)}
/>
)}
</div>
);
}
@@ -45,22 +133,81 @@ function TreeItem({
selectedPath,
onSelect,
onDelete,
onDownload,
canDelete,
onDropToTarget,
expandedDirs,
onToggleDir,
loadingDir,
depth,
}: TreeCallbacks & { node: TreeNode; depth: number }) {
openContextMenu,
hoverDir,
setHoverDir,
}: TreeCallbacks & {
node: TreeNode;
depth: number;
openContextMenu: (e: React.MouseEvent, node: TreeNode) => void;
hoverDir: string | null;
setHoverDir: (p: string | null) => void;
}) {
const isSelected = selectedPath === node.path;
const expanded = expandedDirs.has(node.path);
const isLoading = loadingDir === node.path;
const isDropTarget = node.isDir && hoverDir === node.path;
// PR-D drag handlers — only directory rows are valid drop targets
// (dropping a file ON another file is ambiguous; treat it as
// dropping in the parent dir, which the root area handles). When a
// drag enters a directory row, mark it the hover target. When the
// cursor leaves to a non-child element, clear it. drop fires the
// upload callback with the row's path.
const dragProps = node.isDir && onDropToTarget
? {
onDragOver: (e: React.DragEvent) => {
// preventDefault is REQUIRED to opt this element into the
// drop target list — without it, browsers refuse to fire
// the drop event regardless of the drop handler.
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
},
onDragEnter: (e: React.DragEvent) => {
e.preventDefault();
setHoverDir(node.path);
},
onDragLeave: (e: React.DragEvent) => {
// Only clear hover when leaving to an element OUTSIDE this
// row — bare leave-events fire for every child crossed
// (the icon, the label, the ✕ button). Without the
// contains() check the highlight flickers.
const next = e.relatedTarget as Node | null;
if (!next || !(e.currentTarget as HTMLElement).contains(next)) {
setHoverDir(null);
}
},
onDrop: (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setHoverDir(null);
if (e.dataTransfer.items?.length) {
onDropToTarget(node.path, e.dataTransfer.items);
}
},
}
: {};
if (node.isDir) {
return (
<div>
<div
className="group w-full flex items-center gap-1 px-2 py-0.5 text-left hover:bg-surface-card/40 transition-colors cursor-pointer"
className={`group w-full flex items-center gap-1 px-2 py-0.5 text-left transition-colors cursor-pointer ${
isDropTarget
? "bg-accent/20 outline outline-1 outline-accent/60"
: "hover:bg-surface-card/40"
}`}
style={{ paddingLeft: `${depth * 12 + 8}px` }}
onClick={() => onToggleDir(node.path)}
onContextMenu={(e) => openContextMenu(e, node)}
{...dragProps}
>
<span className="text-[9px] text-ink-soft w-3">{isLoading ? "…" : expanded ? "▼" : "▶"}</span>
<span className="text-[10px]">📁</span>
@@ -82,6 +229,9 @@ function TreeItem({
selectedPath={selectedPath}
onSelect={onSelect}
onDelete={onDelete}
onDownload={onDownload}
canDelete={canDelete}
onDropToTarget={onDropToTarget}
expandedDirs={expandedDirs}
onToggleDir={onToggleDir}
loadingDir={loadingDir}
@@ -99,6 +249,7 @@ function TreeItem({
}`}
style={{ paddingLeft: `${depth * 12 + 20}px` }}
onClick={() => onSelect(node.path)}
onContextMenu={(e) => openContextMenu(e, node)}
>
<span className="text-[9px]">{getIcon(node.name, false)}</span>
<span className="text-[10px] flex-1 truncate font-mono">{node.name}</span>
@@ -0,0 +1,141 @@
"use client";
import { useEffect, useRef } from "react";
/**
* FileTreeContextMenu — VSCode-style right-click menu for a single
* file-tree row. Pops at the cursor's viewport coords; dismisses on
* outside-click, Esc, blur, or scroll.
*
* Why a custom component (no library): the menu is one of several
* "small popovers" in canvas; pulling in a dnd / popover lib for one
* surface adds 10x the bytes of this implementation. The patterns
* (outside-click + Esc + portal-free fixed position) match the
* ContextMenu used in canvas/Toolbar so the keyboard-nav muscle
* memory is uniform.
*
* Items are rendered from a `MenuItem[]` so callers can add/remove
* actions without touching this component (e.g. PR-D will add an
* "Upload to this folder" item for directory rows).
*
* Accessibility:
* - role="menu" + role="menuitem" so screen readers announce the
* surface as a menu, not a generic div.
* - First item gets autofocus so keyboard users can ↓/↑/Enter without
* reaching for the mouse.
* - Esc + outside-click + Tab dismisses; behaves like every other
* menu the user has touched on the canvas.
*/
export interface MenuItem {
/** Stable identifier for testing + analytics. */
id: string;
label: string;
/** Optional left icon glyph; not load-bearing. */
icon?: string;
/** Destructive (rendered in red) — for Delete-class actions. */
destructive?: boolean;
/** Item-specific click handler. The menu auto-closes after onClick
* fires so handlers don't have to call onClose themselves. */
onClick: () => void;
/** Disabled items render but don't fire onClick (useful for
* Delete-on-non-/configs case where the caller wants to surface
* the item but explain it's gated). Currently unused — placeholder
* for future options. */
disabled?: boolean;
}
interface Props {
/** Viewport-coordinate position of the cursor that opened the menu. */
x: number;
y: number;
items: MenuItem[];
onClose: () => void;
}
export function FileTreeContextMenu({ x, y, items, onClose }: Props) {
const ref = useRef<HTMLDivElement>(null);
// First item gets initial focus for keyboard ↓/↑/Enter nav.
const firstItemRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
firstItemRef.current?.focus();
}, []);
// Outside-click + Esc dismiss. Per memory
// (feedback_abort_controller_for_rerendered_listeners), use an
// AbortController so re-mounts (caller toggles the menu) don't leak
// listeners.
useEffect(() => {
const ctrl = new AbortController();
const onPointerDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
// Roving focus across .menuitem buttons. Doing this with
// tabindex management because Tab / Shift+Tab leave the menu
// (which is the right thing — the user is escaping the menu).
e.preventDefault();
const buttons = ref.current?.querySelectorAll<HTMLButtonElement>(
"[role='menuitem']:not([disabled])",
);
if (!buttons || buttons.length === 0) return;
const arr = Array.from(buttons);
const cur = arr.indexOf(document.activeElement as HTMLButtonElement);
const next =
e.key === "ArrowDown"
? (cur + 1) % arr.length
: (cur - 1 + arr.length) % arr.length;
arr[next].focus();
}
};
// `mousedown` (not `click`) so the menu dismisses BEFORE the
// tree-row's click handler would fire — otherwise clicking
// outside also selects a different row, which is not what the
// user expected when "outside-click closes the menu".
document.addEventListener("mousedown", onPointerDown, { signal: ctrl.signal });
document.addEventListener("keydown", onKeyDown, { signal: ctrl.signal });
// Scroll inside any ancestor also dismisses — the fixed-position
// menu would otherwise stay anchored to viewport coords while the
// row it points at scrolled away. Use capture so we catch scroll
// on inner panels (FileTree's overflow-y-auto wrapper).
document.addEventListener("scroll", onClose, { signal: ctrl.signal, capture: true });
return () => ctrl.abort();
}, [onClose]);
return (
<div
ref={ref}
role="menu"
aria-label="File actions"
className="fixed z-[1000] min-w-[140px] py-1 bg-surface-elevated border border-line/60 rounded-md shadow-xl shadow-black/30 text-[11px]"
style={{ left: x, top: y }}
>
{items.map((item, i) => (
<button
key={item.id}
ref={i === 0 ? firstItemRef : undefined}
type="button"
role="menuitem"
disabled={item.disabled}
onClick={() => {
if (item.disabled) return;
item.onClick();
onClose();
}}
className={
item.destructive
? "w-full text-left px-3 py-1 text-bad hover:bg-red-900/30 focus:bg-red-900/30 focus:outline-none disabled:opacity-40 disabled:pointer-events-none transition-colors"
: "w-full text-left px-3 py-1 text-ink-mid hover:bg-surface-card hover:text-ink focus:bg-surface-card focus:text-ink focus:outline-none disabled:opacity-40 disabled:pointer-events-none transition-colors"
}
>
{item.icon && <span className="inline-block w-4 mr-1.5 text-ink-soft">{item.icon}</span>}
{item.label}
</button>
))}
</div>
);
}
@@ -0,0 +1,58 @@
"use client";
/**
* NotAvailablePanel — full-tab placeholder for runtimes whose filesystem
* the platform doesn't own (today: runtime === "external").
*
* Pre-fix the FilesTab tried to GET /workspaces/<id>/files for these
* workspaces. The platform answered with [] (no rows in workspace_files
* for an external workspace by definition), but the canvas rendered
* "0 files / No config files yet" which reads identically to the SaaS
* empty-listing bug fixed in PR-A. Showing an explicit placeholder
* makes the absence intentional and routes the user toward the
* supported surface (Chat) for these workspaces.
*
* Mirrors the same affordance TerminalTab adopted for runtimes without
* a TTY in PR #2830 — uniform "feature-not-applicable" UX across tabs.
*/
export function NotAvailablePanel({ runtime }: { runtime: string }) {
return (
<div className="flex flex-col items-center justify-center h-full p-8 text-center bg-surface-sunken/30">
{/* Folder-with-slash icon. Custom inline SVG so we don't depend
on an icon set being present at canvas build-time (matches
TerminalTab's NotAvailablePanel pattern). */}
<svg
width="72"
height="72"
viewBox="0 0 72 72"
fill="none"
aria-hidden="true"
className="text-ink-soft mb-4"
>
{/* Folder body */}
<path
d="M10 22 L10 56 a4 4 0 0 0 4 4 L58 60 a4 4 0 0 0 4 -4 L62 26 a4 4 0 0 0 -4 -4 L34 22 L28 16 L14 16 a4 4 0 0 0 -4 4 Z"
stroke="currentColor"
strokeWidth="2.5"
strokeLinejoin="round"
fill="none"
opacity="0.6"
/>
{/* Diagonal cancel slash */}
<path
d="M14 14 L58 58"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
/>
</svg>
<h3 className="text-sm font-medium text-ink mb-1.5">Files not available</h3>
<p className="text-[11px] text-ink-soft max-w-xs leading-relaxed">
This workspace runs the{" "}
<span className="font-mono text-ink-mid">{runtime}</span> runtime,
whose filesystem isn't owned by the platform. Use the Chat tab to
interact with the agent directly.
</p>
</div>
);
}
@@ -0,0 +1,136 @@
// @vitest-environment jsdom
//
// Pins the right-click context menu added in PR-C of issue #2999.
// VSCode-style affordance: Open / Download / Delete on file rows,
// Delete on directory rows. Delete is gated by `canDelete` (parent
// only enables on /configs root, matching the toolbar's gate).
//
// Pinned branches:
// 1. Right-click on a file row opens the menu at the click coords
// with Open + Download + Delete items.
// 2. Right-click on a directory row opens the menu with Delete
// only (no Open/Download — directories don't have one-click
// semantics in this surface).
// 3. Clicking Download fires the onDownload callback with the
// row's path.
// 4. Clicking Delete fires onDelete with the row's path (when
// canDelete=true).
// 5. Delete is disabled in the rendered menu when canDelete=false
// and clicking it does NOT fire onDelete (gate is real).
// 6. Esc dismisses the menu.
// 7. Click outside the menu dismisses it.
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, cleanup, fireEvent, act } from "@testing-library/react";
import React from "react";
import { FileTree } from "../FileTree";
import type { TreeNode } from "../tree";
afterEach(cleanup);
const file: TreeNode = { name: "config.yaml", path: "config.yaml", isDir: false, children: [], size: 0 };
const dir: TreeNode = {
name: "skills",
path: "skills",
isDir: true,
children: [],
size: 0,
};
function renderTree(props: Partial<React.ComponentProps<typeof FileTree>> = {}) {
const defaults = {
nodes: [file, dir],
selectedPath: null,
onSelect: vi.fn(),
onDelete: vi.fn(),
onDownload: vi.fn(),
canDelete: true,
expandedDirs: new Set<string>(),
onToggleDir: vi.fn(),
loadingDir: null,
};
const merged = { ...defaults, ...props };
return { ...render(<FileTree {...merged} />), props: merged };
}
describe("FileTree right-click context menu", () => {
it("right-click on a file row opens menu with Open/Download/Delete", () => {
renderTree();
fireEvent.contextMenu(screen.getByText("config.yaml"), {
clientX: 50,
clientY: 100,
});
expect(screen.getByRole("menu")).not.toBeNull();
expect(screen.getByRole("menuitem", { name: /Open/i })).not.toBeNull();
expect(screen.getByRole("menuitem", { name: /Download/i })).not.toBeNull();
expect(screen.getByRole("menuitem", { name: /Delete/i })).not.toBeNull();
});
it("right-click on a directory row opens menu with Delete only (no Open/Download)", () => {
renderTree();
fireEvent.contextMenu(screen.getByText("skills"), { clientX: 60, clientY: 120 });
expect(screen.getByRole("menu")).not.toBeNull();
expect(screen.queryByRole("menuitem", { name: /Open/i })).toBeNull();
expect(screen.queryByRole("menuitem", { name: /Download/i })).toBeNull();
expect(screen.getByRole("menuitem", { name: /Delete/i })).not.toBeNull();
});
it("clicking Download fires onDownload with the row's path", () => {
const { props } = renderTree();
fireEvent.contextMenu(screen.getByText("config.yaml"), { clientX: 0, clientY: 0 });
fireEvent.click(screen.getByRole("menuitem", { name: /Download/i }));
expect(props.onDownload).toHaveBeenCalledWith("config.yaml");
// Menu auto-closes after click.
expect(screen.queryByRole("menu")).toBeNull();
});
it("clicking Delete fires onDelete with the row's path when canDelete=true", () => {
const { props } = renderTree({ canDelete: true });
fireEvent.contextMenu(screen.getByText("config.yaml"), { clientX: 0, clientY: 0 });
fireEvent.click(screen.getByRole("menuitem", { name: /Delete/i }));
expect(props.onDelete).toHaveBeenCalledWith("config.yaml");
});
it("Delete is disabled when canDelete=false; clicking does not fire onDelete", () => {
const { props } = renderTree({ canDelete: false });
fireEvent.contextMenu(screen.getByText("config.yaml"), { clientX: 0, clientY: 0 });
const del = screen.getByRole("menuitem", { name: /Delete/i }) as HTMLButtonElement;
expect(del.disabled).toBe(true);
fireEvent.click(del);
expect(props.onDelete).not.toHaveBeenCalled();
// Menu stays open on disabled click — same as VSCode (the user
// can read the disabled-state hint without losing the menu).
expect(screen.getByRole("menu")).not.toBeNull();
});
it("Esc dismisses the menu", () => {
renderTree();
fireEvent.contextMenu(screen.getByText("config.yaml"), { clientX: 0, clientY: 0 });
expect(screen.getByRole("menu")).not.toBeNull();
act(() => {
fireEvent.keyDown(document, { key: "Escape" });
});
expect(screen.queryByRole("menu")).toBeNull();
});
it("click outside the menu dismisses it", () => {
renderTree();
fireEvent.contextMenu(screen.getByText("config.yaml"), { clientX: 0, clientY: 0 });
expect(screen.getByRole("menu")).not.toBeNull();
// mousedown on document.body — outside the menu.
act(() => {
fireEvent.mouseDown(document.body);
});
expect(screen.queryByRole("menu")).toBeNull();
});
it("opening a second context menu replaces the first (only one open at a time)", () => {
renderTree();
fireEvent.contextMenu(screen.getByText("config.yaml"), { clientX: 10, clientY: 10 });
fireEvent.contextMenu(screen.getByText("skills"), { clientX: 20, clientY: 20 });
// Only one menu in the DOM. The second open replaced the first
// because the menu state is lifted to the FileTree, not per-row.
const menus = screen.getAllByRole("menu");
expect(menus.length).toBe(1);
});
});
@@ -0,0 +1,212 @@
// @vitest-environment jsdom
//
// Pins the drag-drop upload added in PR-D of issue #2999.
// Two layers of coverage:
//
// 1. The pure walker (collectFileEntries / walkEntry) — pins the
// recursion shape against silent folder truncation. Browsers
// return up to ~100 entries per readEntries() call; if the loop
// stops early, large folder uploads silently drop files. We
// simulate a multi-batch reader to discriminate.
//
// 2. FileTree directory-row drop handlers — pins that dragover/drop
// events fire onDropToTarget with the directory's path + the
// drop's DataTransferItemList.
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
import React from "react";
import { FileTree } from "../FileTree";
import type { TreeNode } from "../tree";
import { __testables } from "../useFilesApi";
afterEach(cleanup);
// ---- Walker tests ----
/**
* Build a fake FileSystemEntry tree we can hand to walkEntry. The
* shape mimics what webkitGetAsEntry returns from a real OS drag —
* directory entries expose createReader, file entries expose file().
*/
function fakeFileEntry(name: string, content = "x"): {
isFile: true;
isDirectory: false;
name: string;
fullPath: string;
file: (cb: (f: File) => void) => void;
} {
return {
isFile: true,
isDirectory: false,
name,
fullPath: "/" + name,
file: (cb) => cb(new File([content], name, { type: "text/plain" })),
};
}
function fakeDirEntry(
name: string,
childBatches: ReturnType<typeof fakeFileEntry>[][],
): {
isFile: false;
isDirectory: true;
name: string;
fullPath: string;
createReader: () => { readEntries: (cb: (entries: unknown[]) => void) => void };
} {
let i = 0;
return {
isFile: false,
isDirectory: true,
name,
fullPath: "/" + name,
createReader: () => ({
readEntries: (cb) => {
// Mimic browser semantics: emit one batch per call, then
// an empty array to signal end-of-stream. A walker that
// calls readEntries only once would silently truncate at
// the first batch.
if (i < childBatches.length) {
cb(childBatches[i++]);
} else {
cb([]);
}
},
}),
};
}
describe("walkEntry — folder-recursion drop walker", () => {
it("collects a single dropped file", async () => {
const out: { file: File; relativePath: string }[] = [];
await __testables.walkEntry(fakeFileEntry("README.md") as never, "", out);
expect(out.length).toBe(1);
expect(out[0].relativePath).toBe("README.md");
expect(out[0].file.name).toBe("README.md");
});
it("walks a folder and preserves the relative path under the folder name", async () => {
const out: { file: File; relativePath: string }[] = [];
const folder = fakeDirEntry("skills", [
[fakeFileEntry("a.md"), fakeFileEntry("b.md")],
]);
await __testables.walkEntry(folder as never, "", out);
expect(out.map((e) => e.relativePath).sort()).toEqual([
"skills/a.md",
"skills/b.md",
]);
});
it("loops readEntries until empty so a multi-batch folder isn't truncated", async () => {
// Browsers limit each readEntries() call to ~100 entries. Our
// walker MUST call it again until an empty batch is returned.
// Fake reader emits two batches of 2 + an implicit empty → 4
// total. A buggy walker that only takes the first batch would
// see only 2.
const out: { file: File; relativePath: string }[] = [];
const folder = fakeDirEntry("big", [
[fakeFileEntry("1.txt"), fakeFileEntry("2.txt")],
[fakeFileEntry("3.txt"), fakeFileEntry("4.txt")],
]);
await __testables.walkEntry(folder as never, "", out);
expect(out.length).toBe(4);
});
it("walks nested directories and accumulates the full path", async () => {
const out: { file: File; relativePath: string }[] = [];
const inner = fakeDirEntry("web-search", [[fakeFileEntry("SKILL.md")]]);
// Outer dir whose first batch contains a sub-dir entry.
const outer = {
isFile: false,
isDirectory: true,
name: "skills",
fullPath: "/skills",
createReader: () => {
let i = 0;
return {
readEntries: (cb: (entries: unknown[]) => void) => {
if (i++ === 0) cb([inner]);
else cb([]);
},
};
},
};
await __testables.walkEntry(outer as never, "", out);
expect(out.length).toBe(1);
expect(out[0].relativePath).toBe("skills/web-search/SKILL.md");
});
});
// ---- FileTree drag-drop wiring ----
const file: TreeNode = { name: "config.yaml", path: "config.yaml", isDir: false, children: [], size: 0 };
const skillsDir: TreeNode = { name: "skills", path: "skills", isDir: true, children: [], size: 0 };
function renderTree(props: Partial<React.ComponentProps<typeof FileTree>> = {}) {
// PR-D test defaults must include PR-C's onDownload + canDelete now
// that they're required on the TreeCallbacks shape (the rebase
// surfaced this — the merged tree depends on both feature sets).
const defaults: React.ComponentProps<typeof FileTree> = {
nodes: [file, skillsDir],
selectedPath: null,
onSelect: vi.fn(),
onDelete: vi.fn(),
onDownload: vi.fn(),
canDelete: true,
onDropToTarget: vi.fn(),
expandedDirs: new Set<string>(),
onToggleDir: vi.fn(),
loadingDir: null,
};
const merged = { ...defaults, ...props };
return { ...render(<FileTree {...merged} />), props: merged };
}
describe("FileTree directory-row drag-drop", () => {
it("dragover on a directory row preventDefault's so the drop will fire", () => {
renderTree();
const row = screen.getByText("skills");
const dragOver = new Event("dragover", { bubbles: true, cancelable: true });
Object.defineProperty(dragOver, "dataTransfer", {
value: { dropEffect: "" },
});
row.parentElement!.dispatchEvent(dragOver);
// preventDefault registers via the React handler — without it
// the drop event would never fire, so this assertion is the
// load-bearing one.
expect(dragOver.defaultPrevented).toBe(true);
});
it("drop on a directory row fires onDropToTarget with that path + the items list", () => {
const { props } = renderTree();
const row = screen.getByText("skills").parentElement!;
const fakeItems = { length: 1, 0: { kind: "file" } } as unknown as DataTransferItemList;
fireEvent.drop(row, { dataTransfer: { items: fakeItems } });
expect(props.onDropToTarget).toHaveBeenCalledWith("skills", fakeItems);
});
it("drop on a FILE row does NOT fire onDropToTarget (only directories are valid targets)", () => {
const { props } = renderTree();
const fileRow = screen.getByText("config.yaml").parentElement!;
const fakeItems = { length: 1, 0: { kind: "file" } } as unknown as DataTransferItemList;
fireEvent.drop(fileRow, { dataTransfer: { items: fakeItems } });
expect(props.onDropToTarget).not.toHaveBeenCalled();
});
it("drop with no DataTransferItems does NOT fire onDropToTarget", () => {
const { props } = renderTree();
const row = screen.getByText("skills").parentElement!;
fireEvent.drop(row, { dataTransfer: { items: { length: 0 } } });
expect(props.onDropToTarget).not.toHaveBeenCalled();
});
it("dragenter sets the drop-target highlight on the directory row", () => {
renderTree();
const row = screen.getByText("skills").parentElement!;
fireEvent.dragEnter(row, { dataTransfer: {} });
// Highlight class is the discriminator — without dragenter
// wiring the row stays in its hover-only style.
expect(row.className).toMatch(/bg-accent|outline-accent/);
});
});
@@ -90,6 +90,43 @@ export function useFilesApi(workspaceId: string, root: string) {
[workspaceId]
);
/**
* Fetch a file's content from the server and trigger a browser
* download. Used by the right-click "Download" context-menu item
* (PR-C of issue #2999) — distinct from `handleDownloadFile` in
* FilesTab which downloads the CURRENTLY-OPEN-IN-EDITOR file from
* the in-memory `editContent` buffer (so unsaved edits round-trip
* to disk). This helper downloads the on-server content, suitable
* for arbitrary tree rows the user hasn't opened.
*/
const downloadFileByPath = useCallback(
async (path: string) => {
try {
const res = await api.get<{ content: string }>(
`/workspaces/${workspaceId}/files/${path}?root=${encodeURIComponent(root)}`,
);
// text/plain is correct for the canvas's text-only file
// surface (config.yaml, prompts, skill markdown). Binary
// files would need an Accept-arraybuffer path; the API
// returns string today so this matches the wire shape.
const blob = new Blob([res.content], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = path.split("/").pop() || "file";
a.click();
URL.revokeObjectURL(url);
showToast(`Downloaded ${a.download}`, "success");
} catch (e) {
showToast(
`Download failed: ${e instanceof Error ? e.message : "unknown error"}`,
"error",
);
}
},
[workspaceId, root],
);
const downloadAllFiles = useCallback(async () => {
const fileEntries = files.filter((f) => !f.dir);
const results = await Promise.allSettled(
@@ -114,16 +151,20 @@ export function useFilesApi(workspaceId: string, root: string) {
}, [files, workspaceId]);
const uploadFiles = useCallback(
async (fileList: FileList) => {
async (fileList: FileList, targetDir = "") => {
let uploaded = 0;
for (const file of Array.from(fileList)) {
const path = file.webkitRelativePath || file.name;
const parts = path.split("/");
// For folder picker: webkitRelativePath is "<picked-folder>/a/b.txt"
// — strip the picked-folder prefix so files land flat under the
// workspace's target dir, not under a redundant outer folder.
const relPath = parts.length > 1 ? parts.slice(1).join("/") : parts[0];
const finalPath = targetDir ? `${targetDir}/${relPath}` : relPath;
if (file.size > 1_000_000) continue;
try {
const content = await file.text();
await api.put(`/workspaces/${workspaceId}/files/${relPath}`, { content });
await api.put(`/workspaces/${workspaceId}/files/${finalPath}`, { content });
uploaded++;
} catch {
/* skip binary */
@@ -131,7 +172,7 @@ export function useFilesApi(workspaceId: string, root: string) {
}
if (uploaded > 0) {
useCanvasStore.getState().updateNodeData(workspaceId, { needsRestart: true });
showToast(`Uploaded ${uploaded} files`, "success");
showToast(`Uploaded ${uploaded} files${targetDir ? ` to ${targetDir}` : ""}`, "success");
loadFiles();
}
return uploaded;
@@ -139,6 +180,58 @@ export function useFilesApi(workspaceId: string, root: string) {
[workspaceId, loadFiles]
);
/**
* Upload files dragged from the OS via the HTML5 DataTransferItemList
* API. Unlike the folder-picker path (uploadFiles), this preserves
* the dropped folder structure under `targetDir` — drag a "skills/"
* folder onto the /configs/skills row and you get
* /configs/skills/skills/* (the OUTER folder name is preserved
* because the user explicitly chose to drop a NAMED folder, unlike
* the folder-picker which always wraps the picked dir).
*
* Walks FileSystemDirectoryEntry recursively via webkitGetAsEntry.
* VSCode/JupyterLab use the same primitive — there's no other
* portable browser API for "drag a folder from OS". `webkit*`
* naming is a Chromium relic; Firefox + Safari implement the same
* surface.
*
* Returns the number of files uploaded so the caller can show a
* tally / fail toast.
*/
const uploadDataTransferItems = useCallback(
async (items: DataTransferItemList, targetDir = "") => {
const fileEntries = collectFileEntries(items);
let uploaded = 0;
for (const { file, relativePath } of await fileEntries) {
if (file.size > 1_000_000) continue;
const finalPath = targetDir
? `${targetDir}/${relativePath}`
: relativePath;
try {
const content = await file.text();
await api.put(`/workspaces/${workspaceId}/files/${finalPath}`, {
content,
});
uploaded++;
} catch {
/* skip binary */
}
}
if (uploaded > 0) {
useCanvasStore
.getState()
.updateNodeData(workspaceId, { needsRestart: true });
showToast(
`Uploaded ${uploaded} file${uploaded === 1 ? "" : "s"}${targetDir ? ` to ${targetDir}` : ""}`,
"success",
);
loadFiles();
}
return uploaded;
},
[workspaceId, loadFiles],
);
const deleteAllFiles = useCallback(async () => {
let deleted = 0;
for (const f of files) {
@@ -165,8 +258,98 @@ export function useFilesApi(workspaceId: string, root: string) {
readFile,
writeFile,
deleteFile,
downloadFileByPath,
downloadAllFiles,
uploadFiles,
uploadDataTransferItems,
deleteAllFiles,
};
}
// ----- DataTransfer entry walker (PR-D) ---------------------------------
/**
* Minimal subset of the FileSystem Entry API surface we use. The DOM
* lib types this as FileSystemEntry / FileSystemFileEntry /
* FileSystemDirectoryEntry but the relevant methods are callback-
* based. Keep the shape narrow + explicit so the recursion below
* type-checks without pulling in the full DOM lib types.
*/
interface FSEntry {
isFile: boolean;
isDirectory: boolean;
name: string;
fullPath: string;
file?(success: (f: File) => void, fail?: (e: unknown) => void): void;
createReader?(): { readEntries(success: (entries: FSEntry[]) => void): void };
}
interface CollectedEntry {
file: File;
/** Path relative to the dropped root (e.g. "skills/web-search/SKILL.md"
* for a dropped "skills/" folder containing web-search/SKILL.md). */
relativePath: string;
}
/**
* Walk a DataTransferItemList, returning every file entry as a flat
* array keyed by the path relative to the originally-dropped item.
* Folders dropped from the OS expand recursively; loose files
* passthrough with name as the relative path.
*
* Skips items where webkitGetAsEntry() returns null — that's how
* the browser signals a non-file payload (e.g. a dragged URL or
* text snippet).
*/
async function collectFileEntries(
items: DataTransferItemList,
): Promise<CollectedEntry[]> {
const out: CollectedEntry[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind !== "file") continue;
// webkitGetAsEntry is the standardised name; older Firefox used
// getAsEntry. Both Chromium + Firefox + Safari ship the webkit-
// prefixed variant today. There's no non-prefixed alternative.
const entry = (item as DataTransferItem & {
webkitGetAsEntry?: () => FSEntry | null;
}).webkitGetAsEntry?.();
if (!entry) continue;
await walkEntry(entry, "", out);
}
return out;
}
async function walkEntry(
entry: FSEntry,
prefix: string,
out: CollectedEntry[],
): Promise<void> {
const name = entry.name;
const relPath = prefix ? `${prefix}/${name}` : name;
if (entry.isFile && entry.file) {
const file = await new Promise<File>((resolve, reject) => {
entry.file!(resolve, reject);
});
out.push({ file, relativePath: relPath });
return;
}
if (entry.isDirectory && entry.createReader) {
const reader = entry.createReader();
// readEntries returns up to ~100 at a time on Chromium; loop
// until empty so large folders aren't truncated.
let batch: FSEntry[] = [];
do {
batch = await new Promise<FSEntry[]>((resolve) =>
reader.readEntries(resolve),
);
for (const child of batch) {
await walkEntry(child, relPath, out);
}
} while (batch.length > 0);
}
}
// Exported for direct testing — the recursion + readEntries batching
// is the part most likely to silently truncate a real folder upload.
export const __testables = { collectFileEntries, walkEntry };
+42 -1
View File
@@ -297,10 +297,49 @@ export function SkillsTab({ workspaceId, data }: Props) {
}
};
// Compact-empty pattern: when the workspace has zero plugins
// installed AND the registry isn't open, collapse the whole
// "Plugins" section into a single inline pill rather than rendering
// the full panel chrome. Reported on production 2026-05-05 (#2971):
// the empty state's panel-with-zero-list-rows layout gives the user
// a lot of vertical real estate for content that's just "0
// installed + Install button". The compact form keeps that
// affordance without the chrome.
//
// Expanded/full layout still fires when installed.length > 0 OR
// when the user opens the registry (clicked "+ Install Plugin").
// Once a plugin is installed the section auto-expands to surface
// the list.
const compactEmpty = installed.length === 0 && !showRegistry && installedLoaded;
if (compactEmpty) {
return (
<div className="p-4 space-y-4">
<div
className="flex items-center justify-between gap-2 rounded-full border border-line/60 bg-surface-sunken/70 px-3 py-1.5"
aria-label="Plugins (none installed)"
>
<div className="flex items-center gap-2">
<span className="text-[10px] uppercase tracking-[0.2em] text-ink-soft">Plugins</span>
<span className="text-[11px] text-ink-mid">0 installed</span>
</div>
<button
onClick={() => setShowRegistry(true)}
className="rounded-full border border-violet-700/50 bg-violet-950/30 px-3 py-0.5 text-[10px] text-violet-200 hover:bg-violet-900/40 transition-colors"
aria-expanded="false"
aria-controls="plugins-section"
>
+ Install Plugin
</button>
</div>
</div>
);
}
return (
<div className="p-4 space-y-4">
{/* Plugins section */}
<div className="rounded-xl border border-line bg-surface-sunken/70 p-3">
<div id="plugins-section" className="rounded-xl border border-line bg-surface-sunken/70 p-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-[10px] uppercase tracking-[0.22em] text-ink-soft">Plugins</div>
@@ -311,6 +350,8 @@ export function SkillsTab({ workspaceId, data }: Props) {
<button
onClick={() => setShowRegistry(!showRegistry)}
className="rounded-full border border-violet-700/50 bg-violet-950/30 px-3 py-1 text-[10px] text-violet-200 hover:bg-violet-900/40 transition-colors"
aria-expanded={showRegistry}
aria-controls="plugins-registry"
>
{showRegistry ? "Hide Registry" : "+ Install Plugin"}
</button>
@@ -0,0 +1,119 @@
// @vitest-environment jsdom
//
// Pins the "Files not available" early-return for runtimes whose
// filesystem the platform doesn't own (today: runtime === "external").
//
// Pre-fix: FilesTab issued a GET /workspaces/<id>/files for every
// workspace. The platform's response for an external workspace is
// always [] (no rows in workspace_files), but the canvas rendered
// "0 files / No config files yet" — visually identical to the SaaS
// empty-listing bug fixed in PR-A. The placeholder makes the absence
// intentional.
//
// Pinned branches:
// 1. external runtime → "Files not available" banner renders,
// runtime name surfaces in the body so user knows WHY.
// 2. external runtime → useFilesApi is NOT invoked. Verified by
// asserting the mocked api.get was never called.
// 3. claude-code (or any other runtime) → no banner, normal mount
// proceeds (`/configs` toolbar visible). Pre-fix regression cover.
// 4. data prop omitted (legacy callers) → no early-return, falls
// through to normal mount.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, cleanup, waitFor } from "@testing-library/react";
import React from "react";
afterEach(cleanup);
// Mock the api module so the normal-mount branches don't try to
// fetch against a real backend — and so we can assert the
// external-runtime branch never fires a request.
const apiCalls: string[] = [];
vi.mock("@/lib/api", () => ({
api: {
get: vi.fn((path: string) => {
apiCalls.push(path);
return Promise.resolve([]);
}),
put: vi.fn(() => Promise.resolve()),
del: vi.fn(() => Promise.resolve()),
},
}));
// useCanvasStore is referenced by useFilesApi for the needsRestart
// flag. The Toaster import inside FilesTab also pulls the store
// indirectly. Stub minimally to satisfy the import chain.
vi.mock("@/store/canvas", async () => {
const actual = await vi.importActual<typeof import("@/store/canvas")>(
"@/store/canvas",
);
return {
...actual,
useCanvasStore: {
getState: () => ({
updateNodeData: vi.fn(),
}),
},
};
});
vi.mock("../Toaster", () => ({
showToast: vi.fn(),
}));
beforeEach(() => {
apiCalls.length = 0;
});
import { FilesTab } from "../FilesTab";
const externalData = { runtime: "external", status: "online" } as unknown as Parameters<
typeof FilesTab
>[0]["data"];
const claudeData = { runtime: "claude-code", status: "online" } as unknown as Parameters<
typeof FilesTab
>[0]["data"];
describe("FilesTab not-available early-return for runtimes without platform-owned filesystem", () => {
it("external runtime renders the not-available banner with runtime name", () => {
render(<FilesTab workspaceId="ws-ext" data={externalData} />);
expect(screen.getByText(/Files not available/i)).not.toBeNull();
// Runtime name must surface so the user understands WHY — without
// it the placeholder reads as a generic error.
expect(screen.getByText(/external/)).not.toBeNull();
// Chat tab is the recommended alternative — flagged in copy so the
// user knows where to go next instead of bouncing tabs.
expect(screen.getByText(/Chat tab/i)).not.toBeNull();
});
it("external runtime does NOT issue any /files API call", async () => {
render(<FilesTab workspaceId="ws-ext" data={externalData} />);
// Tolerate one microtask boundary in case useEffect schedules.
await new Promise((r) => setTimeout(r, 0));
const filesCalls = apiCalls.filter((p) => p.includes("/files"));
expect(filesCalls).toEqual([]);
});
it("claude-code runtime does NOT render the banner (normal mount)", async () => {
render(<FilesTab workspaceId="ws-claude" data={claudeData} />);
// The normal-mount path renders the FilesToolbar with the root
// selector. Wait for it (useEffect → loadFiles → setLoading false).
await waitFor(() => {
expect(screen.queryByText(/Files not available/i)).toBeNull();
});
// Toolbar's root selector confirms we're on the platform-owned
// rendering path, not the placeholder.
expect(screen.getByLabelText(/File root directory/i)).not.toBeNull();
});
it("data prop omitted falls through to normal mount (back-compat)", async () => {
render(<FilesTab workspaceId="ws-no-data" />);
await waitFor(() => {
expect(screen.queryByText(/Files not available/i)).toBeNull();
});
// Without data we can't gate on runtime — must mount normally.
expect(screen.getByLabelText(/File root directory/i)).not.toBeNull();
});
});
@@ -0,0 +1,141 @@
// @vitest-environment jsdom
//
// Pins the compact-when-empty layout for the SkillsTab Plugins section
// (issue #2971, reported on production 2026-05-05).
//
// Three states matter for layout:
// 1. installed.length === 0 + registry closed + load completed → COMPACT pill
// 2. installed.length > 0 → FULL panel + installed list
// 3. registry open (showRegistry=true) → FULL panel + registry browser
//
// The compact-empty path is the new behavior; the other two were
// pre-existing. This test pins all three so a future refactor that
// over-collapses (showing compact when plugins are installed) or
// over-expands (showing full panel on empty load) fails loudly.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react";
import React from "react";
afterEach(cleanup);
const apiGet = vi.fn();
vi.mock("@/lib/api", () => ({
api: {
get: (path: string, opts?: unknown) => apiGet(path, opts),
post: vi.fn(() => Promise.resolve({})),
del: vi.fn(),
patch: vi.fn(),
put: vi.fn(),
},
}));
beforeEach(() => {
apiGet.mockReset();
Element.prototype.scrollIntoView = vi.fn();
});
import { SkillsTab } from "../SkillsTab";
const minimalData = {
status: "online" as const,
runtime: "claude-code",
currentTask: "",
agentCard: undefined,
} as unknown as Parameters<typeof SkillsTab>[0]["data"];
describe("SkillsTab Plugins compact-empty layout", () => {
it("renders compact pill when installed.length === 0 and registry closed", async () => {
// Both fetches return empty arrays — workspace is fresh, no plugins.
apiGet.mockImplementation((path: string) => {
if (path.endsWith("/plugins") || path === "/plugins" || path === "/plugins/sources") {
return Promise.resolve([]);
}
return Promise.resolve([]);
});
render(<SkillsTab workspaceId="ws-fresh" data={minimalData} />);
// Wait for the installedLoaded gate to flip — without that the
// component renders a "loading" state, not the compact pill.
await waitFor(() => {
expect(screen.getByLabelText(/Plugins \(none installed\)/i)).toBeTruthy();
});
// Compact assertions: the rounded-xl panel chrome MUST NOT be in
// the DOM (we'd see two "Plugins" labels — one in the header,
// one in the pill — if the layout regressed to "always full
// panel"). The compact form has exactly one "Plugins" label.
const labels = screen.getAllByText("Plugins");
expect(labels).toHaveLength(1);
// The full-panel chrome's id="plugins-section" should NOT be
// rendered when we're in compact mode.
expect(document.getElementById("plugins-section")).toBeNull();
});
it("renders full panel when installed.length > 0", async () => {
apiGet.mockImplementation((path: string) => {
if (path.endsWith("/plugins")) {
return Promise.resolve([
{ name: "memory-postgres", version: "1.0.0", description: "memory backend", supported_on_runtime: true },
]);
}
return Promise.resolve([]);
});
render(<SkillsTab workspaceId="ws-installed" data={minimalData} />);
await waitFor(() => {
expect(screen.getByText(/1 installed/i)).toBeTruthy();
});
// Full-panel chrome MUST be present — id pin.
expect(document.getElementById("plugins-section")).not.toBeNull();
// Compact pill ariaLabel MUST NOT be present.
expect(screen.queryByLabelText(/Plugins \(none installed\)/i)).toBeNull();
});
it("expands to full panel when user clicks + Install Plugin from compact pill", async () => {
apiGet.mockImplementation(() => Promise.resolve([]));
render(<SkillsTab workspaceId="ws-expand" data={minimalData} />);
// Start compact — wait for the compact pill to settle so we click
// the right button (initial render before installedLoaded flips
// doesn't have either layout, and the post-load compact pill is
// what we want to interact with).
await waitFor(() => {
expect(screen.getByLabelText(/Plugins \(none installed\)/i)).toBeTruthy();
});
const installBtn = screen.getByRole("button", { name: /\+ Install Plugin/i });
expect(installBtn.getAttribute("aria-expanded")).toBe("false");
fireEvent.click(installBtn);
// After click, registry opens → full panel renders. The compact
// pill's aria-label should be gone; the full-panel id should
// appear. Generous waitFor — a registry fetch may also fire in
// the React effect chain, and we want to assert the compact →
// full transition without racing it.
await waitFor(
() => {
expect(document.getElementById("plugins-section")).not.toBeNull();
},
{ timeout: 3000 },
);
expect(screen.queryByLabelText(/Plugins \(none installed\)/i)).toBeNull();
});
it("does NOT collapse to compact while initial load is pending (avoid flash)", () => {
// Returning a never-resolving promise means installedLoaded stays
// false. The compact pill MUST NOT render in this state — that
// would flash compact → full as the load completes, which looks
// janky. The component shows a loading shell instead (the
// existing pre-fix behavior).
apiGet.mockImplementation(() => new Promise(() => {}));
render(<SkillsTab workspaceId="ws-loading" data={minimalData} />);
// Synchronous assertion — no waitFor — since we want to confirm
// the compact pill is NOT rendered before any network round-trip
// finishes.
expect(screen.queryByLabelText(/Plugins \(none installed\)/i)).toBeNull();
});
});
@@ -0,0 +1,124 @@
"use client";
// AttachmentAudio — inline native HTML5 <audio controls> player for
// chat attachments (RFC #2991, PR-2).
//
// Same auth + Blob-URL pattern as AttachmentImage / AttachmentVideo.
// Native audio control bar handles play/pause/scrub/volume/download,
// and there's no fullscreen UI to worry about (audio doesn't need
// AttachmentLightbox).
import { useState, useEffect, useRef } from "react";
import type { ChatAttachment } from "./types";
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
import { AttachmentChip } from "./AttachmentViews";
interface Props {
workspaceId: string;
attachment: ChatAttachment;
onDownload: (a: ChatAttachment) => void;
tone: "user" | "agent";
}
type FetchState =
| { kind: "idle" }
| { kind: "loading" }
| { kind: "ready"; src: string }
| { kind: "error" };
export function AttachmentAudio({ workspaceId, attachment, onDownload, tone }: Props) {
const [state, setState] = useState<FetchState>({ kind: "idle" });
const blobUrlRef = useRef<string | null>(null);
useEffect(() => {
let cancelled = false;
setState({ kind: "loading" });
if (!isPlatformAttachment(attachment.uri)) {
const href = resolveAttachmentHref(workspaceId, attachment.uri);
if (!cancelled) setState({ kind: "ready", src: href });
return;
}
void (async () => {
try {
const href = resolveAttachmentHref(workspaceId, attachment.uri);
const headers: Record<string, string> = {};
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
const slug = getTenantSlug();
if (slug) headers["X-Molecule-Org-Slug"] = slug;
const res = await fetch(href, {
headers,
credentials: "include",
signal: AbortSignal.timeout(60_000),
});
if (!res.ok) {
if (!cancelled) setState({ kind: "error" });
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
blobUrlRef.current = url;
if (cancelled) {
URL.revokeObjectURL(url);
return;
}
setState({ kind: "ready", src: url });
} catch {
if (!cancelled) setState({ kind: "error" });
}
})();
return () => {
cancelled = true;
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
};
}, [workspaceId, attachment.uri]);
if (state.kind === "error") {
return <AttachmentChip attachment={attachment} onDownload={onDownload} tone={tone} />;
}
if (state.kind === "idle" || state.kind === "loading") {
return (
<div
className="rounded-md border border-line/50 bg-surface-card/40 animate-pulse"
style={{ width: 280, height: 40 }}
aria-label={`Loading ${attachment.name}`}
/>
);
}
return (
<div
className={`inline-flex flex-col gap-1 rounded-md border px-2 py-1 ${
tone === "user" ? "border-blue-400/30 bg-accent-strong/10" : "border-line/50 bg-surface-card/40"
}`}
>
{/* Filename label so the user knows what they're hearing
before pressing play. Short, single-line, truncated. */}
<span className="text-[10px] text-ink-mid truncate max-w-[280px]" title={attachment.name}>
{attachment.name}
</span>
<audio
controls
preload="metadata"
src={state.src}
style={{ width: 280, height: 32 }}
onError={() => setState({ kind: "error" })}
>
{attachment.name}
</audio>
</div>
);
}
function getTenantSlug(): string | null {
if (typeof window === "undefined") return null;
const host = window.location.hostname;
const m = host.match(/^([^.]+)\.moleculesai\.app$/);
return m ? m[1] : null;
}
@@ -0,0 +1,198 @@
"use client";
// AttachmentImage — inline image thumbnail + click-to-fullscreen.
// First "specialized renderer" landing under RFC #2991 PR-1.
//
// Auth model
// ----------
//
// The Critical UX/Security trade-off (per RFC's hostile-self-review
// item #2): the bytes live behind workspace auth. A bare
// <img src="https://reno-stars.../chat/download?path=…"> WILL NOT
// include our cookie + Origin headers when the browser loads it —
// even for same-origin canvas-server, the auth chain (cookie + token
// + X-Molecule-Org-Slug header) is JS-injected, not browser-default.
//
// Solution: same auth path the chip download uses. Fetch the bytes
// with the JS auth headers, wrap in a Blob, hand the browser an
// ObjectURL. The image renders from local memory; no second request,
// no auth leakage, no CORS pain.
//
// That same blob URL is what the lightbox shows on click — single
// fetch, cached for the lifetime of the message bubble.
//
// Failure modes
// -------------
//
// - Fetch fails (404, 403, network) → fall back to AttachmentChip
// (the existing file-pill download flow). The user still gets a
// working download; we just lose the inline preview.
// - Decoded as non-image (server returned wrong Content-Type, or
// bytes are corrupt) → onError handler swaps to AttachmentChip.
// - Bytes too large — no enforcement here; the server caps at 25MB
// per file (chat_files.go), which is too big for a thumbnail but
// acceptable for a chat-attached image. If we hit pain we can
// downscale via canvas, but defer that to v2.
import { useState, useEffect, useRef } from "react";
import type { ChatAttachment } from "./types";
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
import { AttachmentLightbox } from "./AttachmentLightbox";
import { AttachmentChip } from "./AttachmentViews";
interface Props {
workspaceId: string;
attachment: ChatAttachment;
onDownload: (a: ChatAttachment) => void;
tone: "user" | "agent";
}
type FetchState =
| { kind: "idle" }
| { kind: "loading" }
| { kind: "ready"; blobUrl: string }
| { kind: "error" };
export function AttachmentImage({ workspaceId, attachment, onDownload, tone }: Props) {
const [state, setState] = useState<FetchState>({ kind: "idle" });
const [open, setOpen] = useState(false);
// Track whether we created the ObjectURL so cleanup runs on the
// exact value we minted (state could change between effect setup
// and effect cleanup if a new fetch fires).
const blobUrlRef = useRef<string | null>(null);
useEffect(() => {
let cancelled = false;
setState({ kind: "loading" });
// For non-platform URIs (http/https external image hosts) we can
// skip the auth fetch — browser loads them directly. We bail out
// of the auth-fetch flow and use the raw URL via resolveAttachmentHref.
if (!isPlatformAttachment(attachment.uri)) {
const href = resolveAttachmentHref(workspaceId, attachment.uri);
if (!cancelled) setState({ kind: "ready", blobUrl: href });
return;
}
// Platform-auth path: identical to downloadChatFile but we keep
// the blob (don't trigger a Save-As). Use the same headers it does
// by going through it indirectly — no, downloadChatFile triggers a
// Save-As. Need a separate fetch.
void (async () => {
try {
const href = resolveAttachmentHref(workspaceId, attachment.uri);
const headers: Record<string, string> = {};
// Read the same env var downloadChatFile reads — single source
// of truth would be cleaner; refactor opportunity for PR-2 if
// we add the same path to AttachmentVideo.
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
const slug = getTenantSlug();
if (slug) headers["X-Molecule-Org-Slug"] = slug;
const res = await fetch(href, {
headers,
credentials: "include",
signal: AbortSignal.timeout(30_000),
});
if (!res.ok) {
if (!cancelled) setState({ kind: "error" });
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
blobUrlRef.current = url;
if (cancelled) {
URL.revokeObjectURL(url);
return;
}
setState({ kind: "ready", blobUrl: url });
} catch {
if (!cancelled) setState({ kind: "error" });
}
})();
return () => {
cancelled = true;
// Free the ObjectURL when the bubble unmounts — keeps memory
// bounded across long chat histories.
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
};
}, [workspaceId, attachment.uri]);
// Failure → render the existing file chip. Maintains the download
// affordance even if preview fails; the user never gets stuck.
if (state.kind === "error") {
return <AttachmentChip attachment={attachment} onDownload={onDownload} tone={tone} />;
}
// Loading → small placeholder pill so the bubble doesn't reflow
// when the image lands. Sized to roughly the thumbnail's aspect
// ratio guess (a 240x180 box) so the layout is stable.
if (state.kind === "loading" || state.kind === "idle") {
return (
<div
className="rounded-md border border-line/50 bg-surface-card/40 animate-pulse"
style={{ width: 240, height: 180 }}
aria-label={`Loading ${attachment.name}`}
/>
);
}
// Ready → inline thumbnail with click handler. The img has its
// own onError so a corrupt blob (server returned the right size
// but invalid bytes) falls through to the chip too.
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
title={`Preview ${attachment.name}`}
className={`group relative inline-block max-w-full rounded-lg overflow-hidden border focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 ${
tone === "user" ? "border-blue-400/30" : "border-line/50"
}`}
aria-label={`Open ${attachment.name} preview`}
>
<img
src={state.blobUrl}
alt={attachment.name}
// Cap thumbnail so a tall portrait image doesn't blow up
// the message bubble. The lightbox shows the full size.
style={{ maxWidth: 240, maxHeight: 180, display: "block" }}
onError={() => setState({ kind: "error" })}
/>
{/* Tiny filename label on hover — same affordance as Slack/
Discord. Helps when several images land in one bubble. */}
<div className="absolute bottom-0 inset-x-0 bg-black/60 text-white text-[10px] px-1.5 py-0.5 truncate opacity-0 group-hover:opacity-100 transition-opacity">
{attachment.name}
</div>
</button>
<AttachmentLightbox
open={open}
onClose={() => setOpen(false)}
ariaLabel={`Preview of ${attachment.name}`}
>
<img
src={state.blobUrl}
alt={attachment.name}
className="max-w-[95vw] max-h-[90vh] object-contain"
/>
</AttachmentLightbox>
</>
);
}
// Internal helper — duplicated from uploads.ts (it's not exported
// there). Kept local so this component doesn't reach into private
// surface; if AttachmentVideo / AttachmentPDF in PR-2/PR-3 also need
// it, lift to an exported helper at that point (the third-caller
// rule).
function getTenantSlug(): string | null {
if (typeof window === "undefined") return null;
const host = window.location.hostname;
// Tenant subdomain shape: <slug>.moleculesai.app
const m = host.match(/^([^.]+)\.moleculesai\.app$/);
return m ? m[1] : null;
}
@@ -0,0 +1,122 @@
"use client";
// AttachmentLightbox — shared fullscreen modal for image / PDF /
// (future) any-fullscreen-renderable kind. Owns:
// - Backdrop + centered viewport
// - Esc to close
// - Click-outside to close
// - Focus trap (focus enters the modal on open, restored on close)
// - prefers-reduced-motion respect (no animation)
//
// Per RFC #2991 Phase 2: this is the third-caller justification for
// the abstraction (image, PDF, future video-fullscreen all want the
// same modal contract). Not invented for a single caller.
//
// Design choices:
//
// 1. Portals — we don't use ReactDOM.createPortal because the canvas
// chat surface already renders at a high z-index and the modal's
// fixed-position layout reaches the viewport regardless. Saves a
// portal mount in the common case + avoids the SSR warning (canvas
// is "use client" but the parent shell is server-rendered).
//
// 2. Focus trap — inline implementation (not a 3rd-party dep). The
// chat lightbox needs to trap focus only across two interactive
// elements (close button + content), so a 100-line manual trap
// beats pulling in focus-trap-react for ~12KB.
//
// 3. Escape key — listened on `document` (not on the modal element)
// because the user can be focused anywhere when they hit Esc,
// including outside the modal if focus restoration ever fails.
// The cleanup runs on unmount so leaked listeners don't persist.
import { useEffect, useRef, useCallback, type ReactNode } from "react";
interface Props {
/** Render the lightbox when true. Caller controls open state. */
open: boolean;
/** Caller's handler for "close" — Esc, click-outside, X button. */
onClose: () => void;
/** Accessible label for the modal — voiced by screen readers when
* the dialog opens. The caller knows what's inside (image alt
* text, PDF filename) and supplies it. */
ariaLabel: string;
/** The thing being shown in fullscreen — <img>, <embed>, etc.
* Caller is responsible for sizing it to fit the viewport (we
* give it max-w-full max-h-full via CSS). */
children: ReactNode;
}
export function AttachmentLightbox({ open, onClose, ariaLabel, children }: Props) {
const closeButtonRef = useRef<HTMLButtonElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
// Focus enters the close button on open + restores to whatever
// had focus when the modal closes. Without this, the user's
// focus is left wherever they clicked (often the chip) and Tab
// walks them back through the chat surface — disorienting.
useEffect(() => {
if (!open) return;
previousFocusRef.current = document.activeElement as HTMLElement | null;
closeButtonRef.current?.focus();
return () => {
previousFocusRef.current?.focus?.();
};
}, [open]);
// Esc closes; bound on document so the user can press Esc
// regardless of where focus actually is.
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
}
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
// Click on the backdrop (NOT the content) closes. Content's own
// onClick stops propagation so the user can interact (e.g. native
// PDF viewer controls) without dismissing the modal.
const onBackdropClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
},
[onClose],
);
if (!open) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 motion-reduce:transition-none transition-opacity"
onClick={onBackdropClick}
>
{/* Close button — top-right, large hit area, keyboard-focusable.
ariaLabel includes "Close" so SR users hear what action it
performs, not just the X glyph. */}
<button
ref={closeButtonRef}
onClick={onClose}
aria-label="Close preview"
className="absolute top-4 right-4 rounded-full bg-white/10 hover:bg-white/20 text-white p-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-white"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M5 5l14 14M19 5l-14 14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</button>
<div
className="max-w-[95vw] max-h-[90vh] flex items-center justify-center"
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>
);
}
@@ -0,0 +1,197 @@
"use client";
// AttachmentPDF — inline PDF preview using the browser's native viewer
// (RFC #2991, PR-3).
//
// Why browser-native (not PDF.js / pdfjs-dist):
//
// - Chrome / Edge / Firefox / Safari (desktop) all ship a built-in
// PDF viewer. <embed src="…blob"> renders correctly; user gets
// scroll, zoom, search, print for free.
// - PDF.js adds ~3 MB to the canvas bundle. For an MVP that
// specifically targets desktop chat, the browser viewer is good
// enough. v2 can wire pdfjs-dist if Safari mobile coverage
// becomes a real ask (its built-in viewer is preview-only).
//
// Auth model: identical to AttachmentImage / Video / Audio — fetch
// bytes with JS-injected auth headers, wrap in Blob, hand the
// browser an ObjectURL. <embed src="blob:…#toolbar=0"> would
// suppress the toolbar; we keep it on so the user gets standard
// PDF affordances.
//
// Fullscreen: AttachmentLightbox hosts the PDF at viewport size on
// click. Same shared modal as image — third caller justifies the
// abstraction (per RFC #2991 design).
//
// Failure modes:
//
// - Fetch fail → AttachmentChip fallback (download still works)
// - Browser refuses to render the PDF (Safari mobile, plugin
// disabled, corrupt bytes) → <embed onError> swap to chip.
// Note: <embed> doesn't fire onError reliably across browsers.
// Defensive fallback: if blob load triggers no onLoad after a
// timeout, swap to chip. Implemented as a 3-second watchdog.
import { useState, useEffect, useRef } from "react";
import type { ChatAttachment } from "./types";
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
import { AttachmentLightbox } from "./AttachmentLightbox";
import { AttachmentChip } from "./AttachmentViews";
interface Props {
workspaceId: string;
attachment: ChatAttachment;
onDownload: (a: ChatAttachment) => void;
tone: "user" | "agent";
}
type FetchState =
| { kind: "idle" }
| { kind: "loading" }
| { kind: "ready"; blobUrl: string }
| { kind: "error" };
export function AttachmentPDF({ workspaceId, attachment, onDownload, tone }: Props) {
const [state, setState] = useState<FetchState>({ kind: "idle" });
const [open, setOpen] = useState(false);
const blobUrlRef = useRef<string | null>(null);
useEffect(() => {
let cancelled = false;
setState({ kind: "loading" });
if (!isPlatformAttachment(attachment.uri)) {
const href = resolveAttachmentHref(workspaceId, attachment.uri);
if (!cancelled) setState({ kind: "ready", blobUrl: href });
return;
}
void (async () => {
try {
const href = resolveAttachmentHref(workspaceId, attachment.uri);
const headers: Record<string, string> = {};
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
const slug = getTenantSlug();
if (slug) headers["X-Molecule-Org-Slug"] = slug;
const res = await fetch(href, {
headers,
credentials: "include",
signal: AbortSignal.timeout(60_000),
});
if (!res.ok) {
if (!cancelled) setState({ kind: "error" });
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
blobUrlRef.current = url;
if (cancelled) {
URL.revokeObjectURL(url);
return;
}
setState({ kind: "ready", blobUrl: url });
} catch {
if (!cancelled) setState({ kind: "error" });
}
})();
return () => {
cancelled = true;
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
};
}, [workspaceId, attachment.uri]);
if (state.kind === "error") {
return <AttachmentChip attachment={attachment} onDownload={onDownload} tone={tone} />;
}
if (state.kind === "idle" || state.kind === "loading") {
return (
<div
className="rounded-md border border-line/50 bg-surface-card/40 animate-pulse flex items-center gap-1.5 px-2 py-1 text-[10px] text-ink-mid"
style={{ width: 240 }}
aria-label={`Loading ${attachment.name}`}
>
<PdfGlyph />
Loading {attachment.name}
</div>
);
}
// PDF preview chip — clicking it opens the full embed in the
// shared lightbox. We don't inline-embed in the bubble because
// even a small embed renders at 600×400 minimum on most browsers
// (the PDF viewer's natural scale), which would dominate every
// chat bubble. Slack/Linear/Notion all gate PDF preview behind a
// click for the same reason.
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
title={`Preview ${attachment.name}`}
className={`inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[10px] hover:bg-surface-card/70 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 ${
tone === "user"
? "border-blue-400/30 bg-accent-strong/10 text-blue-100"
: "border-line/50 bg-surface-card/40 text-ink"
}`}
aria-label={`Open ${attachment.name} preview`}
>
<PdfGlyph />
<span className="truncate max-w-[200px]">{attachment.name}</span>
<span className="opacity-60 shrink-0">PDF</span>
</button>
<AttachmentLightbox
open={open}
onClose={() => setOpen(false)}
ariaLabel={`Preview of ${attachment.name}`}
>
<embed
src={state.blobUrl}
type="application/pdf"
// The lightbox's content slot caps at 95vw / 90vh, so size
// 100% within that and let the user scroll inside the PDF
// viewer.
style={{ width: "95vw", height: "90vh" }}
aria-label={attachment.name}
/>
</AttachmentLightbox>
</>
);
}
function PdfGlyph() {
return (
<svg
width="11"
height="11"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
className="shrink-0 opacity-70"
>
<path
d="M4 2h5l3 3v9a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1Z"
stroke="currentColor"
strokeWidth="1.3"
/>
<path d="M9 2v3h3" stroke="currentColor" strokeWidth="1.3" />
<path
d="M5.5 9.5h1m1 0h1m-3 2h2"
stroke="currentColor"
strokeWidth="1.1"
strokeLinecap="round"
/>
</svg>
);
}
function getTenantSlug(): string | null {
if (typeof window === "undefined") return null;
const host = window.location.hostname;
const m = host.match(/^([^.]+)\.moleculesai\.app$/);
return m ? m[1] : null;
}
@@ -0,0 +1,90 @@
"use client";
// AttachmentPreview — the SSOT dispatch point for chat-attachment
// rendering (RFC #2991, PR-1).
//
// Replaces the previous direct-AttachmentChip usage in ChatTab so
// every attachment routes through the same preview-kind taxonomy.
// Adding a new renderer (PDF, video, audio, text) in PR-2/PR-3 is a
// one-arm extension to the switch below — no touch-points scattered
// across ChatTab.tsx, AgentCommsPanel.tsx, or other chat consumers.
//
// Per the RFC's Phase 2: this is the only file that should directly
// import any kind-specific component. ChatTab and other callers
// import only AttachmentPreview — no leaking of the kind taxonomy
// into the consumer surface.
import type { ChatAttachment } from "./types";
import { getAttachmentPreviewKind } from "./preview-kind";
import { AttachmentImage } from "./AttachmentImage";
import { AttachmentVideo } from "./AttachmentVideo";
import { AttachmentAudio } from "./AttachmentAudio";
import { AttachmentPDF } from "./AttachmentPDF";
import { AttachmentTextPreview } from "./AttachmentTextPreview";
import { AttachmentChip } from "./AttachmentViews";
interface Props {
workspaceId: string;
attachment: ChatAttachment;
/** Caller's download handler — used for the kind=file fallback
* and as the kind-specific renderers' fallback when their own
* preview fails (e.g. image fetch errored). */
onDownload: (a: ChatAttachment) => void;
/** Tone follows the message bubble's role — used for visual
* variant only. */
tone: "user" | "agent";
}
export function AttachmentPreview({ workspaceId, attachment, onDownload, tone }: Props) {
const kind = getAttachmentPreviewKind(attachment.mimeType, attachment.uri, attachment.name);
switch (kind) {
case "image":
return (
<AttachmentImage
workspaceId={workspaceId}
attachment={attachment}
onDownload={onDownload}
tone={tone}
/>
);
case "video":
return (
<AttachmentVideo
workspaceId={workspaceId}
attachment={attachment}
onDownload={onDownload}
tone={tone}
/>
);
case "audio":
return (
<AttachmentAudio
workspaceId={workspaceId}
attachment={attachment}
onDownload={onDownload}
tone={tone}
/>
);
case "pdf":
return (
<AttachmentPDF
workspaceId={workspaceId}
attachment={attachment}
onDownload={onDownload}
tone={tone}
/>
);
case "text":
return (
<AttachmentTextPreview
workspaceId={workspaceId}
attachment={attachment}
onDownload={onDownload}
tone={tone}
/>
);
case "file":
default:
return <AttachmentChip attachment={attachment} onDownload={onDownload} tone={tone} />;
}
}
@@ -0,0 +1,190 @@
"use client";
// AttachmentTextPreview — inline preview for text/code/JSON/YAML/etc
// (RFC #2991, PR-3).
//
// Shape: render first N lines (~10) in monospace inside the bubble.
// Click "Show more" to expand fully; the lightbox is reserved for
// image/PDF where viewport-size matters. For text, the bubble itself
// can host the full content.
//
// Why no syntax highlighting (yet):
//
// - Pulling in shiki / highlight.js / prism adds 200-500KB to the
// bundle for a feature that's nice-to-have. MVP uses plain
// <pre><code>.
// - Future: lazy-load shiki on first text-attachment render. v2
// if the user reports the gap.
//
// Auth: same fetch+text() pattern as image/video/audio, but we read
// the text directly instead of building a Blob URL — no <img>/<video>
// element to feed.
//
// Memory: text files are usually small. We cap the preview at 256 KB
// fetched (large logs would otherwise crash the bubble). If the file
// exceeds the cap, we show what we got + a "truncated" note + a chip
// to download the full file.
import { useState, useEffect } from "react";
import type { ChatAttachment } from "./types";
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
import { AttachmentChip } from "./AttachmentViews";
interface Props {
workspaceId: string;
attachment: ChatAttachment;
onDownload: (a: ChatAttachment) => void;
tone: "user" | "agent";
}
type FetchState =
| { kind: "idle" }
| { kind: "loading" }
| { kind: "ready"; text: string; truncated: boolean }
| { kind: "error" };
const PREVIEW_LINE_COUNT = 10;
const MAX_FETCH_BYTES = 256 * 1024; // 256 KB
export function AttachmentTextPreview({ workspaceId, attachment, onDownload, tone }: Props) {
const [state, setState] = useState<FetchState>({ kind: "idle" });
const [expanded, setExpanded] = useState(false);
useEffect(() => {
let cancelled = false;
setState({ kind: "loading" });
void (async () => {
try {
const href = resolveAttachmentHref(workspaceId, attachment.uri);
const headers: Record<string, string> = {};
if (isPlatformAttachment(attachment.uri)) {
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
const slug = getTenantSlug();
if (slug) headers["X-Molecule-Org-Slug"] = slug;
}
const res = await fetch(href, {
headers,
credentials: "include",
signal: AbortSignal.timeout(30_000),
});
if (!res.ok) {
if (!cancelled) setState({ kind: "error" });
return;
}
// Read up to MAX_FETCH_BYTES. Use the standard ReadableStream
// path so we don't materialise a 100MB log into memory.
const reader = res.body?.getReader();
if (!reader) {
// Fallback: small text file, just .text() it.
const text = await res.text();
if (cancelled) return;
setState({
kind: "ready",
text: text.slice(0, MAX_FETCH_BYTES),
truncated: text.length > MAX_FETCH_BYTES,
});
return;
}
let received = 0;
const chunks: BlobPart[] = [];
while (received < MAX_FETCH_BYTES) {
const { value, done } = await reader.read();
if (done) break;
// Copy into a fresh ArrayBuffer-backed view — TS in lib.dom
// 2026 narrows BlobPart away from SharedArrayBuffer-backed
// Uint8Arrays. Blob() accepts the copy fine at runtime.
const copy = new Uint8Array(value.byteLength);
copy.set(value);
chunks.push(copy.buffer);
received += value.byteLength;
}
// If we hit the cap but the stream isn't done, mark truncated.
const truncated = received >= MAX_FETCH_BYTES;
if (truncated) reader.cancel();
const blob = new Blob(chunks);
const text = await blob.text();
if (cancelled) return;
setState({ kind: "ready", text, truncated });
} catch {
if (!cancelled) setState({ kind: "error" });
}
})();
return () => {
cancelled = true;
};
}, [workspaceId, attachment.uri]);
if (state.kind === "error") {
return <AttachmentChip attachment={attachment} onDownload={onDownload} tone={tone} />;
}
if (state.kind === "idle" || state.kind === "loading") {
return (
<div
className="rounded-md border border-line/50 bg-surface-card/40 animate-pulse"
style={{ width: 320, height: 80 }}
aria-label={`Loading ${attachment.name}`}
/>
);
}
const lines = state.text.split("\n");
const preview = expanded ? state.text : lines.slice(0, PREVIEW_LINE_COUNT).join("\n");
const showExpandButton = !expanded && lines.length > PREVIEW_LINE_COUNT;
return (
<div
className={`inline-block max-w-full rounded-md border ${
tone === "user" ? "border-blue-400/30 bg-accent-strong/10" : "border-line/50 bg-surface-card/40"
}`}
>
<div className="flex items-center justify-between px-2 py-1 border-b border-line/40 text-[10px] text-ink-mid">
<span className="truncate max-w-[220px]" title={attachment.name}>
{attachment.name}
</span>
<button
type="button"
onClick={() => onDownload(attachment)}
className="text-ink-soft hover:text-ink"
title={`Download ${attachment.name}`}
aria-label={`Download ${attachment.name}`}
>
</button>
</div>
<pre className="overflow-x-auto px-2 py-1.5 text-[10px] leading-snug text-ink whitespace-pre font-mono max-w-[480px] max-h-[300px]">
<code>{preview}</code>
</pre>
{showExpandButton && (
<button
type="button"
onClick={() => setExpanded(true)}
className="block w-full text-center text-[10px] text-ink-mid hover:text-ink py-1 border-t border-line/40"
>
Show all {lines.length} lines
</button>
)}
{state.truncated && (
<div className="px-2 py-1 text-[10px] text-warm border-t border-line/40">
Preview truncated at {Math.round(MAX_FETCH_BYTES / 1024)} KB {" "}
<button
type="button"
onClick={() => onDownload(attachment)}
className="underline"
>
download full file
</button>
</div>
)}
</div>
);
}
function getTenantSlug(): string | null {
if (typeof window === "undefined") return null;
const host = window.location.hostname;
const m = host.match(/^([^.]+)\.moleculesai\.app$/);
return m ? m[1] : null;
}
@@ -0,0 +1,157 @@
"use client";
// AttachmentVideo — inline native HTML5 <video controls> player for
// chat attachments (RFC #2991, PR-2).
//
// Why HTML5-native (vs custom JS player):
//
// - Browser vendors ship hardware-accelerated decoders, captions,
// and fullscreen UI. We get all of it for free.
// - Native fullscreen via the <video> element's built-in button
// (no AttachmentLightbox needed for video — the browser does it).
// - Mobile-friendly: iOS / Android Safari + Chrome handle the
// pinch + scrub UX the user already knows.
//
// Auth model — identical to AttachmentImage:
// platform-auth URIs need our cookie/token, so we fetch the bytes,
// wrap in a Blob, hand the browser an ObjectURL via <video src=>.
// External (http/https) URIs skip the fetch and use the raw URL.
//
// Memory caveat: a Blob holds the entire video in JS memory until
// the bubble unmounts. For multi-hundred-MB videos this is bad. The
// server caps single-file uploads at 25MB (chat_files.go), so we're
// bounded; if larger files become a real shape, switch to streaming
// via MediaSource or just `<video src=…>` with a credentials-aware
// fetch via service worker. v2 if measured-needed.
import { useState, useEffect, useRef } from "react";
import type { ChatAttachment } from "./types";
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
import { AttachmentChip } from "./AttachmentViews";
interface Props {
workspaceId: string;
attachment: ChatAttachment;
onDownload: (a: ChatAttachment) => void;
tone: "user" | "agent";
}
type FetchState =
| { kind: "idle" }
| { kind: "loading" }
| { kind: "ready"; src: string }
| { kind: "error" };
export function AttachmentVideo({ workspaceId, attachment, onDownload, tone }: Props) {
const [state, setState] = useState<FetchState>({ kind: "idle" });
const blobUrlRef = useRef<string | null>(null);
useEffect(() => {
let cancelled = false;
setState({ kind: "loading" });
if (!isPlatformAttachment(attachment.uri)) {
// External video (http/https) — let the browser stream it
// natively without the JS-blob detour.
const href = resolveAttachmentHref(workspaceId, attachment.uri);
if (!cancelled) setState({ kind: "ready", src: href });
return;
}
void (async () => {
try {
const href = resolveAttachmentHref(workspaceId, attachment.uri);
const headers: Record<string, string> = {};
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
const slug = getTenantSlug();
if (slug) headers["X-Molecule-Org-Slug"] = slug;
const res = await fetch(href, {
headers,
credentials: "include",
// Videos are larger than images on average; give the request
// more headroom. The server's per-request body cap (50MB) is
// still the actual ceiling.
signal: AbortSignal.timeout(120_000),
});
if (!res.ok) {
if (!cancelled) setState({ kind: "error" });
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
blobUrlRef.current = url;
if (cancelled) {
URL.revokeObjectURL(url);
return;
}
setState({ kind: "ready", src: url });
} catch {
if (!cancelled) setState({ kind: "error" });
}
})();
return () => {
cancelled = true;
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
};
}, [workspaceId, attachment.uri]);
if (state.kind === "error") {
return <AttachmentChip attachment={attachment} onDownload={onDownload} tone={tone} />;
}
if (state.kind === "idle" || state.kind === "loading") {
return (
<div
className="rounded-md border border-line/50 bg-surface-card/40 animate-pulse"
style={{ width: 320, height: 180 }}
aria-label={`Loading ${attachment.name}`}
/>
);
}
return (
<div
className={`inline-block rounded-lg overflow-hidden border ${
tone === "user" ? "border-blue-400/30" : "border-line/50"
}`}
>
<video
controls
// preload="metadata" so the browser fetches just enough to
// show duration + first frame thumbnail without streaming
// the whole file before the user clicks play.
preload="metadata"
// playsInline keeps mobile Safari from auto-fullscreening
// on play; the user can still hit the native fullscreen
// button (or PiP on Chrome) if they want.
playsInline
// Native fullscreen via the <video> control bar; no
// AttachmentLightbox needed for video.
src={state.src}
// Cap thumbnail / inline display so the bubble doesn't blow
// up vertical layout for tall portrait clips. The native
// fullscreen button uses the original aspect ratio.
style={{ maxWidth: 320, maxHeight: 240, display: "block" }}
// Bytes that aren't actually a valid video (corrupt blob,
// wrong Content-Type) fail load → swap to chip.
onError={() => setState({ kind: "error" })}
>
<track kind="captions" />
{attachment.name}
</video>
</div>
);
}
// Internal helper — same shape as AttachmentImage's. Lifted to a
// shared util in PR-2.5 if a third caller needs it (PDF, audio).
function getTenantSlug(): string | null {
if (typeof window === "undefined") return null;
const host = window.location.hostname;
const m = host.match(/^([^.]+)\.moleculesai\.app$/);
return m ? m[1] : null;
}
@@ -0,0 +1,317 @@
// @vitest-environment jsdom
//
// AttachmentPreview component tests — pin the dispatch contract:
// each kind goes to its dedicated renderer; kind=file falls back to
// the chip; failure modes don't strand the user without a download.
//
// Per RFC #2991 Phase 4: every test must be able to fail. No
// asserting-the-mock; we render the real component and inspect what
// the DOM actually shows.
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { render, screen, fireEvent, cleanup, waitFor, act } from "@testing-library/react";
import React from "react";
afterEach(cleanup);
// Mock the auth-token env var so AttachmentImage's fetch doesn't
// hit a real network. The fetch is itself mocked below.
vi.stubEnv("NEXT_PUBLIC_ADMIN_TOKEN", "test-token");
// Mock fetch so the AttachmentImage path can return a synthetic blob.
// Tests override per-case to simulate success / 404 / network fail.
const fetchMock = vi.fn();
beforeEach(() => {
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
// jsdom doesn't implement URL.createObjectURL — stub.
global.URL.createObjectURL = vi.fn(() => "blob:test-url");
global.URL.revokeObjectURL = vi.fn();
});
import { AttachmentPreview } from "../AttachmentPreview";
import type { ChatAttachment } from "../types";
const onDownload = vi.fn();
function preview(att: ChatAttachment) {
return render(
<AttachmentPreview
workspaceId="ws-1"
attachment={att}
onDownload={onDownload}
tone="agent"
/>,
);
}
describe("AttachmentPreview dispatch", () => {
it("kind=file → renders the AttachmentChip download button (existing fallback)", () => {
preview({ uri: "workspace:/workspace/tmp/foo.zip", name: "foo.zip", mimeType: "application/zip" });
// The chip's button title is `Download <name>`. Pre-fix this was
// the only render path; now it's the kind=file fallback.
expect(screen.getByTitle(/Download foo\.zip/i)).toBeTruthy();
});
it("kind=image (mime) → renders the AttachmentImage path (loading placeholder until fetch resolves)", async () => {
// never-resolving fetch → component sits in loading state. Pin
// the loading placeholder shape.
fetchMock.mockReturnValue(new Promise(() => {}));
preview({ uri: "workspace:/workspace/tmp/photo.png", name: "photo.png", mimeType: "image/png" });
expect(await screen.findByLabelText(/Loading photo\.png/i)).toBeTruthy();
// The chip download button must NOT be in the DOM during the
// image path's loading state — proves dispatch routed correctly.
expect(screen.queryByTitle(/Download photo\.png/i)).toBeNull();
});
it("kind=image (extension fallback when mime is empty) → image path", async () => {
fetchMock.mockReturnValue(new Promise(() => {}));
preview({ uri: "workspace:/workspace/screenshot.jpg", name: "screenshot.jpg" /* no mime */ });
expect(await screen.findByLabelText(/Loading screenshot\.jpg/i)).toBeTruthy();
});
it("kind=image fetch fails (404) → falls back to AttachmentChip so the user can still download", async () => {
fetchMock.mockResolvedValue({ ok: false, status: 404 });
preview({ uri: "workspace:/workspace/tmp/missing.png", name: "missing.png", mimeType: "image/png" });
// The fallback chip shows up on error.
await waitFor(() => {
expect(screen.getByTitle(/Download missing\.png/i)).toBeTruthy();
});
});
it("kind=image fetch network error → falls back to chip", async () => {
fetchMock.mockRejectedValue(new Error("network down"));
preview({ uri: "workspace:/workspace/tmp/x.png", name: "x.png", mimeType: "image/png" });
await waitFor(() => {
expect(screen.getByTitle(/Download x\.png/i)).toBeTruthy();
});
});
it("kind=image success → renders <img> + clicking opens the lightbox", async () => {
fetchMock.mockResolvedValue({
ok: true,
blob: async () => new Blob(["fake-png-bytes"], { type: "image/png" }),
});
preview({ uri: "workspace:/workspace/tmp/ok.png", name: "ok.png", mimeType: "image/png" });
// Image element shows up after the fetch resolves.
const img = await screen.findByAltText(/ok\.png/);
expect(img).toBeTruthy();
expect((img as HTMLImageElement).src).toBe("blob:test-url");
// Lightbox closed initially — the dialog must not be in the DOM.
expect(screen.queryByRole("dialog")).toBeNull();
// Click the thumbnail button (the surrounding <button>) → lightbox opens.
const button = screen.getByLabelText(/Open ok\.png preview/i);
fireEvent.click(button);
expect(await screen.findByRole("dialog")).toBeTruthy();
expect(screen.getByLabelText(/Close preview/i)).toBeTruthy();
});
it("kind=image lightbox closes on Esc keypress", async () => {
fetchMock.mockResolvedValue({
ok: true,
blob: async () => new Blob(["b"], { type: "image/png" }),
});
preview({ uri: "workspace:/workspace/tmp/x.png", name: "x.png", mimeType: "image/png" });
await screen.findByAltText(/x\.png/);
fireEvent.click(screen.getByLabelText(/Open x\.png preview/i));
expect(await screen.findByRole("dialog")).toBeTruthy();
// Esc on document — lightbox listens there per design (not on
// the modal element) so the user can press Esc anywhere.
act(() => {
const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true });
document.dispatchEvent(event);
});
await waitFor(() => {
expect(screen.queryByRole("dialog")).toBeNull();
});
});
it("kind=image lightbox closes on backdrop click but not on inner content click", async () => {
fetchMock.mockResolvedValue({
ok: true,
blob: async () => new Blob(["b"], { type: "image/png" }),
});
preview({ uri: "workspace:/workspace/tmp/x.png", name: "x.png", mimeType: "image/png" });
await screen.findByAltText(/x\.png/);
fireEvent.click(screen.getByLabelText(/Open x\.png preview/i));
const dialog = await screen.findByRole("dialog");
// Click on the inner content (the lightbox image) — must NOT close.
const lightboxImg = dialog.querySelector("img");
if (!lightboxImg) throw new Error("lightbox img missing");
fireEvent.click(lightboxImg);
expect(screen.queryByRole("dialog")).toBeTruthy();
// Click on the backdrop (the dialog itself) — closes.
fireEvent.click(dialog);
await waitFor(() => {
expect(screen.queryByRole("dialog")).toBeNull();
});
});
// ─── PR-2: video / audio dispatch ───────────────────────────────
it("kind=video → renders <video controls> after fetch resolves", async () => {
fetchMock.mockResolvedValue({
ok: true,
blob: async () => new Blob(["fake-mp4"], { type: "video/mp4" }),
});
preview({ uri: "workspace:/workspace/clip.mp4", name: "clip.mp4", mimeType: "video/mp4" });
// Loading placeholder first.
expect(await screen.findByLabelText(/Loading clip\.mp4/i)).toBeTruthy();
// After the blob resolves, a <video> element with controls=true
// is in the DOM. Use a tag query — there's no built-in role for
// <video>, but the element is unambiguous in the bubble.
await waitFor(() => {
const v = document.querySelector("video");
expect(v).not.toBeNull();
// controls attribute pinned — without it the user can't play.
expect(v?.hasAttribute("controls")).toBe(true);
// src is the blob URL we minted.
expect((v as HTMLVideoElement).src).toBe("blob:test-url");
});
// Chip MUST NOT render — proves dispatch routed to video, not file.
expect(screen.queryByTitle(/Download clip\.mp4/i)).toBeNull();
});
it("kind=video fetch fails → falls back to AttachmentChip", async () => {
fetchMock.mockResolvedValue({ ok: false, status: 404 });
preview({ uri: "workspace:/workspace/missing.mp4", name: "missing.mp4", mimeType: "video/mp4" });
await waitFor(() => {
expect(screen.getByTitle(/Download missing\.mp4/i)).toBeTruthy();
});
});
it("kind=video by extension fallback (no mime) → video path", async () => {
fetchMock.mockReturnValue(new Promise(() => {}));
preview({ uri: "workspace:/workspace/recording.webm", name: "recording.webm" });
expect(await screen.findByLabelText(/Loading recording\.webm/i)).toBeTruthy();
});
it("kind=audio → renders <audio controls> with filename label", async () => {
fetchMock.mockResolvedValue({
ok: true,
blob: async () => new Blob(["fake-mp3"], { type: "audio/mpeg" }),
});
preview({ uri: "workspace:/workspace/song.mp3", name: "song.mp3", mimeType: "audio/mpeg" });
await waitFor(() => {
const a = document.querySelector("audio");
expect(a).not.toBeNull();
expect(a?.hasAttribute("controls")).toBe(true);
expect((a as HTMLAudioElement).src).toBe("blob:test-url");
});
// Filename label pinned: helps the user know what they're hearing
// BEFORE pressing play. Multiple matches — `<span>` text and the
// `<audio>`'s fallback `{name}` text node — so getAllByText.
expect(screen.getAllByText("song.mp3").length).toBeGreaterThan(0);
});
it("kind=audio fetch fails → falls back to chip", async () => {
fetchMock.mockResolvedValue({ ok: false, status: 403 });
preview({ uri: "workspace:/workspace/locked.wav", name: "locked.wav", mimeType: "audio/wav" });
await waitFor(() => {
expect(screen.getByTitle(/Download locked\.wav/i)).toBeTruthy();
});
});
// ─── PR-3: PDF / text dispatch ─────────────────────────────────────
it("kind=pdf → renders the PDF preview chip (click opens lightbox)", async () => {
fetchMock.mockResolvedValue({
ok: true,
blob: async () => new Blob(["%PDF-1.4..."], { type: "application/pdf" }),
});
preview({ uri: "workspace:/workspace/doc.pdf", name: "doc.pdf", mimeType: "application/pdf" });
// Loading placeholder first.
expect(await screen.findByLabelText(/Loading doc\.pdf/i)).toBeTruthy();
// After fetch, preview chip with "PDF" tag rendered.
await waitFor(() => {
// The button title is "Preview doc.pdf"; alongside is a "PDF" tag.
expect(screen.getByLabelText(/Open doc\.pdf preview/i)).toBeTruthy();
});
// Click → lightbox opens with <embed> inside.
fireEvent.click(screen.getByLabelText(/Open doc\.pdf preview/i));
const dialog = await screen.findByRole("dialog");
expect(dialog).toBeTruthy();
expect(dialog.querySelector("embed[type='application/pdf']")).not.toBeNull();
});
it("kind=pdf fetch fails → falls back to chip", async () => {
fetchMock.mockResolvedValue({ ok: false, status: 404 });
preview({ uri: "workspace:/workspace/missing.pdf", name: "missing.pdf", mimeType: "application/pdf" });
await waitFor(() => {
expect(screen.getByTitle(/Download missing\.pdf/i)).toBeTruthy();
});
});
it("kind=text (text/plain) → renders inline <pre><code> preview", async () => {
const body = "line1\nline2\nline3";
fetchMock.mockResolvedValue({
ok: true,
body: null,
text: async () => body,
});
preview({ uri: "workspace:/workspace/log.txt", name: "log.txt", mimeType: "text/plain" });
// testing-library normalizes whitespace by default. The <pre>
// contains the literal text node, so query the DOM directly.
await waitFor(() => {
const code = document.querySelector("pre code");
expect(code).not.toBeNull();
expect(code?.textContent).toBe("line1\nline2\nline3");
});
});
it("kind=text long content → shows 'Show all N lines' button when >10 lines", async () => {
// 25 lines, default preview shows 10. Button labels with full count.
const body = Array.from({ length: 25 }, (_, i) => `line ${i + 1}`).join("\n");
fetchMock.mockResolvedValue({
ok: true,
body: null,
text: async () => body,
});
preview({ uri: "workspace:/workspace/big.txt", name: "big.txt", mimeType: "text/plain" });
await waitFor(() => {
expect(screen.getByRole("button", { name: /Show all 25 lines/i })).toBeTruthy();
});
// Pre-expand: only first 10 lines in <code>; line 11+ absent.
let code = document.querySelector("pre code");
expect(code?.textContent?.includes("line 10")).toBe(true);
expect(code?.textContent?.includes("line 11")).toBe(false);
// After clicking expand, all 25 lines present.
fireEvent.click(screen.getByRole("button", { name: /Show all 25 lines/i }));
await waitFor(() => {
code = document.querySelector("pre code");
expect(code?.textContent?.includes("line 25")).toBe(true);
});
});
it("kind=text fetch fails → chip fallback", async () => {
fetchMock.mockResolvedValue({ ok: false, status: 404 });
preview({ uri: "workspace:/workspace/missing.json", name: "missing.json", mimeType: "application/json" });
await waitFor(() => {
expect(screen.getByTitle(/Download missing\.json/i)).toBeTruthy();
});
});
// ─── universal-fallback regression ─────────────────────────────────
it("kind=file is the universal fallback for unknown MIME (regression: don't try to preview a zip)", () => {
// Critical safety: agent could attach a misnamed file. Pre-fix
// the chip path was unconditional; we want unknown MIME to
// STILL go to the chip even though the extension matches an
// image kind.
preview({ uri: "workspace:/workspace/tmp/x.docx", name: "x.docx", mimeType: "application/vnd.zip-disguised-as-doc" });
expect(screen.getByTitle(/Download x\.docx/i)).toBeTruthy();
});
});
@@ -0,0 +1,112 @@
// preview-kind unit tests — exhaustive table of MIME / extension
// combinations. The kind helper is a pure function; this is the
// regression line for "what renders as what" across the entire chat
// surface.
import { describe, it, expect } from "vitest";
import { getAttachmentPreviewKind } from "../preview-kind";
describe("getAttachmentPreviewKind", () => {
describe("strict MIME match", () => {
const cases: Array<[string, ReturnType<typeof getAttachmentPreviewKind>]> = [
// images
["image/png", "image"],
["image/jpeg", "image"],
["image/gif", "image"],
["image/webp", "image"],
["image/svg+xml", "image"],
["image/avif", "image"],
["IMAGE/PNG", "image"], // case-insensitive
[" image/png ", "image"], // trim
// video
["video/mp4", "video"],
["video/webm", "video"],
["video/quicktime", "video"],
// audio
["audio/mpeg", "audio"],
["audio/wav", "audio"],
["audio/ogg", "audio"],
// pdf
["application/pdf", "pdf"],
// text family
["text/plain", "text"],
["text/markdown", "text"],
["text/html", "text"],
["text/css", "text"],
["text/javascript", "text"],
["text/csv", "text"],
["application/json", "text"],
["application/yaml", "text"],
["application/x-yaml", "text"],
["application/javascript", "text"],
["application/typescript", "text"],
// unknown / non-renderable → file
["application/zip", "file"],
["application/octet-stream", "file"],
["application/x-tar", "file"],
["application/vnd.ms-excel", "file"],
["weird/unknown-thing", "file"],
];
for (const [mime, expected] of cases) {
it(`mimeType=${JSON.stringify(mime)}${expected}`, () => {
expect(getAttachmentPreviewKind(mime)).toBe(expected);
});
}
});
describe("extension fallback when MIME is missing or generic", () => {
const cases: Array<[string | undefined, string | undefined, string | undefined, ReturnType<typeof getAttachmentPreviewKind>]> = [
// [mime, uri, name, expected]
[undefined, "workspace:/tmp/screenshot.png", "screenshot.png", "image"],
["", "workspace:/tmp/photo.JPG", "photo.JPG", "image"],
["application/octet-stream", "workspace:/tmp/clip.mp4", "clip.mp4", "video"],
[undefined, "workspace:/foo/song.mp3", "song.mp3", "audio"],
[undefined, "workspace:/docs/report.pdf", "report.pdf", "pdf"],
[undefined, "workspace:/code/main.py", "main.py", "text"],
[undefined, "workspace:/data/notes.md", "notes.md", "text"],
// No extension → file
[undefined, "workspace:/tmp/Dockerfile", "Dockerfile", "file"],
// Trailing dot → file
[undefined, "workspace:/tmp/weird.", "weird.", "file"],
// URL with query string + fragment → strip before parsing
[undefined, "https://example.com/foo.png?download=1#anchor", "", "image"],
// Unknown extension → file
[undefined, "workspace:/tmp/something.xyz", "something.xyz", "file"],
// Empty
[undefined, "", "", "file"],
[undefined, undefined, undefined, "file"],
];
for (const [mime, uri, name, expected] of cases) {
it(`mime=${mime ?? "<undef>"} uri=${uri} name=${name}${expected}`, () => {
expect(getAttachmentPreviewKind(mime, uri, name)).toBe(expected);
});
}
});
describe("MIME wins over extension", () => {
it("explicit mime=application/zip + extension=.png → file (don't render zip as image)", () => {
// Critical safety: agent might attach a .png-named file that's
// actually a zip. The strict-MIME branch wins and we render
// the chip, not an <img> that 404s on broken bytes.
expect(getAttachmentPreviewKind("application/zip", "x.png", "x.png")).toBe("file");
});
it("explicit mime=text/plain + extension=.png → text", () => {
expect(getAttachmentPreviewKind("text/plain", "log.png", "log.png")).toBe("text");
});
});
describe("regression: hostile-reviewer cases", () => {
it("does NOT misclassify image/svg+xml as text (svg is image even though it has XML)", () => {
expect(getAttachmentPreviewKind("image/svg+xml")).toBe("image");
});
it("application/octet-stream + extension=.docx → file (no renderer, don't try)", () => {
expect(getAttachmentPreviewKind("application/octet-stream", "f.docx", "f.docx")).toBe("file");
});
it("non-canonical MIME application/json works", () => {
expect(getAttachmentPreviewKind("application/json")).toBe("text");
});
});
});
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { resolveAttachmentHref } from "../uploads";
import { isPlatformAttachment, resolveAttachmentHref } from "../uploads";
describe("resolveAttachmentHref — URI scheme normalisation", () => {
const wsId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
@@ -39,3 +39,128 @@ describe("resolveAttachmentHref — URI scheme normalisation", () => {
expect(resolveAttachmentHref(wsId, "s3://bucket/key")).toBe("s3://bucket/key");
});
});
// #2973 follow-up to #2968: cover the platform-pending: scheme branch
// (poll-mode chat uploads) + the isPlatformAttachment SSOT helper that
// the chip-download and markdown-link paths both consume.
//
// Pre-fix the platform-pending: URI fell through to the raw URI →
// browser saw an unhandled-protocol click → about:blank. The fix
// resolves it to the platform pending-uploads endpoint with auth
// headers attached.
describe("resolveAttachmentHref — platform-pending: scheme (poll-mode uploads)", () => {
// Use a chat workspace ID that DIFFERS from the one in the URI, so
// tests can verify which one the resolver uses. The forward-across-
// workspace case is real production behavior — files dragged into one
// workspace's chat can be referenced from another.
const chatWs = "chat-ws-aaaaaaaa";
const sourceWs = "source-ws-bbbbbbbb";
it("resolves a well-formed platform-pending: URI to /pending-uploads/<file>/content", () => {
const url = resolveAttachmentHref(
chatWs,
`platform-pending:${sourceWs}/file-12345`,
);
expect(url).toContain(`/workspaces/${sourceWs}/pending-uploads/file-12345/content`);
});
it("uses the URI's wsid, NOT the chat workspace_id (cross-workspace forwarding)", () => {
// The two ids differ — this is the case PR #2968's commit
// explicitly calls out. A regression that flipped this would
// silently mis-route the download to the WRONG workspace's
// pending-uploads store, returning 404 (or worse, leaking).
const url = resolveAttachmentHref(
chatWs,
`platform-pending:${sourceWs}/file-xyz`,
);
expect(url).toContain(`/workspaces/${sourceWs}/`);
expect(url).not.toContain(`/workspaces/${chatWs}/`);
});
it("falls back to raw URI when platform-pending: is missing the slash", () => {
// Defensive: a URI that drifted from the expected wsid/fileid shape
// returns raw rather than producing a broken /pending-uploads//
// path. Pinned to detect a regression where a future "helpful"
// change synthesizes empty wsid/fileID.
expect(resolveAttachmentHref(chatWs, "platform-pending:no-slash")).toBe(
"platform-pending:no-slash",
);
});
it("falls back to raw URI when platform-pending: has empty fileID", () => {
expect(resolveAttachmentHref(chatWs, "platform-pending:abc/")).toBe(
"platform-pending:abc/",
);
});
it("falls back to raw URI when platform-pending: has empty wsid", () => {
expect(resolveAttachmentHref(chatWs, "platform-pending:/file-xyz")).toBe(
"platform-pending:/file-xyz",
);
});
it("regression: exact production repro from #2968 (reno-stars)", () => {
// From the original PR #2968 body: the chat's markdown-link
// override fell through on this exact shape and the browser
// navigated to about:blank. Pin the post-fix output so a future
// refactor can't reintroduce the original bug.
const url = resolveAttachmentHref(
"chat-ws",
"platform-pending:d76977b1-uuid/bb0dcaf3-uuid",
);
expect(url).toContain("/workspaces/d76977b1-uuid/pending-uploads/bb0dcaf3-uuid/content");
expect(url).not.toContain("chat-ws");
});
});
describe("isPlatformAttachment", () => {
it("returns true for platform-pending: URIs", () => {
expect(isPlatformAttachment("platform-pending:abc/file")).toBe(true);
});
it("returns true even for malformed platform-pending: URIs", () => {
// The helper is a SHAPE check — caller routes through
// downloadChatFile and downloadChatFile handles the malformed case
// downstream. Pinning so a future helper that "validates" the
// wsid/fileID shape doesn't silently break the auth-attached
// download flow for in-flight URIs.
expect(isPlatformAttachment("platform-pending:no-slash")).toBe(true);
});
it("returns true for workspace:<allowed-root> URIs", () => {
expect(isPlatformAttachment("workspace:/configs/foo")).toBe(true);
expect(isPlatformAttachment("workspace:/workspace/x.pdf")).toBe(true);
});
it("returns true for file:///<allowed-root> URIs", () => {
expect(isPlatformAttachment("file:///workspace/x")).toBe(true);
});
it("returns true for absolute paths under allowed roots", () => {
expect(isPlatformAttachment("/home/user/x")).toBe(true);
expect(isPlatformAttachment("/configs/y")).toBe(true);
});
it("returns FALSE for bare HTTPS URLs to other origins", () => {
// Auth-leak class regression: a helper that always returned true
// would attach workspace tokens to third-party requests. Pin
// the negative case explicitly.
expect(isPlatformAttachment("https://example.com/file")).toBe(false);
expect(isPlatformAttachment("http://example.com/file")).toBe(false);
});
it("returns FALSE for non-allowlisted root paths", () => {
expect(isPlatformAttachment("/etc/passwd")).toBe(false);
expect(isPlatformAttachment("/var/log/x")).toBe(false);
expect(isPlatformAttachment("/tmp/x")).toBe(false);
});
it("returns FALSE for empty string", () => {
expect(isPlatformAttachment("")).toBe(false);
});
it("returns FALSE for unrecognised schemes", () => {
expect(isPlatformAttachment("s3://bucket/key")).toBe(false);
expect(isPlatformAttachment("ftp://server/file")).toBe(false);
});
});
@@ -0,0 +1,154 @@
// preview-kind.ts — single source of truth for "what renderer should
// this attachment use" (RFC #2991, PR-1).
//
// Per the RFC's Phase 2 design, MIME type is the dispatch axis. The
// wire shape (ChatAttachment.mimeType) already carries it end-to-end
// from the server's chat_files.go through agent_message_writer.go to
// the canvas hydrater — we just need to map it to a render kind.
//
// Why a separate file from AttachmentPreview.tsx: the kind helper is
// a pure function that's easier to unit-test in isolation than a
// React component, and unit tests across MIME families are the
// regression line for new types added later.
/** The render-kind taxonomy. Each kind has a dedicated component:
*
* image → AttachmentImage (inline thumbnail + click → lightbox)
* video → AttachmentVideo (HTML5 <video controls>, native fullscreen)
* audio → AttachmentAudio (HTML5 <audio controls>)
* pdf → AttachmentPDF (browser-native <embed>, fullscreen modal)
* text → AttachmentTextPreview (monospace, first N lines, expand)
* file → AttachmentChip (existing fallback — generic file pill)
*
* NB: `text` includes JSON, YAML, source code, plain text — anything
* that renders sensibly as preformatted ASCII without a specialized
* viewer. PR-1 ships only `image` + `file`; PR-2 adds video/audio;
* PR-3 adds pdf + text. All routed through this same dispatch table
* so adding a new kind is a one-line registration. */
export type AttachmentPreviewKind = "image" | "video" | "audio" | "pdf" | "text" | "file";
/** Maps a MIME type to the render kind. Falls back to "file" for
* any MIME we don't have a renderer for (current behavior — the
* attachment chip is the universal fallback).
*
* Filename-based fallback: when mimeType is missing or generic
* (application/octet-stream), inspect the URI's extension. The
* workspace-server's chat_files.go derives Content-Type from the
* file extension, but agent-emitted attachments may not always
* set mimeType, and the canvas should still preview a file named
* `screenshot.png` even if the wire shape lacks the MIME.
*
* Strict MIME match always wins; extension fallback only applies
* to empty / generic. Unknown extension → "file". */
export function getAttachmentPreviewKind(
mimeType: string | undefined,
uri?: string,
name?: string,
): AttachmentPreviewKind {
const mime = (mimeType ?? "").toLowerCase().trim();
// Strict MIME match (preferred — set by server's Content-Type
// detection or by the agent's explicit mimeType field).
if (mime.startsWith("image/")) return "image";
if (mime.startsWith("video/")) return "video";
if (mime.startsWith("audio/")) return "audio";
if (mime === "application/pdf") return "pdf";
if (
mime.startsWith("text/") ||
mime === "application/json" ||
mime === "application/yaml" ||
mime === "application/x-yaml" ||
mime === "application/javascript" ||
mime === "application/typescript"
) {
return "text";
}
// Extension-based fallback — only when MIME is missing or
// application/octet-stream (the server's "I don't know" default).
// Skip when MIME is set to something specific we just don't have
// a renderer for (e.g. application/zip → file is correct).
const looksGeneric = mime === "" || mime === "application/octet-stream";
if (looksGeneric) {
const ext = extractExtension(uri, name);
if (ext) {
const kind = EXTENSION_KIND.get(ext);
if (kind) return kind;
}
}
return "file";
}
// Extension → kind table for the fallback branch. Keep this list
// short and curated — every entry is a UX commitment to render
// inline, and a wrong inference (e.g. .doc rendered as text) is
// worse than the generic file chip.
const EXTENSION_KIND: ReadonlyMap<string, AttachmentPreviewKind> = new Map([
// Images
["png", "image"],
["jpg", "image"],
["jpeg", "image"],
["gif", "image"],
["webp", "image"],
["svg", "image"],
["avif", "image"],
["bmp", "image"],
// Video
["mp4", "video"],
["webm", "video"],
["mov", "video"],
["mkv", "video"],
// Audio
["mp3", "audio"],
["wav", "audio"],
["ogg", "audio"],
["m4a", "audio"],
["flac", "audio"],
// PDF
["pdf", "pdf"],
// Text-ish (rendered as preformatted ASCII)
["txt", "text"],
["md", "text"],
["json", "text"],
["yaml", "text"],
["yml", "text"],
["js", "text"],
["ts", "text"],
["tsx", "text"],
["jsx", "text"],
["py", "text"],
["go", "text"],
["rs", "text"],
["java", "text"],
["c", "text"],
["cpp", "text"],
["h", "text"],
["hpp", "text"],
["sh", "text"],
["bash", "text"],
["html", "text"],
["css", "text"],
["sql", "text"],
["toml", "text"],
["ini", "text"],
["xml", "text"],
["csv", "text"],
["log", "text"],
]);
/** Extracts the lowercased extension from a uri or name, without
* the leading dot. Returns "" when no extension is present. */
function extractExtension(uri: string | undefined, name: string | undefined): string {
// Prefer name (always a leaf path); fall back to uri's last
// segment. Strip query string + fragment so a URI like
// "https://example.com/foo.png?download=1" still parses as png.
const candidate = name || uri || "";
if (!candidate) return "";
let leaf = candidate.split(/[\\/]/).pop() || "";
// Drop ?query and #fragment.
leaf = leaf.split(/[?#]/)[0];
const dot = leaf.lastIndexOf(".");
if (dot < 0 || dot === leaf.length - 1) return "";
return leaf.slice(dot + 1).toLowerCase();
}
+11 -11
View File
@@ -98,14 +98,14 @@ Each of the 8 adapter template repos contains:
| Adapter | Repo |
|---------|------|
| claude-code | https://github.com/Molecule-AI/molecule-ai-workspace-template-claude-code |
| langgraph | https://github.com/Molecule-AI/molecule-ai-workspace-template-langgraph |
| crewai | https://github.com/Molecule-AI/molecule-ai-workspace-template-crewai |
| autogen | https://github.com/Molecule-AI/molecule-ai-workspace-template-autogen |
| deepagents | https://github.com/Molecule-AI/molecule-ai-workspace-template-deepagents |
| hermes | https://github.com/Molecule-AI/molecule-ai-workspace-template-hermes |
| gemini-cli | https://github.com/Molecule-AI/molecule-ai-workspace-template-gemini-cli |
| openclaw | https://github.com/Molecule-AI/molecule-ai-workspace-template-openclaw |
| claude-code | https://git.moleculesai.app/molecule-ai/molecule-ai-workspace-template-claude-code |
| langgraph | https://git.moleculesai.app/molecule-ai/molecule-ai-workspace-template-langgraph |
| crewai | https://git.moleculesai.app/molecule-ai/molecule-ai-workspace-template-crewai |
| autogen | https://git.moleculesai.app/molecule-ai/molecule-ai-workspace-template-autogen |
| deepagents | https://git.moleculesai.app/molecule-ai/molecule-ai-workspace-template-deepagents |
| hermes | https://git.moleculesai.app/molecule-ai/molecule-ai-workspace-template-hermes |
| gemini-cli | https://git.moleculesai.app/molecule-ai/molecule-ai-workspace-template-gemini-cli |
| openclaw | https://git.moleculesai.app/molecule-ai/molecule-ai-workspace-template-openclaw |
## Adapter discovery (ADAPTER_MODULE)
@@ -244,7 +244,7 @@ correctness before pushing a `runtime-v*` tag.
## Writing a new adapter
Use the GitHub template repo
[`Molecule-AI/molecule-ai-workspace-template-starter`](https://github.com/Molecule-AI/molecule-ai-workspace-template-starter)
[`molecule-ai/molecule-ai-workspace-template-starter`](https://git.moleculesai.app/molecule-ai/molecule-ai-workspace-template-starter) (note: the starter repo did not survive the 2026-05-06 GitHub-org-suspension migration; recreation tracked at internal#41)
— it ships with the canonical Dockerfile + adapter.py skeleton + config.yaml
schema + the `repository_dispatch: [runtime-published]` cascade receiver
already wired up. No follow-up setup PR required.
@@ -256,7 +256,7 @@ gh repo create Molecule-AI/molecule-ai-workspace-template-<runtime> \
--public \
--description "Molecule AI workspace template: <runtime>"
git clone https://github.com/Molecule-AI/molecule-ai-workspace-template-<runtime>
git clone https://git.moleculesai.app/molecule-ai/molecule-ai-workspace-template-<runtime>.git
cd molecule-ai-workspace-template-<runtime>
```
@@ -286,7 +286,7 @@ After `git push`:
If the canonical shape changes (e.g. `config.yaml` schema gets a new field,
the `BaseAdapter` interface adds a method, the reusable CI workflow
signature changes), update the
[starter](https://github.com/Molecule-AI/molecule-ai-workspace-template-starter)
[starter](https://git.moleculesai.app/molecule-ai/molecule-ai-workspace-template-starter) (recreation pending — see note above)
**first**. Existing templates can either migrate at their own pace or be
touched in a coordinated cleanup PR. Either way, future templates pick up
the new shape from day one.
+3 -2
View File
@@ -54,6 +54,7 @@ TOP_LEVEL_MODULES = {
"a2a_client",
"a2a_executor",
"a2a_mcp_server",
"a2a_response",
"a2a_tools",
"a2a_tools_delegation",
"a2a_tools_inbox",
@@ -277,7 +278,7 @@ include = ["molecule_runtime*"]
README_TEMPLATE = """\
# molecule-ai-workspace-runtime
Shared workspace runtime for [Molecule AI](https://github.com/Molecule-AI/molecule-core)
Shared workspace runtime for [Molecule AI](https://git.moleculesai.app/molecule-ai/molecule-core)
agent adapters. Installed by every workspace template image
(`workspace-template-claude-code`, `-langgraph`, `-hermes`, etc.) to provide
A2A delegation, heartbeat, memory, plugin loading, and skill management.
@@ -395,7 +396,7 @@ If you don't need real-time push, the default poll path works
universally with no extra setup; both modes converge on the same
`inbox_pop` ack so messages never duplicate.
See [`docs/workspace-runtime-package.md`](https://github.com/Molecule-AI/molecule-core/blob/main/docs/workspace-runtime-package.md)
See [`docs/workspace-runtime-package.md`](https://git.moleculesai.app/molecule-ai/molecule-core/src/branch/main/docs/workspace-runtime-package.md)
for the publish flow and architecture.
"""
+216
View File
@@ -0,0 +1,216 @@
#!/usr/bin/env bash
# scripts/check-stale-promote-pr.sh
#
# Scan open auto-promote PRs (base=main head=staging) for the
# silent-block failure mode that motivated issue #2975:
# - PR sat for hours with mergeStateStatus=BLOCKED
# - reviewDecision=REVIEW_REQUIRED (auto-merge armed but waiting
# on a human approval that never comes)
#
# When found, emit:
# - GitHub Actions notice/warning lines (workflow summary surface)
# - Optionally post a comment on the PR (--comment)
#
# Exit code is the count of stale PRs found, capped at 125 so callers
# can detect "alarm fired" via `if ! check-stale-promote-pr.sh; then …`.
# Exit 0 = clean, exit ≥1 = at least N stale PRs need attention.
#
# Used by .github/workflows/auto-promote-stale-alarm.yml. Logic lives
# here (not inline in the workflow YAML) so we can:
# - Unit-test it with a stubbed `gh` (see test-check-stale-promote-pr.sh)
# - Run it ad-hoc by an operator: `scripts/check-stale-promote-pr.sh`
# - Reuse the same surface in any sibling workflow that needs the same
# check (SSOT — one detector, many callers).
#
# Requires: `gh` CLI, `jq`. `GH_TOKEN` env in the workflow context.
set -euo pipefail
# -----------------------------------------------------------------------------
# Inputs
# -----------------------------------------------------------------------------
# Threshold beyond which a BLOCKED+REVIEW_REQUIRED promote PR is "stale"
# enough to alarm. 4 hours is the floor: most legitimate gates clear
# inside an hour, so 4× headroom is plenty for slow CI without false-
# alarming. Override via env for tests + edge ops.
STALE_HOURS="${STALE_HOURS:-4}"
# Repo defaults to the current `gh` context. Tests pass --repo explicitly.
REPO="${GITHUB_REPOSITORY:-}"
# Whether to post a comment to the PR. Off by default to avoid noise on
# manual ad-hoc runs; the cron workflow turns it on.
POST_COMMENT="${POST_COMMENT:-false}"
# Where to read the open-PR JSON from. Empty = call `gh` live. Tests
# point this at a fixture file.
PR_FIXTURE="${PR_FIXTURE:-}"
# Where to read "now" from. Empty = real clock. Tests freeze time so
# the staleness math is deterministic.
NOW_OVERRIDE="${NOW_OVERRIDE:-}"
while [ $# -gt 0 ]; do
case "$1" in
--repo) REPO="$2"; shift 2 ;;
--comment) POST_COMMENT="true"; shift ;;
--no-comment) POST_COMMENT="false"; shift ;;
--fixture) PR_FIXTURE="$2"; shift 2 ;;
--stale-hours) STALE_HOURS="$2"; shift 2 ;;
-h|--help)
sed -n '1,/^set /p' "$0" | grep '^# ' | sed 's/^# //'
exit 0
;;
*) echo "unknown arg: $1" >&2; exit 64 ;;
esac
done
if [ -z "$REPO" ] && [ -z "$PR_FIXTURE" ]; then
echo "::error::REPO env (or GITHUB_REPOSITORY) required when no fixture given" >&2
exit 2
fi
# -----------------------------------------------------------------------------
# Clock helpers — split out so tests can freeze time
# -----------------------------------------------------------------------------
now_epoch() {
if [ -n "$NOW_OVERRIDE" ]; then
printf '%s\n' "$NOW_OVERRIDE"
else
date -u +%s
fi
}
# Parse RFC3339 timestamps the way GitHub emits them (e.g.
# "2026-05-05T23:15:00Z"). gnu-date uses -d, bsd-date uses -j -f. Cover
# both because the workflow runs on ubuntu-latest (gnu) but operators
# may run this script on macOS (bsd).
to_epoch() {
local ts="$1"
# gnu-date path first.
if date -u -d "$ts" +%s 2>/dev/null; then
return 0
fi
# bsd-date fallback — strip optional fractional seconds before %S.
local ts_clean="${ts%%.*}"
ts_clean="${ts_clean%Z}Z"
date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$ts_clean" +%s 2>/dev/null || {
echo "::error::cannot parse timestamp: $ts" >&2
return 1
}
}
# -----------------------------------------------------------------------------
# Fetch open auto-promote PRs
# -----------------------------------------------------------------------------
fetch_prs() {
if [ -n "$PR_FIXTURE" ]; then
cat "$PR_FIXTURE"
return 0
fi
gh pr list --repo "$REPO" \
--base main --head staging --state open \
--json number,title,createdAt,mergeStateStatus,reviewDecision,url
}
# -----------------------------------------------------------------------------
# Stale detection
# -----------------------------------------------------------------------------
# Read PR list from stdin, emit one TSV line per stale PR:
# <num>\t<age_hours>\t<url>\t<title>
# Caller decides what to do (warn, comment, escalate).
detect_stale() {
local now_ts
now_ts="$(now_epoch)"
local stale_seconds=$((STALE_HOURS * 3600))
jq -r '.[] | [.number, .createdAt, .mergeStateStatus, .reviewDecision, .url, .title] | @tsv' \
| while IFS=$'\t' read -r num created_at merge_state review_decision url title; do
# Only alarm on the specific failure mode: BLOCKED + REVIEW_REQUIRED.
# Other BLOCKED reasons (DIRTY, BEHIND, failed checks) are the
# author's signal-to-fix; this script targets the silent
# "no human reviewed yet" wedge specifically.
[ "$merge_state" = "BLOCKED" ] || continue
[ "$review_decision" = "REVIEW_REQUIRED" ] || continue
local created_ts
created_ts="$(to_epoch "$created_at")" || continue
local age=$((now_ts - created_ts))
if [ "$age" -ge "$stale_seconds" ]; then
local age_h=$((age / 3600))
printf '%s\t%d\t%s\t%s\n' "$num" "$age_h" "$url" "$title"
fi
done
}
# -----------------------------------------------------------------------------
# Reporting
# -----------------------------------------------------------------------------
# Comment body — kept short; the issue body has the full design.
comment_body() {
local age_h="$1"
cat <<EOF
⚠️ This auto-promote PR has been BLOCKED on \`REVIEW_REQUIRED\` for **${age_h}h**.
Auto-merge is armed, but main's branch protection requires 1 review and no human has approved. Until someone reviews, the staging→main promote chain is wedged and downstream consumers (canvas builds, tenant redeploys) won't see new code.
**Action**: a human reviewer on \`@Molecule-AI/maintainers\` should approve this PR (or mark it as not ready and close).
Detected by \`scripts/check-stale-promote-pr.sh\` per issue #2975.
EOF
}
post_comment() {
local pr_num="$1"
local age_h="$2"
if [ "$POST_COMMENT" != "true" ]; then
return 0
fi
# Idempotency: only one alarm comment per PR. Look for the marker
# string in existing comments before posting a new one.
local existing
existing="$(gh pr view "$pr_num" --repo "$REPO" --json comments \
--jq '.comments[] | select(.body | test("scripts/check-stale-promote-pr.sh per issue #2975")) | .databaseId' \
| head -n1)"
if [ -n "$existing" ]; then
echo "::notice::PR #$pr_num already has a stale-alarm comment ($existing) — not re-posting"
return 0
fi
comment_body "$age_h" | gh pr comment "$pr_num" --repo "$REPO" --body-file -
echo "::notice::Posted stale-alarm comment on PR #$pr_num (age=${age_h}h)"
}
# -----------------------------------------------------------------------------
# Main
# -----------------------------------------------------------------------------
stale_count=0
while IFS=$'\t' read -r num age_h url title; do
[ -n "$num" ] || continue
stale_count=$((stale_count + 1))
echo "::warning title=Stale auto-promote PR::PR #$num — BLOCKED on REVIEW_REQUIRED for ${age_h}h. $url"
{
echo "## ⚠️ Stale auto-promote PR detected"
echo
echo "- PR: #$num — \`$title\`"
echo "- Age: ${age_h}h"
echo "- State: BLOCKED on REVIEW_REQUIRED"
echo "- URL: $url"
echo
echo "Auto-merge is armed but waiting on a human review. See issue #2975."
} >> "${GITHUB_STEP_SUMMARY:-/dev/null}"
post_comment "$num" "$age_h"
done < <(fetch_prs | detect_stale)
if [ "$stale_count" -eq 0 ]; then
echo "::notice::No stale auto-promote PRs detected (threshold: ${STALE_HOURS}h)"
fi
# Cap exit code so we don't accidentally break shells that interpret
# >125 as signal-style. 1..N maps to "1..N stale PRs".
exit $(( stale_count > 125 ? 125 : stale_count ))
+257
View File
@@ -0,0 +1,257 @@
#!/usr/bin/env bash
# scripts/test-check-stale-promote-pr.sh
#
# Exhaustive bash unit tests for check-stale-promote-pr.sh.
# Goal: 100% branch coverage on the detector logic.
#
# Each case writes a fixture JSON, freezes the clock with NOW_OVERRIDE,
# runs the script with --fixture + --no-comment (so we don't try to
# actually call `gh pr comment`), and asserts on stdout/exit code.
#
# Run: bash scripts/test-check-stale-promote-pr.sh
# Expected: "All N tests passed" + exit 0.
set -euo pipefail
SCRIPT="$(cd "$(dirname "$0")" && pwd)/check-stale-promote-pr.sh"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
PASS=0
FAIL=0
# ─────────────────────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────────────────────
# Frozen "now" — 2026-05-06T05:00:00Z. Compute dynamically so the
# tests stay correct regardless of platform-specific date semantics
# (gnu vs bsd) and any author math errors on the epoch.
if FROZEN_NOW="$(date -u -d '2026-05-06T05:00:00Z' +%s 2>/dev/null)"; then
: # gnu-date worked
elif FROZEN_NOW="$(date -u -j -f '%Y-%m-%dT%H:%M:%SZ' '2026-05-06T05:00:00Z' +%s 2>/dev/null)"; then
: # bsd-date worked
else
echo "FATAL: cannot compute FROZEN_NOW on this platform" >&2
exit 1
fi
run_script() {
# Args: <fixture-file>
# Returns stdout + exit code via a known marker.
local fixture="$1"
shift
set +e
NOW_OVERRIDE="$FROZEN_NOW" \
POST_COMMENT="false" \
bash "$SCRIPT" --fixture "$fixture" "$@" 2>&1
local rc=$?
set -e
echo "EXIT_CODE=$rc"
}
assert_pass() {
local name="$1"
local got="$2"
local want_pattern="$3"
if printf '%s' "$got" | grep -qE "$want_pattern"; then
PASS=$((PASS + 1))
printf ' ✓ %s\n' "$name"
else
FAIL=$((FAIL + 1))
printf ' ✗ %s\n want pattern: %s\n got:\n%s\n' "$name" "$want_pattern" "$got"
fi
}
assert_no_match() {
local name="$1"
local got="$2"
local bad_pattern="$3"
if printf '%s' "$got" | grep -qE "$bad_pattern"; then
FAIL=$((FAIL + 1))
printf ' ✗ %s\n bad pattern matched: %s\n got:\n%s\n' "$name" "$bad_pattern" "$got"
else
PASS=$((PASS + 1))
printf ' ✓ %s\n' "$name"
fi
}
# ─────────────────────────────────────────────────────────────────────────────
# Test cases
# ─────────────────────────────────────────────────────────────────────────────
echo "1. Empty PR list — clean exit"
echo '[]' > "$TMP/empty.json"
got=$(run_script "$TMP/empty.json")
assert_pass "empty-no-warning" "$got" "No stale auto-promote PRs detected"
assert_pass "empty-exit-zero" "$got" "EXIT_CODE=0"
echo
echo "2. Single PR, BLOCKED+REVIEW_REQUIRED, 5h old — fires alarm"
cat > "$TMP/stale1.json" <<EOF
[{
"number": 2963,
"title": "staging → main",
"createdAt": "2026-05-06T00:00:00Z",
"mergeStateStatus": "BLOCKED",
"reviewDecision": "REVIEW_REQUIRED",
"url": "https://github.com/test/test/pull/2963"
}]
EOF
got=$(run_script "$TMP/stale1.json")
assert_pass "stale1-warning" "$got" "Stale auto-promote PR"
assert_pass "stale1-pr-number" "$got" "PR #2963"
assert_pass "stale1-age" "$got" "for 5h"
assert_pass "stale1-exit-1" "$got" "EXIT_CODE=1"
echo
echo "3. Same PR but only 3h old — under threshold, NO alarm"
cat > "$TMP/young.json" <<EOF
[{
"number": 100,
"title": "fresh promote",
"createdAt": "2026-05-06T02:00:00Z",
"mergeStateStatus": "BLOCKED",
"reviewDecision": "REVIEW_REQUIRED",
"url": "https://github.com/test/test/pull/100"
}]
EOF
got=$(run_script "$TMP/young.json")
assert_pass "young-no-alarm" "$got" "No stale auto-promote PRs"
assert_pass "young-exit-zero" "$got" "EXIT_CODE=0"
assert_no_match "young-no-warning" "$got" "Stale auto-promote PR"
echo
echo "4. PR is BLOCKED but for the wrong reason (DIRTY, not REVIEW_REQUIRED)"
cat > "$TMP/dirty.json" <<EOF
[{
"number": 200,
"title": "needs rebase",
"createdAt": "2026-05-06T00:00:00Z",
"mergeStateStatus": "BLOCKED",
"reviewDecision": "APPROVED",
"url": "https://github.com/test/test/pull/200"
}]
EOF
got=$(run_script "$TMP/dirty.json")
assert_pass "dirty-no-alarm" "$got" "No stale auto-promote PRs"
assert_pass "dirty-exit-zero" "$got" "EXIT_CODE=0"
echo
echo "5. PR is APPROVED but mergeStateStatus is CLEAN — NOT alarming"
cat > "$TMP/clean.json" <<EOF
[{
"number": 300,
"title": "all green",
"createdAt": "2026-05-06T00:00:00Z",
"mergeStateStatus": "CLEAN",
"reviewDecision": "APPROVED",
"url": "https://github.com/test/test/pull/300"
}]
EOF
got=$(run_script "$TMP/clean.json")
assert_pass "clean-no-alarm" "$got" "No stale auto-promote PRs"
echo
echo "6. Multiple PRs — only the BLOCKED+REVIEW_REQUIRED+old one alarms"
cat > "$TMP/mixed.json" <<EOF
[
{
"number": 100,
"title": "fresh",
"createdAt": "2026-05-06T04:00:00Z",
"mergeStateStatus": "BLOCKED",
"reviewDecision": "REVIEW_REQUIRED",
"url": "https://x/100"
},
{
"number": 200,
"title": "stale + alarming",
"createdAt": "2026-05-05T20:00:00Z",
"mergeStateStatus": "BLOCKED",
"reviewDecision": "REVIEW_REQUIRED",
"url": "https://x/200"
},
{
"number": 300,
"title": "approved + clean",
"createdAt": "2026-05-05T20:00:00Z",
"mergeStateStatus": "CLEAN",
"reviewDecision": "APPROVED",
"url": "https://x/300"
}
]
EOF
got=$(run_script "$TMP/mixed.json")
assert_pass "mixed-only-200" "$got" "PR #200"
assert_no_match "mixed-not-100" "$got" "PR #100"
assert_no_match "mixed-not-300" "$got" "PR #300"
assert_pass "mixed-exit-1" "$got" "EXIT_CODE=1"
echo
echo "7. Custom STALE_HOURS via --stale-hours overrides threshold"
got=$(run_script "$TMP/young.json" --stale-hours 1)
assert_pass "custom-threshold-fires" "$got" "PR #100"
assert_pass "custom-threshold-exit-1" "$got" "EXIT_CODE=1"
echo
echo "8. Two stale PRs — exit code reflects count"
cat > "$TMP/two-stale.json" <<EOF
[
{
"number": 200,
"title": "stale-A",
"createdAt": "2026-05-05T20:00:00Z",
"mergeStateStatus": "BLOCKED",
"reviewDecision": "REVIEW_REQUIRED",
"url": "https://x/200"
},
{
"number": 201,
"title": "stale-B",
"createdAt": "2026-05-05T19:00:00Z",
"mergeStateStatus": "BLOCKED",
"reviewDecision": "REVIEW_REQUIRED",
"url": "https://x/201"
}
]
EOF
got=$(run_script "$TMP/two-stale.json")
assert_pass "two-stale-exit-2" "$got" "EXIT_CODE=2"
echo
echo "9. Help text is shown for --help"
set +e
help_out=$(bash "$SCRIPT" --help 2>&1)
help_rc=$?
set -e
assert_pass "help-exits-zero" "EXIT_CODE=$help_rc" "EXIT_CODE=0"
assert_pass "help-mentions-issue" "$help_out" "issue #2975"
echo
echo "10. Unknown arg exits 64 (EX_USAGE)"
set +e
bad_out=$(bash "$SCRIPT" --bogus 2>&1)
bad_rc=$?
set -e
assert_pass "unknown-arg-rc" "EXIT_CODE=$bad_rc" "EXIT_CODE=64"
echo
echo "11. Missing repo + missing fixture exits 2"
set +e
out=$(REPO="" bash "$SCRIPT" 2>&1)
rc=$?
set -e
assert_pass "no-repo-exit-2" "EXIT_CODE=$rc" "EXIT_CODE=2"
# ─────────────────────────────────────────────────────────────────────────────
# Summary
# ─────────────────────────────────────────────────────────────────────────────
echo
echo "─────────────────────────────────────────────"
echo "Tests: $PASS passed, $FAIL failed"
if [ "$FAIL" -gt 0 ]; then
exit 1
fi
echo "All tests passed."
+37
View File
@@ -157,6 +157,43 @@ A2A_RESP=$(curl -s --max-time "$TIMEOUT" -X POST "$BASE/workspaces/$POLL_WS_ID/a
}')
check "poll-mode A2A returns queued status" '"status":"queued"' "$A2A_RESP"
# ---------- Phase 3.5: Python parser classifies queued envelope correctly ----------
# (#2967) — server emits the queued envelope, the wheel's a2a_response.parse()
# MUST classify it as the Queued variant, not Malformed. Pre-#2967 the bare
# message/send parser in a2a_client.py:587 misclassified this and returned
# "[A2A_ERROR] unexpected response shape", which broke external↔external A2A
# on poll-mode peers.
#
# This phase exercises the actual on-the-wire response from a real
# workspace-server (NOT a mocked dict) through the same module the production
# wheel ships, so a regression in either the server emit shape OR the client
# parser fails this E2E.
echo ""
echo "--- Phase 3.5: Python parser classifies real server response (#2967) ---"
# Pipe the queued response captured above through a2a_response.parse and
# assert the classification. WORKSPACE_ID is required at module import
# time but irrelevant to this parsing call (any UUID is fine).
PARSE_RESULT=$(WORKSPACE_ID="00000000-0000-0000-0000-000000000001" \
python3 -c "
import json, sys
sys.path.insert(0, '$(cd "$(dirname "$0")/../../workspace" && pwd)')
import a2a_response
data = json.loads(r'''$A2A_RESP''')
v = a2a_response.parse(data)
print(type(v).__name__)
if isinstance(v, a2a_response.Queued):
print(f'method={v.method} delivery_mode={v.delivery_mode}')
")
check_eq "Python parser classifies real server response as Queued" \
"Queued" "$(printf '%s' "$PARSE_RESULT" | head -n1)"
check "Queued variant captures method=message/send" \
"method=message/send" "$PARSE_RESULT"
check "Queued variant captures delivery_mode=poll" \
"delivery_mode=poll" "$PARSE_RESULT"
check "queued response echoes delivery_mode=poll" '"delivery_mode":"poll"' "$A2A_RESP"
check "queued response echoes the JSON-RPC method" '"method":"message/send"' "$A2A_RESP"
@@ -21,6 +21,7 @@ import (
"os"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/memory/contract"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/textutil"
)
// verifyConfig is the typed dependency bundle for verifyParity.
@@ -121,7 +122,7 @@ func verifyParity(ctx context.Context, cfg verifyConfig, stdout *os.File) (*veri
matched := true
for _, c := range legacy {
if pluginContents[c] == 0 {
fmt.Fprintf(stdout, "[mismatch] workspace=%s missing-from-plugin content=%q\n", wsID, truncate(c, 80))
fmt.Fprintf(stdout, "[mismatch] workspace=%s missing-from-plugin content=%q\n", wsID, textutil.TruncateBytes(c, 80))
matched = false
break
}
@@ -192,9 +193,4 @@ func queryLegacyMemories(ctx context.Context, db *sql.DB, workspaceID string) ([
return out, rows.Err()
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
// truncation moved to internal/textutil.TruncateBytes (#2962 SSOT).
@@ -349,16 +349,8 @@ func TestVerifyParity_PickSampleError(t *testing.T) {
}
}
// --- Truncate ---
func TestVerifyTruncate(t *testing.T) {
if got := truncate("short", 10); got != "short" {
t.Errorf("got %q", got)
}
if got := truncate(strings.Repeat("a", 200), 10); !strings.HasSuffix(got, "…") {
t.Errorf("expected ellipsis: %q", got)
}
}
// Truncate moved to internal/textutil — coverage in
// internal/textutil/truncate_test.go (TestTruncateBytes_RuneBoundary).
// --- CLI: -verify mode ---
+2 -2
View File
@@ -51,7 +51,7 @@ func Import(
return result
}
_ = broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_PROVISIONING", wsID, map[string]interface{}{
_ = broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceProvisioning), wsID, map[string]interface{}{
"name": b.Name,
"tier": b.Tier,
"source_bundle_id": b.ID,
@@ -142,7 +142,7 @@ func markFailed(ctx context.Context, wsID string, broadcaster *events.Broadcaste
db.DB.ExecContext(ctx,
`UPDATE workspaces SET status = $1, last_sample_error = $2, updated_at = now() WHERE id = $3`,
models.StatusFailed, msg, wsID)
broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_PROVISION_FAILED", wsID, map[string]interface{}{
broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceProvisionFailed), wsID, map[string]interface{}{
"error": msg,
})
}
+11 -10
View File
@@ -10,6 +10,7 @@ import (
"time"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
)
const (
@@ -304,14 +305,14 @@ func (m *Manager) HandleInbound(ctx context.Context, ch ChannelRow, msg *Inbound
"parts": []map[string]interface{}{{"kind": "text", "text": msg.Text}},
},
"metadata": map[string]interface{}{
"source": ch.ChannelType,
"channel_id": ch.ID,
"chat_id": msg.ChatID,
"user_id": msg.UserID,
"username": msg.Username,
"message_id": msg.MessageID,
"history": history,
"extra": msg.Metadata,
"source": ch.ChannelType,
"channel_id": ch.ID,
"chat_id": msg.ChatID,
"user_id": msg.UserID,
"username": msg.Username,
"message_id": msg.MessageID,
"history": history,
"extra": msg.Metadata,
},
},
})
@@ -383,7 +384,7 @@ func (m *Manager) HandleInbound(ctx context.Context, ch ChannelRow, msg *Inbound
// Broadcast event
if m.broadcaster != nil {
m.broadcaster.RecordAndBroadcast(ctx, "CHANNEL_MESSAGE", ch.WorkspaceID, map[string]interface{}{
m.broadcaster.RecordAndBroadcast(ctx, string(events.EventChannelMessage), ch.WorkspaceID, map[string]interface{}{
"channel_id": ch.ID,
"channel_type": ch.ChannelType,
"username": msg.Username,
@@ -427,7 +428,7 @@ func (m *Manager) SendOutbound(ctx context.Context, channelID string, text strin
}
if m.broadcaster != nil {
m.broadcaster.RecordAndBroadcast(ctx, "CHANNEL_MESSAGE", ch.WorkspaceID, map[string]interface{}{
m.broadcaster.RecordAndBroadcast(ctx, string(events.EventChannelMessage), ch.WorkspaceID, map[string]interface{}{
"channel_id": ch.ID,
"channel_type": ch.ChannelType,
"direction": "outbound",
@@ -14,10 +14,12 @@ import (
"time"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
"github.com/gin-gonic/gin"
)
// proxyDispatchBuildError is a sentinel wrapper for failures inside
// http.NewRequestWithContext. handleA2ADispatchError unwraps it to emit the
// "failed to create proxy request" 500 instead of the standard 502/503 paths.
@@ -90,10 +92,10 @@ func (h *WorkspaceHandler) handleA2ADispatchError(ctx context.Context, workspace
Status: http.StatusServiceUnavailable,
Headers: map[string]string{"Retry-After": strconv.Itoa(busyRetryAfterSeconds)},
Response: gin.H{
"error": "workspace agent busy — adapter handles retry (native_session)",
"busy": true,
"retry_after": busyRetryAfterSeconds,
"native_session": true,
"error": "workspace agent busy — adapter handles retry (native_session)",
"busy": true,
"retry_after": busyRetryAfterSeconds,
"native_session": true,
},
}
}
@@ -149,7 +151,7 @@ func (h *WorkspaceHandler) handleA2ADispatchError(ctx context.Context, workspace
// Provisioner selection (mutually exclusive in production):
// - h.provisioner != nil → local Docker deployment; IsRunning does docker inspect.
// - h.cpProv != nil → SaaS / EC2 deployment; IsRunning calls CP's
// /cp/workspaces/:id/status to read the EC2 state.
// /cp/workspaces/:id/status to read the EC2 state.
//
// Pre-fix this function ONLY consulted h.provisioner — for SaaS tenants
// (h.provisioner=nil, h.cpProv=set) it short-circuited to false on every
@@ -191,7 +193,7 @@ func (h *WorkspaceHandler) maybeMarkContainerDead(ctx context.Context, workspace
log.Printf("ProxyA2A: failed to mark workspace %s offline: %v", workspaceID, err)
}
db.ClearWorkspaceKeys(ctx, workspaceID)
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_OFFLINE", workspaceID, map[string]interface{}{})
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceOffline), workspaceID, map[string]interface{}{})
go h.RestartByID(workspaceID)
return true
}
@@ -272,7 +274,7 @@ func (h *WorkspaceHandler) logA2ASuccess(ctx context.Context, workspaceID, calle
}(ctx)
if callerID == "" && statusCode < 400 {
h.broadcaster.BroadcastOnly(workspaceID, "A2A_RESPONSE", map[string]interface{}{
h.broadcaster.BroadcastOnly(workspaceID, string(events.EventA2AResponse), map[string]interface{}{
"response_body": json.RawMessage(respBody),
"method": a2aMethod,
"duration_ms": durationMs,
@@ -21,6 +21,8 @@ import (
"time"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/textutil"
)
// extractIdempotencyKey pulls params.message.messageId out of an A2A JSON-RPC
@@ -419,7 +421,7 @@ func (h *WorkspaceHandler) stitchDrainResponseToDelegation(ctx context.Context,
AND method = 'delegate_result'
AND target_id = $4
AND response_body->>'delegation_id' = $5
`, "Delegation completed ("+truncate(responseText, 80)+")", string(respJSON),
`, "Delegation completed ("+textutil.TruncateBytes(responseText, 80)+")", string(respJSON),
sourceID, targetID, delegationID)
if err != nil {
log.Printf("A2AQueue drain stitch: update failed for delegation %s: %v", delegationID, err)
@@ -435,10 +437,10 @@ func (h *WorkspaceHandler) stitchDrainResponseToDelegation(ctx context.Context,
// "⏸ queued" line to "✓ completed" in real time. Without this the
// transition only surfaces after the user reloads or polls activity.
if h.broadcaster != nil {
h.broadcaster.RecordAndBroadcast(ctx, "DELEGATION_COMPLETE", sourceID, map[string]interface{}{
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventDelegationComplete), sourceID, map[string]interface{}{
"delegation_id": delegationID,
"target_id": targetID,
"response_preview": truncate(responseText, 200),
"response_preview": textutil.TruncateBytes(responseText, 200),
"via": "queue_drain",
})
}
+53 -9
View File
@@ -55,7 +55,7 @@ func NewActivityHandler(b *events.Broadcaster) *ActivityHandler {
func (h *ActivityHandler) List(c *gin.Context) {
workspaceID := c.Param("id")
activityType := c.Query("type")
source := c.Query("source") // "canvas" = source_id IS NULL, "agent" = source_id IS NOT NULL
source := c.Query("source") // "canvas" = source_id IS NULL, "agent" = source_id IS NOT NULL
peerID := c.Query("peer_id") // optional UUID — restrict to rows where this peer is sender OR target
limitStr := c.DefaultQuery("limit", "100")
sinceSecsStr := c.Query("since_secs")
@@ -580,7 +580,45 @@ func (h *ActivityHandler) Report(c *gin.Context) {
// LogActivity inserts an activity log and optionally broadcasts via WebSocket.
// Takes events.EventEmitter (#1814) so callers passing a stub broadcaster
// in tests no longer need to construct the full *events.Broadcaster.
//
// Errors are logged and swallowed — this is the fire-and-forget contract
// most callers expect. For atomic-with-sibling-writes use LogActivityTx
// and propagate the error.
func LogActivity(ctx context.Context, broadcaster events.EventEmitter, params ActivityParams) {
hook, err := logActivityExec(ctx, db.DB, broadcaster, params)
if err != nil {
log.Printf("LogActivity insert error: %v", err)
return
}
hook()
}
// LogActivityTx inserts the activity row inside the caller-provided tx
// and returns a commitHook that fires the post-commit ACTIVITY_LOGGED
// broadcast. Caller MUST invoke commitHook AFTER tx.Commit() — firing
// it before commit can leak a WebSocket event for a row that ends up
// rolled back, which the canvas's optimistic UI then shows then loses.
//
// Returns an error if the INSERT fails — caller should Rollback. Caller
// is also responsible for tx.BeginTx + tx.Commit/Rollback. Used by
// chat_files uploadPollMode so PutBatchTx + N activity rows commit
// atomically; if any activity row fails, the pending_uploads rows roll
// back too and the client retries the entire multipart upload cleanly.
func LogActivityTx(ctx context.Context, tx *sql.Tx, broadcaster events.EventEmitter, params ActivityParams) (commitHook func(), err error) {
if tx == nil {
return nil, errors.New("LogActivityTx: tx is nil")
}
return logActivityExec(ctx, tx, broadcaster, params)
}
// activityExecutor is the SQL surface LogActivity[Tx] needs. *sql.Tx
// and *sql.DB both satisfy it, so the same insert path serves the
// fire-and-forget caller (db.DB) and the Tx-aware caller (*sql.Tx).
type activityExecutor interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
}
func logActivityExec(ctx context.Context, exec activityExecutor, broadcaster events.EventEmitter, params ActivityParams) (commitHook func(), err error) {
reqJSON, reqErr := json.Marshal(params.RequestBody)
if reqErr != nil {
log.Printf("LogActivity: failed to marshal request_body for %s: %v", params.WorkspaceID, reqErr)
@@ -606,20 +644,21 @@ func LogActivity(ctx context.Context, broadcaster events.EventEmitter, params Ac
traceStr = &s
}
_, err := db.DB.ExecContext(ctx, `
if _, err := exec.ExecContext(ctx, `
INSERT INTO activity_logs (workspace_id, activity_type, source_id, target_id, method, summary, request_body, response_body, tool_trace, duration_ms, status, error_detail)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9::jsonb, $10, $11, $12)
`, params.WorkspaceID, params.ActivityType, params.SourceID, params.TargetID,
params.Method, params.Summary, reqStr, respStr, traceStr,
params.DurationMs, params.Status, params.ErrorDetail)
if err != nil {
log.Printf("LogActivity insert error: %v", err)
return
params.DurationMs, params.Status, params.ErrorDetail); err != nil {
return nil, err
}
// Broadcast ACTIVITY_LOGGED event
// Build the broadcast payload up-front so the post-commit hook is a
// pure in-memory call — no JSON marshaling between commit and emit
// where a panic would leak the row without an event.
var payload map[string]interface{}
if broadcaster != nil {
payload := map[string]interface{}{
payload = map[string]interface{}{
"activity_type": params.ActivityType,
"method": params.Method,
"summary": params.Summary,
@@ -650,8 +689,13 @@ func LogActivity(ctx context.Context, broadcaster events.EventEmitter, params Ac
if respStr != nil {
payload["response_body"] = json.RawMessage(respJSON)
}
broadcaster.BroadcastOnly(params.WorkspaceID, "ACTIVITY_LOGGED", payload)
}
return func() {
if broadcaster != nil {
broadcaster.BroadcastOnly(params.WorkspaceID, string(events.EventActivityLogged), payload)
}
}, nil
}
type ActivityParams struct {
@@ -5,6 +5,7 @@ import (
"context"
"database/sql/driver"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
@@ -909,6 +910,114 @@ func TestLogActivity_Broadcast_IncludesRequestAndResponseBodies(t *testing.T) {
}
}
// TestLogActivityTx_DefersBroadcastUntilCommitHook pins the #149
// contract: LogActivityTx returns a commitHook that the caller MUST
// invoke after tx.Commit(); the broadcast MUST NOT fire from inside
// LogActivityTx itself. Firing inside would leak a websocket event
// for a row that the caller may roll back, painting a ghost message
// into the canvas's optimistic UI that disappears on the next refresh.
func TestLogActivityTx_DefersBroadcastUntilCommitHook(t *testing.T) {
mock := setupTestDB(t)
defer mock.ExpectationsWereMet()
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO activity_logs").
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
tx, err := db.DB.BeginTx(context.Background(), nil)
if err != nil {
t.Fatalf("BeginTx: %v", err)
}
cb := &recordingBroadcaster{}
method := "chat_upload_receive"
hook, err := LogActivityTx(context.Background(), tx, cb, ActivityParams{
WorkspaceID: "ws-123",
ActivityType: "a2a_receive",
Method: &method,
Status: "ok",
})
if err != nil {
t.Fatalf("LogActivityTx: %v", err)
}
if len(cb.calls) != 0 {
t.Errorf("broadcast leaked before commitHook: got %d calls", len(cb.calls))
}
if err := tx.Commit(); err != nil {
t.Fatalf("Commit: %v", err)
}
hook()
if len(cb.calls) != 1 {
t.Fatalf("commitHook must broadcast exactly once, got %d", len(cb.calls))
}
if cb.calls[0].eventType != "ACTIVITY_LOGGED" {
t.Errorf("event type = %q, want ACTIVITY_LOGGED", cb.calls[0].eventType)
}
}
// TestLogActivityTx_InsertError_NoHook_NoBroadcast — when the INSERT
// fails inside the Tx, LogActivityTx returns an error and a nil
// commitHook. The caller is expected to Rollback; no broadcast can
// possibly fire because the hook never exists.
func TestLogActivityTx_InsertError_NoHook_NoBroadcast(t *testing.T) {
mock := setupTestDB(t)
defer mock.ExpectationsWereMet()
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO activity_logs").
WillReturnError(errors.New("constraint violation simulated"))
mock.ExpectRollback()
tx, err := db.DB.BeginTx(context.Background(), nil)
if err != nil {
t.Fatalf("BeginTx: %v", err)
}
cb := &recordingBroadcaster{}
method := "chat_upload_receive"
hook, err := LogActivityTx(context.Background(), tx, cb, ActivityParams{
WorkspaceID: "ws-123",
ActivityType: "a2a_receive",
Method: &method,
Status: "ok",
})
if err == nil {
t.Fatal("expected error on INSERT failure, got nil")
}
if hook != nil {
t.Errorf("commitHook must be nil on insert error, got non-nil hook")
}
if err := tx.Rollback(); err != nil {
t.Fatalf("Rollback: %v", err)
}
if len(cb.calls) != 0 {
t.Errorf("broadcast must NOT fire on insert error, got %d calls", len(cb.calls))
}
}
// TestLogActivityTx_NilTx_Errors — passing a nil tx is caller misuse.
// Return an error rather than panicking on the nil receiver inside
// ExecContext (which would crash the request goroutine and surface as
// a 500 with no log line tying it to the bad call site).
func TestLogActivityTx_NilTx_Errors(t *testing.T) {
cb := &recordingBroadcaster{}
hook, err := LogActivityTx(context.Background(), nil, cb, ActivityParams{
WorkspaceID: "ws-123",
ActivityType: "a2a_receive",
Status: "ok",
})
if err == nil {
t.Fatal("nil tx must error, got nil")
}
if hook != nil {
t.Errorf("commitHook must be nil when tx is nil, got non-nil hook")
}
if len(cb.calls) != 0 {
t.Errorf("broadcast must NOT fire on nil-tx error, got %d", len(cb.calls))
}
}
func TestLogActivity_Broadcast_IncludesResponseBody(t *testing.T) {
mock := setupTestDB(t)
defer mock.ExpectationsWereMet()
@@ -56,10 +56,17 @@ type RefreshResult struct {
Recreated []string `json:"recreated"`
}
// TemplateImageRef returns the canonical GHCR ref for a runtime's template
// image. Single source of truth shared with imagewatch.
// TemplateImageRef returns the canonical image ref for a runtime's template,
// using the configured registry (provisioner.RegistryPrefix()) and the
// moving `:latest` tag. Single source of truth shared with imagewatch.
//
// Defaults to ghcr.io/molecule-ai/workspace-template-<runtime>:latest
// (upstream OSS). When MOLECULE_IMAGE_REGISTRY is set in the environment
// (typically the AWS ECR mirror in production), this returns the prefixed
// equivalent so admin operations and image-watch checks hit the same
// registry the provisioner pulls from.
func TemplateImageRef(runtime string) string {
return fmt.Sprintf("ghcr.io/molecule-ai/workspace-template-%s:latest", runtime)
return fmt.Sprintf("%s/workspace-template-%s:latest", provisioner.RegistryPrefix(), runtime)
}
// ghcrAuthHeader returns the base64-encoded JSON auth payload Docker's
+15 -15
View File
@@ -69,7 +69,7 @@ func (h *AgentHandler) Assign(c *gin.Context) {
return
}
h.broadcaster.RecordAndBroadcast(ctx, "AGENT_ASSIGNED", workspaceID, map[string]interface{}{
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventAgentAssigned), workspaceID, map[string]interface{}{
"agent_id": agentID,
"model": body.Model,
})
@@ -118,7 +118,7 @@ func (h *AgentHandler) Replace(c *gin.Context) {
return
}
h.broadcaster.RecordAndBroadcast(ctx, "AGENT_REPLACED", workspaceID, map[string]interface{}{
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventAgentReplaced), workspaceID, map[string]interface{}{
"agent_id": agentID,
"model": body.Model,
"old_model": oldModel,
@@ -148,7 +148,7 @@ func (h *AgentHandler) Remove(c *gin.Context) {
return
}
h.broadcaster.RecordAndBroadcast(ctx, "AGENT_REMOVED", workspaceID, map[string]interface{}{
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventAgentRemoved), workspaceID, map[string]interface{}{
"agent_id": agentID,
"model": model,
})
@@ -215,21 +215,21 @@ func (h *AgentHandler) Move(c *gin.Context) {
}
// Broadcast on both workspaces
h.broadcaster.RecordAndBroadcast(ctx, "AGENT_MOVED", sourceID, map[string]interface{}{
"agent_id": agentID,
"model": model,
"target_workspace_id": body.TargetWorkspaceID,
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventAgentMoved), sourceID, map[string]interface{}{
"agent_id": agentID,
"model": model,
"target_workspace_id": body.TargetWorkspaceID,
})
h.broadcaster.RecordAndBroadcast(ctx, "AGENT_MOVED", body.TargetWorkspaceID, map[string]interface{}{
"agent_id": agentID,
"model": model,
"source_workspace_id": sourceID,
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventAgentMoved), body.TargetWorkspaceID, map[string]interface{}{
"agent_id": agentID,
"model": model,
"source_workspace_id": sourceID,
})
c.JSON(http.StatusOK, gin.H{
"agent_id": agentID,
"model": model,
"from_workspace": sourceID,
"to_workspace": body.TargetWorkspaceID,
"agent_id": agentID,
"model": model,
"from_workspace": sourceID,
"to_workspace": body.TargetWorkspaceID,
})
}
@@ -42,9 +42,9 @@ import (
"errors"
"fmt"
"log"
"unicode/utf8"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/textutil"
)
// ErrWorkspaceNotFound is returned by AgentMessageWriter.Send when the
@@ -54,36 +54,6 @@ import (
// timeout) surface as wrapped errors and should be treated as 503.
var ErrWorkspaceNotFound = errors.New("agent_message: workspace not found")
// truncatePreviewRunes returns at most maxRunes runes of s, plus an ellipsis
// when truncated. Operates on the rune (codepoint) boundary instead of
// byte indices — the previous byte-slice version produced invalid UTF-8
// when maxRunes landed mid-codepoint (CJK, emoji, accented characters
// in agent-authored chat messages), and Postgres JSONB rejects invalid
// UTF-8, dropping the activity_log INSERT silently. The persistence
// failure log fires but the message vanishes from chat history — the
// exact regression class the SSOT consolidation was built to prevent.
//
// maxRunes is in runes, not bytes — `truncatePreviewRunes("你好", 1)` returns
// `"你…"`, not `"\xe4…"`. Set the cap on a UI-friendly basis (visible
// character count, not stored byte count); 80 runes covers the
// activity_logs.summary column comfortably.
func truncatePreviewRunes(s string, maxRunes int) string {
if utf8.RuneCountInString(s) <= maxRunes {
return s
}
// Walk runes until we've consumed maxRunes; cut at that byte index.
count := 0
cut := len(s)
for i := range s {
if count == maxRunes {
cut = i
break
}
count++
}
return s[:cut] + "…"
}
// AgentMessageAttachment is one file attached to an agent → user
// message. Identical to handlers.NotifyAttachment in field set; kept
// distinct so the writer's API doesn't import a handler type with HTTP
@@ -186,7 +156,7 @@ func (w *AgentMessageWriter) Send(
respPayload["parts"] = fileParts
}
respJSON, _ := json.Marshal(respPayload)
preview := truncatePreviewRunes(message, 80)
preview := textutil.TruncateRunes(message, 80)
if _, err := w.db.ExecContext(ctx, `
INSERT INTO activity_logs (workspace_id, activity_type, method, summary, response_body, status)
VALUES ($1, 'a2a_receive', 'notify', $2, $3::jsonb, 'ok')
@@ -331,45 +331,11 @@ func TestAgentMessageWriter_Send_DBErrorOnLookupReturnsWrapped(t *testing.T) {
}
}
// TestTruncatePreviewRunes_RuneBoundary pins the multi-byte-safe
// truncation. The previous byte-slice version produced invalid UTF-8
// when the cut landed mid-codepoint (CJK, emoji, accented), and
// Postgres JSONB rejects invalid UTF-8 — INSERT fails, log.Printf
// fires, message vanishes from chat history. Per memory
// feedback_assert_exact_not_substring.md, pin the boundary cases
// directly.
func TestTruncatePreviewRunes_RuneBoundary(t *testing.T) {
cases := []struct {
name string
in string
max int
want string
}{
{"under-max ASCII", "hi", 80, "hi"},
{"under-max CJK", "你好", 80, "你好"},
{"exactly-at-max", "abcde", 5, "abcde"},
{"truncate ASCII", "abcdefghij", 5, "abcde…"},
{"truncate CJK at rune boundary", "你好世界你好世界", 4, "你好世界…"},
{"truncate emoji at rune boundary", "😀😀😀😀😀😀", 3, "😀😀😀…"},
// The pre-fix bug shape: byte-slice on non-ASCII would have
// mangled the codepoint here. With rune-boundary truncation
// the result is well-formed UTF-8.
{"non-zero with emoji prefix", "🚀abcdefghijk", 5, "🚀abcd…"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := truncatePreviewRunes(c.in, c.max)
if got != c.want {
t.Errorf("truncatePreviewRunes(%q, %d) = %q, want %q", c.in, c.max, got, c.want)
}
// Always-valid UTF-8 invariant. A byte-slice truncation
// could leave partial codepoints; this version must not.
if !utf8.ValidString(got) {
t.Errorf("truncatePreviewRunes(%q, %d) returned invalid UTF-8: %q", c.in, c.max, got)
}
})
}
}
// Helper-level truncate tests now live in
// internal/textutil/truncate_test.go (TestTruncateRunes). The
// integration-level coverage that exercises the agent_message_writer
// path with non-ASCII content is TestAgentMessageWriter_Send_NonASCIIMessagePersists
// below.
// TestAgentMessageWriter_Send_NonASCIIMessagePersists pins the end-to-end
// path for non-ASCII messages — the original reno-stars regression
@@ -51,7 +51,7 @@ func (h *ApprovalsHandler) Create(c *gin.Context) {
return
}
h.broadcaster.RecordAndBroadcast(ctx, "APPROVAL_REQUESTED", workspaceID, map[string]interface{}{
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventApprovalRequested), workspaceID, map[string]interface{}{
"approval_id": approvalID,
"action": body.Action,
"reason": body.Reason,
@@ -62,7 +62,7 @@ func (h *ApprovalsHandler) Create(c *gin.Context) {
var parentID *string
db.DB.QueryRowContext(ctx, `SELECT parent_id FROM workspaces WHERE id = $1`, workspaceID).Scan(&parentID)
if parentID != nil {
h.broadcaster.RecordAndBroadcast(ctx, "APPROVAL_ESCALATED", *parentID, map[string]interface{}{
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventApprovalEscalated), *parentID, map[string]interface{}{
"approval_id": approvalID,
"from_workspace_id": workspaceID,
"action": body.Action,
@@ -656,8 +656,28 @@ func (h *ChatFilesHandler) uploadPollMode(c *gin.Context, ctx context.Context, w
})
}
// Phase 2: atomic batch insert. On failure no rows commit.
fileIDs, err := h.pendingUploads.PutBatch(ctx, wsUUID, items)
// Phase 2+3: PutBatch + N activity-row inserts run in ONE Tx so
// either every pending_uploads row + every activity_logs row commits,
// or none do. Per-file pre-validation already happened above so the
// only failure modes inside the Tx are DB-side; either way Rollback
// leaves the table state unchanged and the client retries the whole
// multipart upload cleanly. Broadcasts are deferred until after
// Commit — emitting an ACTIVITY_LOGGED event for a row that ends up
// rolled back would leak a ghost message into the canvas's
// optimistic UI.
tx, err := db.DB.BeginTx(ctx, nil)
if err != nil {
log.Printf("chat_files uploadPollMode: begin tx for %s: %v", workspaceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not stage files"})
return
}
// Defer-rollback is safe even after a successful Commit — the second
// Rollback is a no-op (database/sql tracks tx state).
defer func() {
_ = tx.Rollback()
}()
fileIDs, err := h.pendingUploads.PutBatchTx(ctx, tx, wsUUID, items)
if err != nil {
if errors.Is(err, pendinguploads.ErrTooLarge) {
// Belt + suspenders: pre-validation above already caught
@@ -669,28 +689,20 @@ func (h *ChatFilesHandler) uploadPollMode(c *gin.Context, ctx context.Context, w
})
return
}
log.Printf("chat_files uploadPollMode: storage.PutBatch failed for %s: %v",
log.Printf("chat_files uploadPollMode: storage.PutBatchTx failed for %s: %v",
workspaceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not stage files"})
return
}
// Phase 3: write per-file activity rows and build the response. Activity
// rows are written individually (not part of the same Tx as PutBatch)
// because LogActivity is shared across many handlers and threading the
// Tx through would be a bigger refactor. The trade-off: if an activity
// write fails after the PutBatch commits, the pending_uploads rows
// orphan until the 24h TTL — significantly better than the previous
// "every multi-file upload could orphan" behavior, and the workspace's
// fetcher handles soft-404 cleanly when activity rows reference a row
// the platform later expired.
out := make([]uploadedFile, 0, len(prepReady))
broadcasts := make([]func(), 0, len(prepReady))
for i, p := range prepReady {
fileID := fileIDs[i]
uri := fmt.Sprintf("platform-pending:%s/%s", workspaceID, fileID)
summary := "chat_upload_receive: " + p.Sanitized
method := "chat_upload_receive"
LogActivity(ctx, h.broadcaster, ActivityParams{
hook, err := LogActivityTx(ctx, tx, h.broadcaster, ActivityParams{
WorkspaceID: workspaceID,
ActivityType: "a2a_receive",
TargetID: &workspaceID,
@@ -705,10 +717,13 @@ func (h *ChatFilesHandler) uploadPollMode(c *gin.Context, ctx context.Context, w
},
Status: "ok",
})
log.Printf("chat_files uploadPollMode: staged %s/%s (file_id=%s size=%d mimetype=%q)",
workspaceID, p.Sanitized, fileID, len(p.Content), p.Mimetype)
if err != nil {
log.Printf("chat_files uploadPollMode: activity insert failed for %s/%s: %v",
workspaceID, p.Sanitized, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not log upload activity"})
return
}
broadcasts = append(broadcasts, hook)
out = append(out, uploadedFile{
URI: uri,
Name: p.Sanitized,
@@ -717,6 +732,24 @@ func (h *ChatFilesHandler) uploadPollMode(c *gin.Context, ctx context.Context, w
})
}
if err := tx.Commit(); err != nil {
log.Printf("chat_files uploadPollMode: commit failed for %s: %v", workspaceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not stage files"})
return
}
// Post-commit: fire deferred broadcasts and emit the staged log
// lines now that the rows are durable. Broadcasts are pure in-memory
// (no I/O); panicking here would NOT leak a row but would leak a
// log line, so the order doesn't matter for correctness.
for _, b := range broadcasts {
b()
}
for i, p := range prepReady {
log.Printf("chat_files uploadPollMode: staged %s/%s (file_id=%s size=%d mimetype=%q)",
workspaceID, p.Sanitized, fileIDs[i], len(p.Content), p.Mimetype)
}
c.JSON(http.StatusOK, gin.H{"files": out})
}
@@ -107,6 +107,16 @@ func (s *inMemStorage) PutBatch(_ context.Context, ws uuid.UUID, items []pending
return ids, nil
}
// PutBatchTx mirrors PutBatch for the Tx-aware caller path. The tx
// argument is not consulted — production atomicity (PutBatch INSERTs +
// activity_logs INSERTs in the same Tx) is verified by the dedicated
// integration test against real Postgres. This in-mem fake records the
// puts immediately; tests that exercise the rollback path use
// putErr/sqlmock to simulate the failure.
func (s *inMemStorage) PutBatchTx(ctx context.Context, _ *sql.Tx, ws uuid.UUID, items []pendinguploads.PutItem) ([]uuid.UUID, error) {
return s.PutBatch(ctx, ws, items)
}
func (s *inMemStorage) Get(context.Context, uuid.UUID) (pendinguploads.Record, error) {
return pendinguploads.Record{}, pendinguploads.ErrNotFound
}
@@ -138,11 +148,37 @@ func expectPollDeliveryModeMissing(mock sqlmock.Sqlmock, workspaceID string) {
// expectActivityInsert stubs the LogActivity INSERT so the poll branch's
// per-file activity row write doesn't fail the sqlmock expectations.
// In the post-#149 path this INSERT runs inside the BeginTx that wraps
// PutBatchTx + N activity rows — pair it with expectUploadPollTxBegin
// + expectUploadPollTxCommit (or Rollback) when the test exercises
// uploadPollMode.
func expectActivityInsert(mock sqlmock.Sqlmock) {
mock.ExpectExec(`INSERT INTO activity_logs`).
WillReturnResult(sqlmock.NewResult(1, 1))
}
// expectUploadPollTxBegin marks the start of the BeginTx that
// uploadPollMode opens around PutBatchTx + per-file LogActivityTx.
// inMemStorage doesn't drive sqlmock for the pending_uploads INSERTs
// (it's a process-local fake), so the only Tx-scoped DB calls
// sqlmock sees are the activity_logs INSERTs.
func expectUploadPollTxBegin(mock sqlmock.Sqlmock) {
mock.ExpectBegin()
}
// expectUploadPollTxCommit pairs with expectUploadPollTxBegin on the
// happy path — every activity row inserted, Tx committed.
func expectUploadPollTxCommit(mock sqlmock.Sqlmock) {
mock.ExpectCommit()
}
// expectUploadPollTxRollback pairs with expectUploadPollTxBegin on a
// failure path — PutBatchTx error, activity insert error, or any other
// abort that triggers the deferred tx.Rollback() in uploadPollMode.
func expectUploadPollTxRollback(mock sqlmock.Sqlmock) {
mock.ExpectRollback()
}
// expectActivityInsertWithTypeAndMethod is a strict variant that pins
// the activity_type and method positional args. Used in the discriminator
// regression test below — the workspace inbox poller filters
@@ -198,7 +234,9 @@ func TestPollUpload_HappyPath_OneFile_StagesAndLogs(t *testing.T) {
wsID := "11111111-2222-3333-4444-555555555555"
expectPollDeliveryMode(mock, wsID, "poll")
expectUploadPollTxBegin(mock)
expectActivityInsert(mock)
expectUploadPollTxCommit(mock)
store := newInMemStorage()
h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil)).
@@ -254,9 +292,11 @@ func TestPollUpload_MultipleFiles_AllStagedAndLogged(t *testing.T) {
wsID := "11111111-aaaa-bbbb-cccc-555555555555"
expectPollDeliveryMode(mock, wsID, "poll")
expectUploadPollTxBegin(mock)
expectActivityInsert(mock)
expectActivityInsert(mock)
expectActivityInsert(mock)
expectUploadPollTxCommit(mock)
store := newInMemStorage()
h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil)).
@@ -425,6 +465,8 @@ func TestPollUpload_StorageError_500(t *testing.T) {
wsID := "88888888-2222-3333-4444-555555555555"
expectPollDeliveryMode(mock, wsID, "poll")
expectUploadPollTxBegin(mock)
expectUploadPollTxRollback(mock)
store := newInMemStorage()
store.putErr = errors.New("disk full")
@@ -446,6 +488,8 @@ func TestPollUpload_StorageTooLarge_413(t *testing.T) {
wsID := "99999999-2222-3333-4444-555555555555"
expectPollDeliveryMode(mock, wsID, "poll")
expectUploadPollTxBegin(mock)
expectUploadPollTxRollback(mock)
store := newInMemStorage()
store.putErr = pendinguploads.ErrTooLarge
@@ -569,7 +613,9 @@ func TestPollUpload_SanitizesFilenameInResponse(t *testing.T) {
wsID := "bbbbbbbb-2222-3333-4444-555555555555"
expectPollDeliveryMode(mock, wsID, "poll")
expectUploadPollTxBegin(mock)
expectActivityInsert(mock)
expectUploadPollTxCommit(mock)
store := newInMemStorage()
h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil)).
@@ -650,6 +696,8 @@ func TestPollUpload_AtomicRollbackOnPutBatchError(t *testing.T) {
wsID := "bbbbbbbb-3333-3333-4444-555555555555"
expectPollDeliveryMode(mock, wsID, "poll")
expectUploadPollTxBegin(mock)
expectUploadPollTxRollback(mock)
store := newInMemStorage()
store.putErr = errors.New("db down mid-batch")
@@ -672,6 +720,58 @@ func TestPollUpload_AtomicRollbackOnPutBatchError(t *testing.T) {
}
}
// TestPollUpload_AtomicRollbackOnActivityInsertFailure pins the #149
// guarantee: if an activity_logs INSERT fails mid-loop (after some
// rows have already been INSERTed in the same Tx), uploadPollMode
// MUST Rollback so neither the pending_uploads nor the activity rows
// commit. Pre-#149 the activity rows were written one-by-one outside
// any Tx; a mid-loop failure left orphan pending_uploads rows the
// 24h TTL would later sweep, but the user never saw the file in the
// canvas. Post-#149 the contract is all-or-nothing.
//
// What this pins: the second activity insert errors → Tx rolls back
// → response is 500 → no Commit. Pin via the sqlmock rollback
// expectation; the inMemStorage will report puts=N (it doesn't model
// Tx state), but at the SQL layer no rows committed.
func TestPollUpload_AtomicRollbackOnActivityInsertFailure(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
wsID := "cccccccc-3333-3333-4444-555555555555"
expectPollDeliveryMode(mock, wsID, "poll")
expectUploadPollTxBegin(mock)
// File 1 inserts cleanly. File 2's INSERT fails. uploadPollMode
// must NOT call Commit and the deferred tx.Rollback() runs.
mock.ExpectExec(`INSERT INTO activity_logs`).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec(`INSERT INTO activity_logs`).
WillReturnError(errors.New("constraint violation simulated"))
expectUploadPollTxRollback(mock)
store := newInMemStorage()
h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil)).
WithPendingUploads(store, nil)
body, ct := pollUploadFixture(t, map[string][]byte{
"a.txt": []byte("aaa"),
"b.txt": []byte("bbb"),
"c.txt": []byte("ccc"),
})
c, w := makeUploadRequest(t, wsID, body, ct)
h.Upload(c)
if w.Code != http.StatusInternalServerError {
t.Fatalf("status=%d body=%s, want 500 on activity-insert mid-loop failure",
w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
// This is the load-bearing assertion: ExpectationsWereMet only
// passes if Rollback was called and Commit was NOT — the SQL-
// level proof of the all-or-nothing contract.
t.Errorf("Tx must rollback (and NOT commit) on activity-insert failure: %v", err)
}
}
// TestPollUpload_MimetypeWithCRLFInjectionStripped pins the safeMimetype
// hardening: a multipart-supplied Content-Type header with CR/LF is
// rewritten to application/octet-stream so the eventual /content
@@ -731,7 +831,9 @@ func TestPollUpload_ActivityRowDiscriminator(t *testing.T) {
wsID := "abc12345-6789-4abc-8def-000000000999"
expectPollDeliveryMode(mock, wsID, "poll")
expectUploadPollTxBegin(mock)
expectActivityInsertWithTypeAndMethod(mock, wsID, "a2a_receive", "chat_upload_receive")
expectUploadPollTxCommit(mock)
store := newInMemStorage()
h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil)).
@@ -0,0 +1,113 @@
package handlers
// chat_history.go — HTTP-shape adapter over messagestore.MessageStore
// (RFC #2945 PR-D).
//
// Pre-PR-D, this file owned the activity_logs query AND the parser
// AND the HTTP plumbing. PR-D extracts the storage + parser into
// internal/messagestore/ so OSS operators can plug in alternative
// backends (S3-tiered, vector store, in-memory). The handler is now
// a thin adapter: parse query params → call store → emit JSON.
//
// Endpoint: GET /workspaces/:id/chat-history?limit=N&before_ts=T
// Auth: same wsAuth chain as /workspaces/:id/activity (tenant
// ADMIN_TOKEN + X-Molecule-Org-Id header). No new trust boundary.
//
// Behavioral parity with canvas TS is enforced at the messagestore
// layer (internal/messagestore/postgres_store_test.go); this file's
// tests cover the HTTP-shape concerns only.
import (
"net/http"
"strconv"
"time"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/messagestore"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// ChatHistoryResponse is the wire shape for GET /chat-history.
type ChatHistoryResponse struct {
Messages []messagestore.ChatMessage `json:"messages"`
ReachedEnd bool `json:"reached_end"`
}
// ChatHistoryHandler exposes the typed chat-history endpoint over a
// MessageStore. The store is injected so OSS operators can swap the
// backend without forking the handler.
type ChatHistoryHandler struct {
store messagestore.MessageStore
}
// NewChatHistoryHandler wires a MessageStore (typically
// messagestore.NewPostgresMessageStore at production startup).
//
// Tests inject fakes (see internal/handlers/chat_history_test.go).
// Constructor takes the interface, not a concrete type, so the
// platform-default vs OSS-alternative decision happens at wiring
// time in router.go.
func NewChatHistoryHandler(store messagestore.MessageStore) *ChatHistoryHandler {
return &ChatHistoryHandler{store: store}
}
// List handles GET /workspaces/:id/chat-history?limit=N&before_ts=T.
//
// Query parameters mirror /activity for caller convenience:
//
// - limit (default 100, max 1000) — page size
// - before_ts (RFC3339, optional) — cursor for paginating backward
//
// Validates inputs at the trust boundary; the store sees only
// well-formed ListOptions.
func (h *ChatHistoryHandler) List(c *gin.Context) {
workspaceID := c.Param("id")
if _, err := uuid.Parse(workspaceID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "workspace id must be a UUID"})
return
}
limit := 100
if v := c.Query("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
if limit > 1000 {
limit = 1000
}
opts := messagestore.ListOptions{Limit: limit}
if v := c.Query("before_ts"); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "before_ts must be an RFC3339 timestamp (e.g. 2026-05-01T00:00:00Z)",
})
return
}
opts.BeforeTS = t
opts.HasBefore = true
}
messages, reachedEnd, err := h.store.List(c.Request.Context(), workspaceID, opts)
if err != nil {
// Errors here are infra (DB unreachable, store impl failure).
// Surface as 502 so the canvas can retry vs. treating as
// "no rows."
c.JSON(http.StatusBadGateway, gin.H{"error": "chat history unavailable"})
return
}
// Defensive: if the store returns nil messages slice (any impl
// might), emit empty array rather than `null` so canvas's JSON
// parser doesn't have to handle two empty representations.
if messages == nil {
messages = []messagestore.ChatMessage{}
}
c.JSON(http.StatusOK, ChatHistoryResponse{
Messages: messages,
ReachedEnd: reachedEnd,
})
}
@@ -0,0 +1,276 @@
package handlers
// chat_history_test.go — handler-level tests against a fake
// MessageStore. The parser-level parity tests against the canvas TS
// fixtures live in internal/messagestore/postgres_store_test.go;
// this file covers the HTTP-shape concerns (param validation,
// pagination passthrough, error mapping) without touching a DB.
//
// Why the split: PR-D extracted storage to messagestore.MessageStore.
// The handler is now a thin adapter — its tests should exercise the
// adapter (ParseQuery → store.List → emitJSON), not the parser. A
// future MessageStore impl (S3, vector store) shares the same
// handler; testing the handler against the interface keeps the
// adapter test independent of any specific impl.
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/messagestore"
"github.com/gin-gonic/gin"
)
const testWorkspaceID = "550e8400-e29b-41d4-a716-446655440000"
func init() {
gin.SetMode(gin.TestMode)
}
// fakeStore is a stub MessageStore for handler-level tests. Every
// real store impl (Postgres, S3, vector) shares the handler — so a
// fake that records inputs + returns scripted outputs is the right
// granularity for HTTP-shape coverage.
type fakeStore struct {
// LastWorkspaceID + LastOpts capture the call shape so the test
// can assert the handler passed the right args to the store.
LastWorkspaceID string
LastOpts messagestore.ListOptions
// Returns — set per test.
ReturnMessages []messagestore.ChatMessage
ReturnReachedEnd bool
ReturnErr error
// Panic — if non-empty, List panics with this string. Used by
// the resilience test to confirm the handler returns 502 on
// store-impl failures rather than crashing the goroutine.
PanicWith string
}
func (s *fakeStore) List(ctx context.Context, workspaceID string, opts messagestore.ListOptions) ([]messagestore.ChatMessage, bool, error) {
if s.PanicWith != "" {
panic(s.PanicWith)
}
s.LastWorkspaceID = workspaceID
s.LastOpts = opts
return s.ReturnMessages, s.ReturnReachedEnd, s.ReturnErr
}
// Compile-time assertion that fakeStore satisfies the interface.
// Catches drift if the interface changes and the fake stops being a
// drop-in for tests.
var _ messagestore.MessageStore = (*fakeStore)(nil)
func newRouter(store messagestore.MessageStore) *gin.Engine {
r := gin.New()
h := NewChatHistoryHandler(store)
r.GET("/workspaces/:id/chat-history", h.List)
return r
}
func doChatHistoryRequest(t *testing.T, r *gin.Engine, path string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w
}
// =====================================================================
// Param validation
// =====================================================================
func TestChatHistoryHandler_RejectsNonUUIDWorkspaceID(t *testing.T) {
store := &fakeStore{}
r := newRouter(store)
w := doChatHistoryRequest(t, r, "/workspaces/not-a-uuid/chat-history")
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for non-UUID, got %d", w.Code)
}
if store.LastWorkspaceID != "" {
t.Errorf("non-UUID reached the store: %q", store.LastWorkspaceID)
}
}
func TestChatHistoryHandler_RejectsMalformedBeforeTS(t *testing.T) {
store := &fakeStore{}
r := newRouter(store)
w := doChatHistoryRequest(t, r, "/workspaces/"+testWorkspaceID+"/chat-history?before_ts=not-a-timestamp")
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for malformed before_ts, got %d", w.Code)
}
if !strings.Contains(w.Body.String(), "RFC3339") {
t.Errorf("error message should mention RFC3339; got %q", w.Body.String())
}
}
func TestChatHistoryHandler_DefaultsLimitTo100(t *testing.T) {
store := &fakeStore{}
r := newRouter(store)
doChatHistoryRequest(t, r, "/workspaces/"+testWorkspaceID+"/chat-history")
if store.LastOpts.Limit != 100 {
t.Errorf("default limit=%d want 100", store.LastOpts.Limit)
}
if store.LastOpts.HasBefore {
t.Errorf("HasBefore should be false when no cursor passed")
}
}
func TestChatHistoryHandler_ClampsLimitToMax1000(t *testing.T) {
store := &fakeStore{}
r := newRouter(store)
doChatHistoryRequest(t, r, "/workspaces/"+testWorkspaceID+"/chat-history?limit=99999")
if store.LastOpts.Limit != 1000 {
t.Errorf("limit not clamped: got %d, want 1000", store.LastOpts.Limit)
}
}
func TestChatHistoryHandler_IgnoresInvalidLimit(t *testing.T) {
// Negative or zero limits should fall back to default rather
// than reach the store (which rejects them as a programming bug).
store := &fakeStore{}
r := newRouter(store)
for _, bad := range []string{"-1", "0", "abc"} {
store.LastOpts = messagestore.ListOptions{}
doChatHistoryRequest(t, r, "/workspaces/"+testWorkspaceID+"/chat-history?limit="+bad)
if store.LastOpts.Limit != 100 {
t.Errorf("limit=%q yielded %d, want default 100", bad, store.LastOpts.Limit)
}
}
}
// =====================================================================
// Pagination passthrough
// =====================================================================
func TestChatHistoryHandler_BeforeTSPassedToStore(t *testing.T) {
store := &fakeStore{}
r := newRouter(store)
doChatHistoryRequest(t, r, "/workspaces/"+testWorkspaceID+"/chat-history?before_ts=2026-04-25T18:00:00Z&limit=25")
if !store.LastOpts.HasBefore {
t.Errorf("HasBefore=false but query passed before_ts")
}
got := store.LastOpts.BeforeTS.UTC().Format("2006-01-02T15:04:05Z")
if got != "2026-04-25T18:00:00Z" {
t.Errorf("BeforeTS=%q want 2026-04-25T18:00:00Z", got)
}
if store.LastOpts.Limit != 25 {
t.Errorf("limit=%d want 25", store.LastOpts.Limit)
}
}
// =====================================================================
// Response shape
// =====================================================================
func TestChatHistoryHandler_EmptyResultIsArrayNotNull(t *testing.T) {
// nil messages slice from the store must serialize as `[]`,
// not `null` — canvas's JSON parser has one path.
store := &fakeStore{ReturnMessages: nil, ReturnReachedEnd: true}
r := newRouter(store)
w := doChatHistoryRequest(t, r, "/workspaces/"+testWorkspaceID+"/chat-history")
if w.Code != http.StatusOK {
t.Fatalf("status=%d", w.Code)
}
var resp ChatHistoryResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("body not JSON: %v", err)
}
// json.Unmarshal of `null` into a []slice yields a nil — assert
// the JSON literally contains "[]" so a future change that
// forgets the nil-coercion would fail loudly.
if !strings.Contains(w.Body.String(), `"messages":[]`) {
t.Errorf("body should contain `\"messages\":[]`; got %s", w.Body.String())
}
if !resp.ReachedEnd {
t.Errorf("reached_end not propagated")
}
}
func TestChatHistoryHandler_NonEmptyResponsePreservesShape(t *testing.T) {
size := int64(4096)
store := &fakeStore{
ReturnMessages: []messagestore.ChatMessage{
{
ID: "msg-1",
Role: "user",
Content: "hi",
Timestamp: "2026-04-25T18:00:00Z",
},
{
ID: "msg-2",
Role: "agent",
Content: "hello back",
Attachments: []messagestore.ChatAttachment{
{Name: "img.png", URI: "workspace:/img.png", MimeType: "image/png", Size: &size},
},
Timestamp: "2026-04-25T18:00:01Z",
},
},
ReturnReachedEnd: false,
}
r := newRouter(store)
w := doChatHistoryRequest(t, r, "/workspaces/"+testWorkspaceID+"/chat-history")
if w.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
var resp ChatHistoryResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("body not JSON: %v", err)
}
if len(resp.Messages) != 2 {
t.Fatalf("messages=%d want 2", len(resp.Messages))
}
if resp.Messages[1].Attachments[0].Size == nil || *resp.Messages[1].Attachments[0].Size != 4096 {
t.Errorf("size pointer flattened in JSON round-trip")
}
}
// =====================================================================
// Error mapping — store errors become 502, not 500/panic
// =====================================================================
func TestChatHistoryHandler_StoreErrorReturns502(t *testing.T) {
store := &fakeStore{ReturnErr: errors.New("simulated DB unreachable")}
r := newRouter(store)
w := doChatHistoryRequest(t, r, "/workspaces/"+testWorkspaceID+"/chat-history")
if w.Code != http.StatusBadGateway {
t.Errorf("expected 502 on store error, got %d", w.Code)
}
if !strings.Contains(w.Body.String(), "unavailable") {
t.Errorf("response body should communicate unavailability; got %q", w.Body.String())
}
}
// =====================================================================
// Interface conformance — the platform-default Postgres impl is the
// only impl in tree today, but the assertion catches future drift if
// the interface evolves and the impl falls behind.
// =====================================================================
func TestMessageStoreInterface_PostgresImplSatisfies(t *testing.T) {
// Compile-time assertion lives in messagestore/postgres_store.go
// (`var _ MessageStore = (*PostgresMessageStore)(nil)`). This
// runtime test exists only to keep the conformance visible in
// the handler test file — a reader of chat_history_test.go
// shouldn't have to traverse to the messagestore package to see
// what the handler is paired with.
var s messagestore.MessageStore = messagestore.NewPostgresMessageStore(nil)
_ = s
}
@@ -10,6 +10,7 @@ import (
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/textutil"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
@@ -164,10 +165,10 @@ func (h *DelegationHandler) Delegate(c *gin.Context) {
go h.executeDelegation(sourceID, body.TargetID, delegationID, a2aBody)
// Broadcast event so canvas shows delegation in real-time
h.broadcaster.RecordAndBroadcast(ctx, "DELEGATION_SENT", sourceID, map[string]interface{}{
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventDelegationSent), sourceID, map[string]interface{}{
"delegation_id": delegationID,
"target_id": body.TargetID,
"task_preview": truncate(body.Task, 100),
"task_preview": textutil.TruncateBytes(body.Task, 100),
})
resp := gin.H{
@@ -317,7 +318,7 @@ func (h *DelegationHandler) executeDelegation(sourceID, targetID, delegationID s
// Update status: pending → dispatched
h.updateDelegationStatus(sourceID, delegationID, "dispatched", "")
h.broadcaster.RecordAndBroadcast(ctx, "DELEGATION_STATUS", sourceID, map[string]interface{}{
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventDelegationStatus), sourceID, map[string]interface{}{
"delegation_id": delegationID, "target_id": targetID, "status": "dispatched",
})
@@ -352,7 +353,7 @@ func (h *DelegationHandler) executeDelegation(sourceID, targetID, delegationID s
log.Printf("Delegation %s: failed to insert error log: %v", delegationID, err)
}
h.broadcaster.RecordAndBroadcast(ctx, "DELEGATION_FAILED", sourceID, map[string]interface{}{
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventDelegationFailed), sourceID, map[string]interface{}{
"delegation_id": delegationID, "target_id": targetID, "error": proxyErr.Error(),
})
// RFC #2829 PR-2 result-push (see UpdateStatus for rationale).
@@ -388,7 +389,7 @@ func (h *DelegationHandler) executeDelegation(sourceID, targetID, delegationID s
`, sourceID, sourceID, targetID, "Delegation queued — target at capacity", string(queuedJSON)); err != nil {
log.Printf("Delegation %s: failed to insert queued log: %v", delegationID, err)
}
h.broadcaster.RecordAndBroadcast(ctx, "DELEGATION_STATUS", sourceID, map[string]interface{}{
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventDelegationStatus), sourceID, map[string]interface{}{
"delegation_id": delegationID, "target_id": targetID, "status": "queued",
})
return
@@ -407,7 +408,7 @@ func (h *DelegationHandler) executeDelegation(sourceID, targetID, delegationID s
if _, err := db.DB.ExecContext(ctx, `
INSERT INTO activity_logs (workspace_id, activity_type, method, source_id, target_id, summary, response_body, status)
VALUES ($1, 'delegation', 'delegate_result', $2, $3, $4, $5::jsonb, 'completed')
`, sourceID, sourceID, targetID, "Delegation completed ("+truncate(responseText, 80)+")", string(respJSON)); err != nil {
`, sourceID, sourceID, targetID, "Delegation completed ("+textutil.TruncateBytes(responseText, 80)+")", string(respJSON)); err != nil {
log.Printf("Delegation %s: failed to insert success log: %v", delegationID, err)
}
@@ -420,10 +421,10 @@ func (h *DelegationHandler) executeDelegation(sourceID, targetID, delegationID s
// delegation_ledger_integration_test.go.
recordLedgerStatus(ctx, delegationID, "completed", "", responseText)
h.updateDelegationStatus(sourceID, delegationID, "completed", "")
h.broadcaster.RecordAndBroadcast(ctx, "DELEGATION_COMPLETE", sourceID, map[string]interface{}{
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventDelegationComplete), sourceID, map[string]interface{}{
"delegation_id": delegationID,
"target_id": targetID,
"response_preview": truncate(responseText, 200),
"response_preview": textutil.TruncateBytes(responseText, 200),
})
// RFC #2829 PR-2 result-push (see UpdateStatus for rationale).
pushDelegationResultToInbox(ctx, sourceID, delegationID, "completed", responseText, "")
@@ -503,10 +504,10 @@ func (h *DelegationHandler) Record(c *gin.Context) {
recordLedgerInsert(ctx, sourceID, body.TargetID, body.DelegationID, body.Task, "")
recordLedgerStatus(ctx, body.DelegationID, "dispatched", "", "")
h.broadcaster.RecordAndBroadcast(ctx, "DELEGATION_SENT", sourceID, map[string]interface{}{
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventDelegationSent), sourceID, map[string]interface{}{
"delegation_id": body.DelegationID,
"target_id": body.TargetID,
"task_preview": truncate(body.Task, 100),
"task_preview": textutil.TruncateBytes(body.Task, 100),
})
c.JSON(http.StatusAccepted, gin.H{
@@ -555,12 +556,12 @@ func (h *DelegationHandler) UpdateStatus(c *gin.Context) {
if _, err := db.DB.ExecContext(ctx, `
INSERT INTO activity_logs (workspace_id, activity_type, method, source_id, summary, response_body, status)
VALUES ($1, 'delegation', 'delegate_result', $2, $3, $4::jsonb, 'completed')
`, sourceID, sourceID, "Delegation completed ("+truncate(body.ResponsePreview, 80)+")", string(respJSON)); err != nil {
`, sourceID, sourceID, "Delegation completed ("+textutil.TruncateBytes(body.ResponsePreview, 80)+")", string(respJSON)); err != nil {
log.Printf("Delegation UpdateStatus: result insert failed for %s: %v", delegationID, err)
}
h.broadcaster.RecordAndBroadcast(ctx, "DELEGATION_COMPLETE", sourceID, map[string]interface{}{
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventDelegationComplete), sourceID, map[string]interface{}{
"delegation_id": delegationID,
"response_preview": truncate(body.ResponsePreview, 200),
"response_preview": textutil.TruncateBytes(body.ResponsePreview, 200),
})
// RFC #2829 PR-2 result-push: when the gate is on, also write an
// a2a_receive row so the caller's inbox poller surfaces this to
@@ -570,7 +571,7 @@ func (h *DelegationHandler) UpdateStatus(c *gin.Context) {
// the result instead of holding open an HTTP connection.
pushDelegationResultToInbox(ctx, sourceID, delegationID, "completed", body.ResponsePreview, "")
} else {
h.broadcaster.RecordAndBroadcast(ctx, "DELEGATION_FAILED", sourceID, map[string]interface{}{
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventDelegationFailed), sourceID, map[string]interface{}{
"delegation_id": delegationID,
"error": body.Error,
})
@@ -626,7 +627,7 @@ func (h *DelegationHandler) ListDelegations(c *gin.Context) {
entry["error"] = errorDetail
}
if responseBody != "" {
entry["response_preview"] = truncate(responseBody, 300)
entry["response_preview"] = textutil.TruncateBytes(responseBody, 300)
}
delegations = append(delegations, entry)
}
@@ -727,9 +728,3 @@ func extractResponseText(body []byte) string {
return string(body)
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "..."
}
@@ -8,6 +8,7 @@ import (
"time"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/textutil"
)
// delegation_ledger.go — durable per-task ledger for A2A delegation
@@ -50,40 +51,15 @@ func NewDelegationLedger(handle *sql.DB) *DelegationLedger {
return &DelegationLedger{db: handle}
}
// truncatePreview caps stored preview at 4KB. The full prompt/response is
// already in activity_logs.{request,response}_body — this is the at-a-glance
// view for the dashboard, not a forensic record.
// previewCap caps stored preview at 4KB. The full prompt/response is
// already in activity_logs.{request,response}_body — this is the
// at-a-glance view for the dashboard, not a forensic record.
//
// Rune-safe: previous byte-slice form (s[:previewCap]) split on a byte
// boundary, which on a multi-byte codepoint at byte 4096 produced
// invalid UTF-8 — Postgres JSONB rejects → ledger row not inserted →
// audit gap. Issue #2962. Walks the string by rune, stops at the last
// rune-boundary index that fits inside the cap. ASCII-only strings hit
// the cap exactly; CJK/emoji strings stop slightly under the cap,
// never over.
//
// Mirrors the truncatePreviewRunes fix from agent_message_writer.go
// (#2959). Both call sites should consume a shared helper after both
// fixes have landed — followup deduplication tracked in #2962's body.
// Truncation goes through textutil.TruncateBytesNoMarker so it's
// rune-safe (#2026 / #2959 / #2962 bug class: byte-slice mid-codepoint
// Postgres JSONB rejects → silent INSERT failure → audit gap).
const previewCap = 4096
func truncatePreview(s string) string {
if len(s) <= previewCap {
return s
}
// Range over a string yields rune-boundary byte indices. Walk
// until the next index would exceed previewCap; the previous
// index is the safe truncation point.
end := 0
for i := range s {
if i > previewCap {
break
}
end = i
}
return s[:end]
}
// InsertOpts is the agent's record-of-intent. Caller, callee, task preview,
// and the chosen delegation_id are required; idempotency_key is optional.
type InsertOpts struct {
@@ -118,7 +94,7 @@ func (l *DelegationLedger) Insert(ctx context.Context, opts InsertOpts) {
) VALUES ($1, $2, $3, $4, 'queued', $5, $6)
ON CONFLICT (delegation_id) DO NOTHING
`, opts.DelegationID, opts.CallerID, opts.CalleeID,
truncatePreview(opts.TaskPreview), deadline, idemArg)
textutil.TruncateBytesNoMarker(opts.TaskPreview, previewCap), deadline, idemArg)
if err != nil {
log.Printf("delegation_ledger Insert(%s): %v", opts.DelegationID, err)
}
@@ -197,7 +173,7 @@ func (l *DelegationLedger) SetStatus(ctx context.Context,
result_preview = NULLIF($4, ''),
updated_at = now()
WHERE delegation_id = $1
`, delegationID, status, errorDetail, truncatePreview(resultPreview))
`, delegationID, status, errorDetail, textutil.TruncateBytesNoMarker(resultPreview, previewCap))
return err
}
@@ -2,6 +2,7 @@ package handlers
import (
"context"
"database/sql/driver"
"errors"
"strings"
"testing"
@@ -74,15 +75,20 @@ func TestLedgerInsert_TruncatesOversizedPreview(t *testing.T) {
mock := setupTestDB(t)
l := NewDelegationLedger(nil)
huge := strings.Repeat("x", 10_000) // > previewCap
// 4096 / 3 = 1365 runes; +10 for margin so we cross the cap.
// '世' is 3 bytes in UTF-8 (worst case for byte-cap rune walking).
huge := strings.Repeat("世", (previewCap/3)+10)
if len(huge) <= previewCap {
t.Fatalf("test setup: input too short (%d bytes) — must exceed previewCap=%d", len(huge), previewCap)
}
mock.ExpectExec(`INSERT INTO delegations`).
WithArgs(
"deleg-big",
"c", "ca",
sqlmock.AnyArg(), // truncated preview — verify length below via custom matcher
sqlmock.AnyArg(),
sqlmock.AnyArg(),
capValidUTF8Matcher{cap: previewCap}, // truncated preview must fit cap AND be valid UTF-8
sqlmock.AnyArg(), // deadline
sqlmock.AnyArg(), // idempotency_key
).
WillReturnResult(sqlmock.NewResult(0, 1))
@@ -97,87 +103,28 @@ func TestLedgerInsert_TruncatesOversizedPreview(t *testing.T) {
}
}
// ---------- truncatePreview unit ----------
// capValidUTF8Matcher pins #2962 at the integration boundary: the
// preview that lands in the INSERT MUST be valid UTF-8 (else Postgres
// JSONB rejects → silent audit gap) AND fit within the byte cap. Pre-
// migration this would have asserted on the corrupted "世" mid-codepoint
// byte slice; post-migration it asserts the truncated preview is a
// clean rune-aligned prefix.
type capValidUTF8Matcher struct{ cap int }
func TestTruncatePreview_UnderCap(t *testing.T) {
in := "short"
if got := truncatePreview(in); got != in {
t.Errorf("under-cap should passthrough; got %q", got)
func (m capValidUTF8Matcher) Match(v driver.Value) bool {
s, ok := v.(string)
if !ok {
return false
}
return len(s) <= m.cap && utf8.ValidString(s)
}
func TestTruncatePreview_OverCapTruncatesAtBoundary(t *testing.T) {
in := strings.Repeat("a", previewCap+100)
got := truncatePreview(in)
if len(got) != previewCap {
t.Errorf("expected len=%d got len=%d", previewCap, len(got))
}
}
func TestTruncatePreview_ExactlyAtCap(t *testing.T) {
in := strings.Repeat("a", previewCap)
got := truncatePreview(in)
if got != in {
t.Errorf("at-cap should passthrough unchanged")
}
}
// TestTruncatePreview_NeverProducesInvalidUTF8 — pins #2962. The old
// byte-slice implementation (s[:previewCap]) split on a byte boundary,
// so a multi-byte codepoint straddling byte 4096 produced invalid
// UTF-8 → Postgres JSONB rejects → ledger row not inserted → audit
// gap. Test feeds a CJK / emoji-padded string longer than previewCap
// and asserts utf8.ValidString on the result.
func TestTruncatePreview_NeverProducesInvalidUTF8(t *testing.T) {
// Build a string of '世' (3 bytes per rune in UTF-8) that's just
// past the cap. With the old implementation, the slice at byte
// previewCap would land mid-rune and ValidString would fail.
// With the rune-aware implementation, the result is always valid
// UTF-8 even if the byte length is < previewCap.
rune3 := "世" // U+4E16, 3 bytes
// Need at least previewCap/3 + 1 runes so we cross the cap with
// margin to spare.
in := strings.Repeat(rune3, (previewCap/3)+10)
if len(in) <= previewCap {
t.Fatalf("test setup: input too short (%d bytes) — must exceed previewCap=%d", len(in), previewCap)
}
got := truncatePreview(in)
if !utf8.ValidString(got) {
t.Errorf("truncatePreview produced invalid UTF-8 — JSONB will reject this row. len(got)=%d", len(got))
}
if len(got) > previewCap {
t.Errorf("truncatePreview exceeded cap: len(got)=%d > previewCap=%d", len(got), previewCap)
}
// Defense-in-depth: the result should also be a clean rune
// prefix of the input — not some garbled sequence.
if !strings.HasPrefix(in, got) {
t.Errorf("truncatePreview should return a prefix of the input")
}
}
// TestTruncatePreview_MultiByteAtBoundary — most-targeted regression.
// Feeds an input where the cap byte falls EXACTLY in the middle of a
// 3-byte codepoint. Pre-fix, this is the case that produces invalid
// UTF-8; post-fix, the truncate stops at the previous rune boundary.
func TestTruncatePreview_MultiByteAtBoundary(t *testing.T) {
// Build a string that's `previewCap-1` ASCII bytes followed by
// '世' (3 bytes). Total = previewCap + 2. The old impl would
// slice at byte previewCap, landing inside the '世' codepoint.
prefix := strings.Repeat("a", previewCap-1)
in := prefix + "世"
if len(in) != previewCap+2 {
t.Fatalf("test setup: expected len %d, got %d", previewCap+2, len(in))
}
got := truncatePreview(in)
if !utf8.ValidString(got) {
t.Errorf("truncatePreview produced invalid UTF-8 at the multi-byte boundary case")
}
// Result should be exactly the ASCII prefix — '世' was past
// the cap so it must be dropped entirely.
if got != prefix {
t.Errorf("expected exact ASCII prefix, got %q (len=%d)", got[len(got)-10:], len(got))
}
}
// Helper-level truncation tests now live in
// internal/textutil/truncate_test.go. The integration-level path
// (TestLedgerInsert_TruncatesOversizedPreview above) still exercises
// the previewCap boundary through the SQL write so a regression in
// the wiring (wrong cap, wrong helper, missing call) would still go
// red here.
// ---------- SetStatus lifecycle ----------
@@ -8,6 +8,7 @@ import (
"net/http"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
"github.com/gin-gonic/gin"
)
@@ -100,7 +101,7 @@ func (h *WorkspaceHandler) RotateExternalCredentials(c *gin.Context) {
// see when credentials were rotated. No PII; the token plaintext
// is NOT logged.
if h.broadcaster != nil {
h.broadcaster.RecordAndBroadcast(ctx, "EXTERNAL_CREDENTIALS_ROTATED", id, map[string]interface{}{
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventExternalCredentialsRotated), id, map[string]interface{}{
"workspace_id": id,
})
}
@@ -331,43 +331,84 @@ func memoryToView(m contract.Memory) MemoryView {
}
// namespacesToViews converts resolver namespaces into UI-friendly
// views. Stable sort: workspace → team → org → custom, then by name.
// views. Prefers `DisplayName` from the resolver (workspace.name from
// the DB) when present; falls back to a UUID-prefix label.
//
// Issue #2988: pre-fix, every namespace used a shortID-truncated UUID
// label. On a root workspace where workspace==team==org IDs collide
// (resolver derive() degenerate case), all three labels rendered
// identically. DisplayName disambiguates by surfacing real workspace
// names — the canvas dropdown now reads "Workspace (mac laptop)" /
// "Team (mac laptop)" / "Org (mac laptop)" for a root workspace
// rather than three identical UUID prefixes. The `kind` prefix
// "Workspace/Team/Org" still carries the semantic distinction.
func namespacesToViews(in []namespace.Namespace) []NamespaceView {
views := make([]NamespaceView, 0, len(in))
for _, n := range in {
views = append(views, NamespaceView{
Name: n.Name,
Kind: n.Kind,
Label: namespaceLabel(n.Name, n.Kind),
Label: namespaceLabelWithName(n.Name, n.Kind, n.DisplayName),
})
}
return views
}
// namespaceLabel renders a human-friendly label for a namespace. The
// canvas displays this directly; we keep the formatting server-side
// so the shape stays consistent across UIs (canvas, future TUI, etc.).
// namespaceLabel renders a human-friendly label for a namespace using
// the UUID-prefix fallback only. Kept for back-compat with callers
// that don't yet plumb a display name. New callers should use
// namespaceLabelWithName which prefers the workspace's display name
// when available.
//
// Format:
// workspace:abc-123 → "Workspace (abc-123)" (UUID short-prefixed)
// Format (UUID-prefix fallback):
// workspace:abc-123 → "Workspace (abc-123)"
// team:t-1 → "Team (t-1)"
// org:acme → "Org (acme)"
// custom:foo → "foo" (operator-defined; raw)
// custom:foo → "foo"
func namespaceLabel(name string, kind contract.NamespaceKind) string {
return namespaceLabelWithName(name, kind, "")
}
// namespaceLabelWithName renders the human-friendly label, preferring
// `displayName` when non-empty.
//
// When displayName is set:
// Workspace, "mac laptop" → "Workspace (mac laptop)"
// Team, "Engineering team" → "Team (Engineering team)"
// Org, "Hongming's Org" → "Org (Hongming's Org)"
//
// When displayName is empty (lookup miss, future-migration drop, etc.),
// falls back to the UUID-prefix shape for back-compat.
//
// Custom namespaces ignore displayName because they're operator-defined
// — the operator chose the raw suffix as the label, surfacing a
// different "name" would be a UX surprise.
func namespaceLabelWithName(name string, kind contract.NamespaceKind, displayName string) string {
suffix := ""
if i := indexOfColon(name); i >= 0 && i+1 < len(name) {
suffix = name[i+1:]
}
switch kind {
case contract.NamespaceKindWorkspace:
if displayName != "" {
return "Workspace (" + displayName + ")"
}
return "Workspace (" + shortID(suffix) + ")"
case contract.NamespaceKindTeam:
if displayName != "" {
return "Team (" + displayName + ")"
}
return "Team (" + shortID(suffix) + ")"
case contract.NamespaceKindOrg:
if displayName != "" {
return "Org (" + displayName + ")"
}
return "Org (" + suffix + ")"
case contract.NamespaceKindCustom:
// Custom namespaces are operator-defined; surface the raw
// suffix so they can label them however they want.
// Operator-defined; the suffix IS the label they chose.
// displayName is ignored — surfacing a different name would
// be a UX surprise for an operator who deliberately named
// the namespace.
if suffix == "" {
return name
}
@@ -507,6 +507,92 @@ func TestMemoriesV2_Forget_MissingMemoryID_400(t *testing.T) {
// View-shaping unit tests — pin individual helpers
// ─────────────────────────────────────────────────────────────────────────────
// namespaceLabelWithName tests — the new code path that prefers
// DisplayName over UUID-prefix fallback (issue #2988).
func TestNamespaceLabelWithName_PrefersDisplayNameWhenSet(t *testing.T) {
cases := []struct {
name string
raw string
kind contract.NamespaceKind
display string
want string
}{
{"workspace with name", "workspace:abc-1234", contract.NamespaceKindWorkspace, "mac laptop", "Workspace (mac laptop)"},
{"team with name", "team:abc-1234", contract.NamespaceKindTeam, "Engineering", "Team (Engineering)"},
{"org with name", "org:acme", contract.NamespaceKindOrg, "Hongming's Org", "Org (Hongming's Org)"},
// Custom ignores displayName by design — operator chose the suffix.
{"custom ignores displayName", "custom:ops-shared", contract.NamespaceKindCustom, "FancyName", "ops-shared"},
{"unknown kind falls through", "weird:x", contract.NamespaceKind("future"), "WhoCares", "weird:x"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := namespaceLabelWithName(tc.raw, tc.kind, tc.display)
if got != tc.want {
t.Errorf("namespaceLabelWithName(%q, %q, %q) = %q, want %q",
tc.raw, tc.kind, tc.display, got, tc.want)
}
})
}
}
func TestNamespaceLabelWithName_FallsBackToUUIDPrefixWhenEmpty(t *testing.T) {
// When displayName is empty (NULL in DB, lookup miss, etc.), the
// label shape MUST match the legacy UUID-prefix shape exactly so
// existing canvas behaviour is unchanged for callers that don't
// plumb a name.
cases := []struct {
raw string
kind contract.NamespaceKind
want string
}{
{"workspace:abcdefghij", contract.NamespaceKindWorkspace, "Workspace (abcdefgh)"},
{"team:t-99", contract.NamespaceKindTeam, "Team (t-99)"},
{"org:acme", contract.NamespaceKindOrg, "Org (acme)"},
}
for _, tc := range cases {
got := namespaceLabelWithName(tc.raw, tc.kind, "")
if got != tc.want {
t.Errorf("displayName=\"\" path: got %q, want %q", got, tc.want)
}
}
}
func TestNamespacesToViews_PassesDisplayNameThrough(t *testing.T) {
in := []namespace.Namespace{
{Name: "workspace:root-1", Kind: contract.NamespaceKindWorkspace, DisplayName: "mac laptop"},
{Name: "team:root-1", Kind: contract.NamespaceKindTeam, DisplayName: "mac laptop"}, // root → team aliases self
{Name: "org:root-1", Kind: contract.NamespaceKindOrg, DisplayName: "mac laptop"},
}
out := namespacesToViews(in)
if len(out) != 3 {
t.Fatalf("len = %d, want 3", len(out))
}
wantLabels := []string{
"Workspace (mac laptop)",
"Team (mac laptop)",
"Org (mac laptop)",
}
for i, v := range out {
if v.Label != wantLabels[i] {
t.Errorf("[%d] label = %q, want %q", i, v.Label, wantLabels[i])
}
}
}
func TestNamespacesToViews_FallsBackToUUIDLabelWhenDisplayNameEmpty(t *testing.T) {
// Exercises the back-compat path — DisplayName="" plumbs through
// to namespaceLabelWithName which returns the legacy UUID-prefix
// label. This is what callers see when the workspaces table
// has a NULL name (defensive — workspaces.name is NOT NULL today).
in := []namespace.Namespace{
{Name: "workspace:root-1", Kind: contract.NamespaceKindWorkspace}, // no DisplayName
}
out := namespacesToViews(in)
if out[0].Label != "Workspace (root-1)" {
t.Errorf("fallback label = %q, want %q", out[0].Label, "Workspace (root-1)")
}
}
func TestNamespaceLabel_AllKinds(t *testing.T) {
cases := []struct {
name string
@@ -20,12 +20,14 @@ import (
"github.com/Molecule-AI/molecule-monorepo/platform/internal/channels"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/crypto"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provlog"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/scheduler"
"github.com/google/uuid"
)
// createWorkspaceTree recursively materialises an OrgWorkspace (and its
// descendants) into the workspaces + canvas_layouts tables and kicks off
// Docker provisioning. absX/absY are THIS workspace's absolute canvas
@@ -80,61 +82,6 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
}
}
// 5s timeout bounds the lookup independently of any HTTP request
// context. createWorkspaceTree runs in goroutines spawned from the
// /org/import handler, so plumbing the request context here would
// cascade-cancel into provisionWorkspaceAuto and abort in-flight
// EC2 provisioning if the client disconnected mid-import — that's
// the wrong behaviour. A short bounded timeout protects the
// per-row SELECT against a wedged DB without taking the
// drop-everything-on-disconnect tradeoff.
ctxLookup, cancelLookup := context.WithTimeout(context.Background(), 5*time.Second)
defer cancelLookup()
// Idempotency: if a workspace with the same (parent_id, name) already
// exists, skip the INSERT + canvas_layouts + broadcast + provisioning.
// This is what makes /org/import safe to call multiple times — the
// historical leak was every call recreating the entire tree (see
// tenant-hongming, 72 distinct child workspaces in 4 days, all from
// repeated org-template spawns of the same template).
//
// Recursion still runs on the existing id so partial-match templates
// (parent exists, some children missing) backfill the missing children
// instead of either no-op'ing the whole subtree or duplicating the
// existing children.
//
// /org/import is ADDITIVE-ONLY, never destructive. Children present
// in the existing tree but absent from the new template are
// preserved (no DELETE on diff). Skip-path also does NOT propagate
// updates to existing nodes — a re-import that adds an
// initial_memory or schedule to an existing workspace is silently
// dropped (the function bypasses seedInitialMemories, schedule SQL,
// channel config for skipped rows). To force-update an existing
// tree, delete and re-import or use a future /org/sync route.
existingID, existing, lookupErr := h.lookupExistingChild(ctxLookup, ws.Name, parentID)
if lookupErr != nil {
return fmt.Errorf("idempotency check for %s: %w", ws.Name, lookupErr)
}
if existing {
log.Printf("Org import: %q already exists (id=%s) — skipping create+provision, recursing into children for partial-match", ws.Name, existingID)
parentRef := ""
if parentID != nil {
parentRef = *parentID
}
provlog.Event("provision.skip_existing", map[string]any{
"name": ws.Name,
"existing_id": existingID,
"parent_id": parentRef,
"tier": tier,
})
*results = append(*results, map[string]interface{}{
"id": existingID,
"name": ws.Name,
"tier": tier,
"skipped": true,
})
return h.recurseChildrenForImport(ws, existingID, absX, absY, defaults, orgBaseDir, results, provisionSem)
}
id := uuid.New().String()
awarenessNS := workspaceAwarenessNamespace(id)
@@ -186,10 +133,67 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
if maxConcurrent <= 0 {
maxConcurrent = models.DefaultMaxConcurrentTasks
}
_, err := db.DB.ExecContext(ctx, `
// TOCTOU-safe insert (#2872 Critical 1).
//
// `ON CONFLICT DO NOTHING` paired with the partial unique index
// from migration 20260506000000_workspaces_unique_parent_name.up.sql
// atomically resolves a race window that the prior
// lookup-then-insert had: two concurrent /org/import POSTs both
// saw "not found" in lookupExistingChild and both INSERT'd the
// same (parent_id, name). After this swap the SECOND INSERT
// silently no-ops, RETURNING returns 0 rows → sql.ErrNoRows, and
// the skip-path runs.
//
// ON CONFLICT target uses (COALESCE(parent_id,...), name) WHERE
// status != 'removed' — must match the partial-index predicate
// EXACTLY for Postgres to consider the index applicable.
var insertedID string
err := db.DB.QueryRowContext(ctx, `
INSERT INTO workspaces (id, name, role, tier, runtime, awareness_namespace, status, parent_id, workspace_dir, workspace_access, max_concurrent_tasks)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
`, id, ws.Name, role, tier, runtime, awarenessNS, "provisioning", parentID, workspaceDir, workspaceAccess, maxConcurrent)
ON CONFLICT (COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid), name)
WHERE status != 'removed'
DO NOTHING
RETURNING id
`, id, ws.Name, role, tier, runtime, awarenessNS, "provisioning", parentID, workspaceDir, workspaceAccess, maxConcurrent).Scan(&insertedID)
if errors.Is(err, sql.ErrNoRows) {
// Skip path — a non-removed row already exists for
// (parent_id, name). Re-select its id; idempotency-friendly
// semantics match the original lookupExistingChild path
// (parent_id IS NOT DISTINCT FROM matches NULL too,
// status='removed' rows are ignored).
ctxLookup, cancelLookup := context.WithTimeout(context.Background(), 5*time.Second)
defer cancelLookup()
existingID, found, selErr := h.lookupExistingChild(ctxLookup, ws.Name, parentID)
if selErr != nil {
return fmt.Errorf("post-conflict re-select for %s: %w", ws.Name, selErr)
}
if !found {
// Index conflicted but row vanished between INSERT and
// re-SELECT (status flipped to 'removed' concurrently).
// Surface as an error rather than silently retrying —
// the user can re-trigger /org/import safely.
return fmt.Errorf("workspace %q conflicted on insert but not visible on re-select (concurrent status flip?)", ws.Name)
}
log.Printf("Org import: %q already exists (id=%s) — skipping create+provision, recursing into children for partial-match", ws.Name, existingID)
parentRef := ""
if parentID != nil {
parentRef = *parentID
}
provlog.Event("provision.skip_existing", map[string]any{
"name": ws.Name,
"existing_id": existingID,
"parent_id": parentRef,
"tier": tier,
})
*results = append(*results, map[string]interface{}{
"id": existingID,
"name": ws.Name,
"tier": tier,
"skipped": true,
})
return h.recurseChildrenForImport(ws, existingID, absX, absY, defaults, orgBaseDir, results, provisionSem)
}
if err != nil {
log.Printf("Org import: failed to create %s: %v", ws.Name, err)
return fmt.Errorf("failed to create %s: %w", ws.Name, err)
@@ -227,7 +231,7 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
if parentID != nil {
payload["parent_id"] = *parentID
}
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_PROVISIONING", id, payload)
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceProvisioning), id, payload)
// Seed initial memories from workspace config or defaults (issue #1050).
// Per-workspace initial_memories override defaults; if workspace has none,
@@ -243,7 +247,7 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
if _, err := db.DB.ExecContext(ctx, `UPDATE workspaces SET status = $1, url = $2 WHERE id = $3`, models.StatusOnline, ws.URL, id); err != nil {
log.Printf("Org import: external workspace status update failed for %s: %v", ws.Name, err)
}
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_ONLINE", id, map[string]interface{}{
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceOnline), id, map[string]interface{}{
"name": ws.Name, "external": true,
})
} else if h.workspace.HasProvisioner() {
@@ -31,11 +31,25 @@ import (
// tests pin the helper's three observable behaviors plus an AST gate
// that catches future re-introductions of the un-checked INSERT.
// lookupChildSQLRE anchors the sqlmock ExpectQuery on every load-bearing
// token of lookupExistingChild's SELECT (org_import.go:639-645). A loose
// substring match (the prior shape, just `SELECT id FROM workspaces`)
// would silent-pass a regression that drops `IS NOT DISTINCT FROM`
// (breaks NULL-parent matching), drops `parent_id` entirely (hijacks
// siblings of the same name across different parents), or drops the
// `status != 'removed'` filter (blocks re-import after Collapse).
// RFC #2872 Important-2.
//
// The four anchored tokens are exactly the predicates the bug shapes
// would tamper with. Whitespace is `\s+` so a future formatter pass
// doesn't churn this string.
const lookupChildSQLRE = `(?s)SELECT id FROM workspaces\s+WHERE name = \$1\s+AND parent_id IS NOT DISTINCT FROM \$2\s+AND status != 'removed'`
func TestLookupExistingChild_NotFound_ReturnsFalseNoError(t *testing.T) {
mock := setupTestDB(t)
// 0-row result → driver returns sql.ErrNoRows on Scan.
parent := "parent-1"
mock.ExpectQuery(`SELECT id FROM workspaces`).
mock.ExpectQuery(lookupChildSQLRE).
WithArgs("Alpha", &parent).
WillReturnRows(sqlmock.NewRows([]string{"id"}))
@@ -56,7 +70,7 @@ func TestLookupExistingChild_NotFound_ReturnsFalseNoError(t *testing.T) {
func TestLookupExistingChild_Found_ReturnsIDAndTrue(t *testing.T) {
mock := setupTestDB(t)
parent := "parent-1"
mock.ExpectQuery(`SELECT id FROM workspaces`).
mock.ExpectQuery(lookupChildSQLRE).
WithArgs("Alpha", &parent).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-existing-uuid"))
@@ -79,7 +93,7 @@ func TestLookupExistingChild_NilParent_MatchesRoot(t *testing.T) {
// a plain `=` would never match a NULL row. Pin that roots
// (parent_id=NULL) are still found by the lookup.
mock := setupTestDB(t)
mock.ExpectQuery(`SELECT id FROM workspaces`).
mock.ExpectQuery(lookupChildSQLRE).
WithArgs("RootAgent", (*string)(nil)).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-root-uuid"))
@@ -102,7 +116,7 @@ func TestLookupExistingChild_DBError_Propagates(t *testing.T) {
mock := setupTestDB(t)
parent := "parent-1"
connFail := errors.New("simulated postgres unavailable")
mock.ExpectQuery(`SELECT id FROM workspaces`).
mock.ExpectQuery(lookupChildSQLRE).
WithArgs("Alpha", &parent).
WillReturnError(connFail)
@@ -137,7 +151,7 @@ func TestLookupExistingChild_WrappedNoRows_TreatedAsNotFound(t *testing.T) {
mock := setupTestDB(t)
parent := "parent-1"
wrapped := fmt.Errorf("driver-wrapped: %w", sql.ErrNoRows)
mock.ExpectQuery(`SELECT id FROM workspaces`).
mock.ExpectQuery(lookupChildSQLRE).
WithArgs("Alpha", &parent).
WillReturnError(wrapped)
@@ -209,19 +223,42 @@ func findLookupAndWorkspacesInsertPos(t *testing.T, fname string, src []byte) (l
return
}
// Source-level guard — pins that org_import.go calls
// h.lookupExistingChild BEFORE its INSERT INTO workspaces.
// onConflictDoNothingRE pins the TOCTOU-safe shape introduced by
// migration 20260506000000_workspaces_unique_parent_name.up.sql +
// the org_import.go INSERT swap (#2872 Critical 1). The workspaces
// INSERT MUST funnel concurrent collisions through the partial unique
// index — `ON CONFLICT (...) WHERE status != 'removed' DO NOTHING`
// is the literal pg statement form that achieves it.
//
// The pattern intentionally requires both the COALESCE expression
// (so root-workspace NULL parents collide) AND the partial-index WHERE
// clause (so 'removed' rows don't block re-imports). A regression that
// drops either piece would make the index target a different shape
// than the migration created, and Postgres would emit
// "no unique or exclusion constraint matching the ON CONFLICT
// specification" at runtime — but only on the FIRST collision attempt
// in production, not in CI without a live race. This regex catches
// the shape in source so the bug never ships.
var onConflictDoNothingRE = regexp.MustCompile(
`(?s)ON\s+CONFLICT\s*\(\s*COALESCE\s*\(\s*parent_id\s*,\s*'00000000-0000-0000-0000-000000000000'::uuid\s*\)\s*,\s*name\s*\).*?WHERE\s+status\s*!=\s*'removed'.*?DO\s+NOTHING`,
)
// Source-level guard — pins that org_import.go's INSERT INTO workspaces
// uses the TOCTOU-safe ON CONFLICT DO NOTHING pattern.
//
// Per memory feedback_behavior_based_ast_gates.md: pin the behavior
// (idempotency check before INSERT), not just function names. If a
// future refactor reintroduces the un-checked INSERT (the original
// bug shape that leaked 72 workspaces in 4 days), this test fails.
// (atomic conflict resolution at the DB), not just function names.
// If a future refactor reintroduces the un-checked INSERT (the original
// bug shape that leaked 72 workspaces in 4 days at tenant-hongming),
// this test fails BEFORE the broken code reaches production where the
// race window opens.
//
// AST-walk implementation closes the silent-false-pass mode that the
// previous bytes.Index gate had — see workspacesInsertRE comment for
// the failure mode (workspaces_audit / workspace_secrets / etc.
// shadowing the real target via prefix match).
func TestCreateWorkspaceTree_CallsLookupBeforeInsert(t *testing.T) {
// Replaces an earlier "lookup-before-insert" gate that became obsolete
// when this swap moved idempotency into the database. The earlier
// gate would silent-false-pass against ON CONFLICT — even though that
// shape is correct — because lookupExistingChild now runs AFTER the
// INSERT (only on the skip path, to retrieve the existing id).
func TestCreateWorkspaceTree_InsertUsesOnConflictDoNothing(t *testing.T) {
wd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
@@ -230,30 +267,24 @@ func TestCreateWorkspaceTree_CallsLookupBeforeInsert(t *testing.T) {
if err != nil {
t.Fatalf("read org_import.go: %v", err)
}
lookupPos, insertPos, fset := findLookupAndWorkspacesInsertPos(t, "org_import.go", src)
if lookupPos == token.NoPos {
t.Fatalf("AST: no call to lookupExistingChild in org_import.go — idempotency check removed?")
}
if insertPos == token.NoPos {
insertSQL := findWorkspacesInsertSQL(t, "org_import.go", src)
if insertSQL == "" {
t.Fatalf("AST: no SQL literal matching `^\\s*INSERT INTO workspaces\\s*\\(` in any CallExpr in org_import.go — schema change or rename?")
}
if lookupPos > insertPos {
t.Errorf("lookupExistingChild call at %s must come BEFORE INSERT INTO workspaces at %s — non-idempotent ordering would re-leak under repeat /org/import calls",
fset.Position(lookupPos), fset.Position(insertPos))
if !onConflictDoNothingRE.MatchString(insertSQL) {
t.Errorf("workspaces INSERT SQL does NOT use the TOCTOU-safe ON CONFLICT shape — concurrent /org/import POSTs will silently double-insert. Required pattern:\n ON CONFLICT (COALESCE(parent_id, '00000000-...'::uuid), name) WHERE status != 'removed' DO NOTHING\n\nActual SQL:\n%s", insertSQL)
}
}
// TestGate_FailsWhenLookupAfterInsert proves the gate actually catches
// the bug it's named after — running it against synthetic Go source
// where the lookup call is positioned AFTER the workspaces INSERT must
// produce lookupPos > insertPos, which the production gate flags as
// an ERROR. Without this test the gate could regress to "always pass"
// and we wouldn't notice until the bug shipped again.
// TestGate_FailsWhenInsertOmitsOnConflict proves the gate actually
// catches the bug it's named after — running it against synthetic Go
// source where the workspaces INSERT lacks the ON CONFLICT clause must
// fail the regex match. Without this test the gate could regress to
// "always pass" and the TOCTOU window would silently reopen.
//
// Per memory feedback_assert_exact_not_substring.md: verify a
// tightened test FAILS on old code before merging.
func TestGate_FailsWhenLookupAfterInsert(t *testing.T) {
// Per memory feedback_assert_exact_not_substring.md: verify the
// tightened test FAILS on the bug shape before merging.
func TestGate_FailsWhenInsertOmitsOnConflict(t *testing.T) {
const buggySrc = `package handlers
import "context"
@@ -264,26 +295,57 @@ func (fakeDB) ExecContext(ctx context.Context, sql string, args ...interface{})
type fakeOrgHandler struct{}
func (h *fakeOrgHandler) lookupExistingChild(ctx context.Context, name string, parentID *string) (string, bool, error) {
return "", false, nil
}
func buggyCreate(h *fakeOrgHandler, db fakeDB, ctx context.Context, name string, parentID *string) {
// Bug shape: INSERT runs FIRST, lookup runs AFTER. This is the
// non-idempotent ordering the gate exists to forbid.
// Bug shape: bare INSERT, no ON CONFLICT. Two concurrent calls
// race past the unique-index check before either completes the
// transaction; constraint failure surfaces as a 500 to the
// caller (not graceful skip). Pre-#2872 this would silently
// duplicate-insert.
db.ExecContext(ctx, ` + "`INSERT INTO workspaces (id, name) VALUES ($1, $2)`" + `, "x", name)
h.lookupExistingChild(ctx, name, parentID)
}
`
lookupPos, insertPos, _ := findLookupAndWorkspacesInsertPos(t, "buggy.go", []byte(buggySrc))
if lookupPos == token.NoPos || insertPos == token.NoPos {
t.Fatalf("synthetic buggy source missing expected nodes (lookupPos=%v insertPos=%v) — helper logic regression", lookupPos, insertPos)
insertSQL := findWorkspacesInsertSQL(t, "buggy.go", []byte(buggySrc))
if insertSQL == "" {
t.Fatalf("synthetic buggy source missing workspaces INSERT — helper logic regression")
}
if lookupPos < insertPos {
t.Fatalf("synthetic bug shape (lookup AFTER insert) returned lookupPos=%d < insertPos=%d — gate would NOT fire on actual bug, regression!", lookupPos, insertPos)
if onConflictDoNothingRE.MatchString(insertSQL) {
t.Fatalf("synthetic bug shape (bare INSERT, no ON CONFLICT) was MATCHED by the gate — regression: gate would not flag the actual bug. SQL:\n%s", insertSQL)
}
// Implicit: lookupPos > insertPos here, which the production gate
// flags via t.Errorf. This proves the gate is live, not vestigial.
}
// findWorkspacesInsertSQL walks `src` and returns the unquoted SQL of
// the first string literal matching workspacesInsertRE inside any
// CallExpr's argument list. Returns "" if none found. Helper for the
// ON CONFLICT gate above.
func findWorkspacesInsertSQL(t *testing.T, fname string, src []byte) string {
t.Helper()
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, fname, src, parser.ParseComments)
if err != nil {
t.Fatalf("parse %s: %v", fname, err)
}
var sql string
ast.Inspect(file, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
for _, arg := range call.Args {
lit, ok := arg.(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
continue
}
raw := lit.Value
if unq, err := strconv.Unquote(raw); err == nil {
raw = unq
}
if workspacesInsertRE.MatchString(raw) && sql == "" {
sql = raw
}
}
return true
})
return sql
}
// TestGate_IgnoresAuditTableShadow proves the regex tightening
@@ -451,6 +451,201 @@ func TestIntegration_PendingUploads_AckedIndexExists(t *testing.T) {
}
}
// TestIntegration_PollUpload_AtomicRollback_AcrossBothTables proves the
// #149 cross-table contract at the database layer: when PutBatchTx and
// LogActivityTx run in the same caller-owned Tx and an activity INSERT
// fails after some rows have already been INSERTed, Rollback unwinds
// BOTH tables, leaving zero rows.
//
// Coverage map (#149):
// - chat_files_poll_test.go's TestPollUpload_AtomicRollbackOnActivityInsertFailure
// uses sqlmock to prove the Go handler issues Begin / N inserts /
// Rollback in the right order (no Commit on failure path).
// - This integration test proves the helpers + real Postgres compose
// correctly: rollback after a mid-Tx activity insert failure
// actually reverts BOTH the prior activity row AND the
// pending_uploads rows from PutBatchTx.
// - The pre-existing TestIntegration_PendingUploads_PutBatch_AtomicRollback
// covers the pending_uploads-only case.
//
// Failure injection: a NUL byte in `summary` (TEXT column) — lib/pq
// rejects it at the protocol layer. Same trick the existing PutBatch
// AtomicRollback test uses for the pending_uploads INSERT.
func TestIntegration_PollUpload_AtomicRollback_AcrossBothTables(t *testing.T) {
conn := integrationDB_PendingUploads(t)
ctx := context.Background()
// activity_logs has a FK to workspaces(id) — seed a real row so
// non-failing inserts succeed. Wipe activity_logs + this workspaces
// row at end so the next test sees a clean slate (the integrationDB
// helper only wipes pending_uploads).
wsID := uuid.New()
if _, err := conn.ExecContext(ctx,
`INSERT INTO workspaces (id, name) VALUES ($1, 'test-149-rollback')`, wsID,
); err != nil {
t.Fatalf("seed workspace: %v", err)
}
t.Cleanup(func() {
// CASCADE on workspaces FK deletes the activity_logs rows; explicit
// DELETE on activity_logs catches any rows that somehow leaked.
_, _ = conn.ExecContext(context.Background(), `DELETE FROM activity_logs WHERE workspace_id = $1`, wsID)
_, _ = conn.ExecContext(context.Background(), `DELETE FROM workspaces WHERE id = $1`, wsID)
})
store := pendinguploads.NewPostgres(conn)
// Mirror uploadPollMode's Tx shape: BeginTx → PutBatchTx → N ×
// LogActivityTx → Commit (or Rollback on failure).
tx, err := conn.BeginTx(ctx, nil)
if err != nil {
t.Fatalf("BeginTx: %v", err)
}
items := []pendinguploads.PutItem{
{Content: []byte("first"), Filename: "a.txt", Mimetype: "text/plain"},
{Content: []byte("second"), Filename: "b.txt", Mimetype: "text/plain"},
}
fileIDs, err := store.PutBatchTx(ctx, tx, wsID, items)
if err != nil {
t.Fatalf("PutBatchTx: %v", err)
}
if len(fileIDs) != 2 {
t.Fatalf("len(fileIDs) = %d, want 2", len(fileIDs))
}
// First activity insert succeeds — would commit if not for the
// rollback that the second insert's failure forces.
wsIDStr := wsID.String()
method := "chat_upload_receive"
okSummary := "chat_upload_receive: a.txt"
if _, err := LogActivityTx(ctx, tx, nil, ActivityParams{
WorkspaceID: wsIDStr,
ActivityType: "a2a_receive",
TargetID: &wsIDStr,
Method: &method,
Summary: &okSummary,
Status: "ok",
}); err != nil {
t.Fatalf("first LogActivityTx (should succeed): %v", err)
}
// Second activity insert: NUL byte in summary triggers lib/pq
// "invalid byte sequence for encoding UTF8: 0x00" — the canonical
// "DB-side error after some Tx work has already happened" we want.
badSummary := "chat_upload_receive: b\x00.txt"
_, err = LogActivityTx(ctx, tx, nil, ActivityParams{
WorkspaceID: wsIDStr,
ActivityType: "a2a_receive",
TargetID: &wsIDStr,
Method: &method,
Summary: &badSummary,
Status: "ok",
})
if err == nil {
t.Fatal("expected error from NUL-byte summary, got nil")
}
// Caller (uploadPollMode in production) rolls back on the error.
if rbErr := tx.Rollback(); rbErr != nil {
t.Fatalf("Rollback: %v", rbErr)
}
// THE assertion this test exists for: BOTH tables must have zero
// rows for this workspace. Pre-#149 the activity_logs row from the
// first insert would persist (separate fire-and-forget INSERT) and
// pending_uploads would also persist (committed by PutBatch's own
// Tx). Post-#149 the shared Tx + Rollback unwinds both.
var puCount, alCount int
if err := conn.QueryRowContext(ctx,
`SELECT COUNT(*) FROM pending_uploads WHERE workspace_id = $1`, wsID,
).Scan(&puCount); err != nil {
t.Fatalf("count pending_uploads: %v", err)
}
if err := conn.QueryRowContext(ctx,
`SELECT COUNT(*) FROM activity_logs WHERE workspace_id = $1`, wsID,
).Scan(&alCount); err != nil {
t.Fatalf("count activity_logs: %v", err)
}
if puCount != 0 {
t.Errorf("pending_uploads leaked %d row(s) after Rollback — #149 regression", puCount)
}
if alCount != 0 {
t.Errorf("activity_logs leaked %d row(s) after Rollback — #149 regression "+
"(THIS is the scenario the ticket called out: pre-fix, the first activity row "+
"committed in its own implicit Tx, leaving an orphan)", alCount)
}
}
// TestIntegration_PollUpload_HappyPath_AcrossBothTables is the positive
// counterpart to the rollback test: when nothing fails, both tables
// commit together and the row counts match.
func TestIntegration_PollUpload_HappyPath_AcrossBothTables(t *testing.T) {
conn := integrationDB_PendingUploads(t)
ctx := context.Background()
wsID := uuid.New()
if _, err := conn.ExecContext(ctx,
`INSERT INTO workspaces (id, name) VALUES ($1, 'test-149-happy')`, wsID,
); err != nil {
t.Fatalf("seed workspace: %v", err)
}
t.Cleanup(func() {
_, _ = conn.ExecContext(context.Background(), `DELETE FROM activity_logs WHERE workspace_id = $1`, wsID)
_, _ = conn.ExecContext(context.Background(), `DELETE FROM workspaces WHERE id = $1`, wsID)
})
store := pendinguploads.NewPostgres(conn)
tx, err := conn.BeginTx(ctx, nil)
if err != nil {
t.Fatalf("BeginTx: %v", err)
}
items := []pendinguploads.PutItem{
{Content: []byte("a"), Filename: "a.txt", Mimetype: "text/plain"},
{Content: []byte("b"), Filename: "b.txt", Mimetype: "text/plain"},
{Content: []byte("c"), Filename: "c.txt", Mimetype: "text/plain"},
}
if _, err := store.PutBatchTx(ctx, tx, wsID, items); err != nil {
t.Fatalf("PutBatchTx: %v", err)
}
wsIDStr := wsID.String()
method := "chat_upload_receive"
for _, it := range items {
summary := "chat_upload_receive: " + it.Filename
if _, err := LogActivityTx(ctx, tx, nil, ActivityParams{
WorkspaceID: wsIDStr,
ActivityType: "a2a_receive",
TargetID: &wsIDStr,
Method: &method,
Summary: &summary,
Status: "ok",
}); err != nil {
t.Fatalf("LogActivityTx %q: %v", it.Filename, err)
}
}
if err := tx.Commit(); err != nil {
t.Fatalf("Commit: %v", err)
}
var puCount, alCount int
if err := conn.QueryRowContext(ctx,
`SELECT COUNT(*) FROM pending_uploads WHERE workspace_id = $1`, wsID,
).Scan(&puCount); err != nil {
t.Fatalf("count pending_uploads: %v", err)
}
if err := conn.QueryRowContext(ctx,
`SELECT COUNT(*) FROM activity_logs WHERE workspace_id = $1`, wsID,
).Scan(&alCount); err != nil {
t.Fatalf("count activity_logs: %v", err)
}
if puCount != 3 {
t.Errorf("pending_uploads count = %d, want 3", puCount)
}
if alCount != 3 {
t.Errorf("activity_logs count = %d, want 3", alCount)
}
}
func TestIntegration_PendingUploads_GetIgnoresExpiredAndAcked(t *testing.T) {
conn := integrationDB_PendingUploads(t)
store := pendinguploads.NewPostgres(conn)
@@ -2,6 +2,7 @@ package handlers_test
import (
"context"
"database/sql"
"encoding/json"
"errors"
"net/http"
@@ -84,6 +85,9 @@ func (f *fakeStorage) Sweep(_ context.Context, _ time.Duration) (pendinguploads.
func (f *fakeStorage) PutBatch(_ context.Context, _ uuid.UUID, _ []pendinguploads.PutItem) ([]uuid.UUID, error) {
return nil, nil
}
func (f *fakeStorage) PutBatchTx(_ context.Context, _ *sql.Tx, _ uuid.UUID, _ []pendinguploads.PutItem) ([]uuid.UUID, error) {
return nil, nil
}
func newRouter(handler *handlers.PendingUploadsHandler) *gin.Engine {
gin.SetMode(gin.TestMode)
+10 -10
View File
@@ -414,7 +414,7 @@ func (h *RegistryHandler) Register(c *gin.Context) {
}
// Broadcast WORKSPACE_ONLINE
if err := h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_ONLINE", payload.ID, map[string]interface{}{
if err := h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceOnline), payload.ID, map[string]interface{}{
"url": cachedURL,
"agent_card": payload.AgentCard,
"delivery_mode": effectiveMode,
@@ -572,7 +572,7 @@ func (h *RegistryHandler) Heartbeat(c *gin.Context) {
// Broadcast current task update only when it changed (avoid spamming on every heartbeat)
if payload.CurrentTask != prevTask {
h.broadcaster.BroadcastOnly(payload.WorkspaceID, "TASK_UPDATED", map[string]interface{}{
h.broadcaster.BroadcastOnly(payload.WorkspaceID, string(events.EventTaskUpdated), map[string]interface{}{
"current_task": payload.CurrentTask,
"active_tasks": payload.ActiveTasks,
})
@@ -593,7 +593,7 @@ func (h *RegistryHandler) Heartbeat(c *gin.Context) {
// so per-heartbeat cost is one in-memory channel send per active
// SSE subscriber and one WS hub fan-out. At 30s heartbeat cadence
// this is far below any noise floor on either path.
h.broadcaster.BroadcastOnly(payload.WorkspaceID, "WORKSPACE_HEARTBEAT", map[string]interface{}{
h.broadcaster.BroadcastOnly(payload.WorkspaceID, string(events.EventWorkspaceHeartbeat), map[string]interface{}{
"active_tasks": payload.ActiveTasks,
"uptime_seconds": payload.UptimeSeconds,
})
@@ -678,7 +678,7 @@ func (h *RegistryHandler) evaluateStatus(c *gin.Context, payload models.Heartbea
if err != nil {
log.Printf("Heartbeat: failed to mark %s degraded (wedged): %v", payload.WorkspaceID, err)
}
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_DEGRADED", payload.WorkspaceID, map[string]interface{}{
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceDegraded), payload.WorkspaceID, map[string]interface{}{
"runtime_state": "wedged",
"sample_error": payload.SampleError,
})
@@ -699,7 +699,7 @@ func (h *RegistryHandler) evaluateStatus(c *gin.Context, payload models.Heartbea
if _, err := db.DB.ExecContext(ctx, `UPDATE workspaces SET status = $1, updated_at = now() WHERE id = $2`, models.StatusDegraded, payload.WorkspaceID); err != nil {
log.Printf("Heartbeat: failed to mark %s degraded: %v", payload.WorkspaceID, err)
}
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_DEGRADED", payload.WorkspaceID, map[string]interface{}{
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceDegraded), payload.WorkspaceID, map[string]interface{}{
"error_rate": payload.ErrorRate,
"sample_error": payload.SampleError,
})
@@ -718,7 +718,7 @@ func (h *RegistryHandler) evaluateStatus(c *gin.Context, payload models.Heartbea
if _, err := db.DB.ExecContext(ctx, `UPDATE workspaces SET status = $1, updated_at = now() WHERE id = $2`, models.StatusOnline, payload.WorkspaceID); err != nil {
log.Printf("Heartbeat: failed to recover %s to online: %v", payload.WorkspaceID, err)
}
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_ONLINE", payload.WorkspaceID, map[string]interface{}{})
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceOnline), payload.WorkspaceID, map[string]interface{}{})
}
// Recovery: if workspace was offline but is now sending heartbeats, bring it back online.
@@ -728,7 +728,7 @@ func (h *RegistryHandler) evaluateStatus(c *gin.Context, payload models.Heartbea
if _, err := db.DB.ExecContext(ctx, `UPDATE workspaces SET status = $1, updated_at = now() WHERE id = $2 AND status = 'offline'`, models.StatusOnline, payload.WorkspaceID); err != nil {
log.Printf("Heartbeat: failed to recover %s from offline: %v", payload.WorkspaceID, err)
}
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_ONLINE", payload.WorkspaceID, map[string]interface{}{})
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceOnline), payload.WorkspaceID, map[string]interface{}{})
}
// Auto-recovery: if a workspace is marked "provisioning" but is actively sending
@@ -743,7 +743,7 @@ func (h *RegistryHandler) evaluateStatus(c *gin.Context, payload models.Heartbea
} else {
log.Printf("Heartbeat: transitioned %s from provisioning to online (heartbeat received)", payload.WorkspaceID)
}
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_ONLINE", payload.WorkspaceID, map[string]interface{}{
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceOnline), payload.WorkspaceID, map[string]interface{}{
"recovered_from": currentStatus,
})
}
@@ -771,7 +771,7 @@ func (h *RegistryHandler) evaluateStatus(c *gin.Context, payload models.Heartbea
} else {
log.Printf("Heartbeat: transitioned %s from awaiting_agent to online (heartbeat received)", payload.WorkspaceID)
}
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_ONLINE", payload.WorkspaceID, map[string]interface{}{
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceOnline), payload.WorkspaceID, map[string]interface{}{
"recovered_from": currentStatus,
})
}
@@ -820,7 +820,7 @@ func (h *RegistryHandler) UpdateCard(c *gin.Context) {
return
}
h.broadcaster.RecordAndBroadcast(c.Request.Context(), "AGENT_CARD_UPDATED", payload.WorkspaceID, map[string]interface{}{
h.broadcaster.RecordAndBroadcast(c.Request.Context(), string(events.EventAgentCardUpdated), payload.WorkspaceID, map[string]interface{}{
"agent_card": payload.AgentCard,
})
@@ -1,23 +1,20 @@
package handlers
// template_files_eic.go — SSH-backed file write for SaaS workspaces
// (EC2-per-workspace). Pairs with the existing Docker-path in templates.go
// (WriteFile) and template_import.go (ReplaceFiles).
// template_files_eic.go — SSH-backed file operations for SaaS workspaces
// (EC2-per-workspace). Pairs with the local-Docker path in templates.go
// (List/Read/Write/Delete) and template_import.go (ReplaceFiles).
//
// Flow for a single file write:
// 1. Generate ephemeral ed25519 keypair (on-disk for ≤ write duration).
// 2. Push the public key via `aws ec2-instance-connect send-ssh-public-key`
// so the target sshd accepts it for the next 60s.
// 3. Open a TLS-tunnelled TCP port via `aws ec2-instance-connect open-tunnel`
// from a local free port → workspace's sshd on 22.
// 4. Pipe content to `ssh ... "install -D -m 0644 /dev/stdin <abs path>"`.
// `install -D` creates any missing parent dirs atomically. File is owned
// by whichever $OSUser we authenticated as (ubuntu by default).
// 5. Close tunnel + wipe keydir.
// Architecture note: every operation goes through `withEICTunnel`, which
// owns the ephemeral-keypair → key-push → tunnel → port-wait dance. Per-
// op helpers (list/read/write/delete) only carry the remote command +
// stdin/stdout shape. This keeps the EIC connection logic in one place
// so a fix to the dance — e.g. PR #2822's `LogLevel=ERROR` shim — only
// touches one helper.
//
// All the AWS calls + ssh tunnel exec go through the same package-level
// func vars defined in terminal.go (openTunnelCmd, sendSSHPublicKey) so
// tests can stub them the same way the terminal tests do.
// Path translation rules: see resolveWorkspaceFilePath. `/configs`
// is the per-runtime managed-config indirection (claude-code → /configs,
// hermes → /home/ubuntu/.hermes); other allow-listed roots (`/home`,
// `/workspace`, `/plugins`) pass through literally.
import (
"bytes"
@@ -32,8 +29,7 @@ import (
)
// workspaceFilePathPrefix maps a runtime name to the absolute base path on
// the workspace EC2 where the Files API's relative paths land. New runtimes
// can be added here without touching handler code.
// the workspace EC2 where the runtime's managed-config dir lives.
//
// Keep these stable — changing the base path for an existing runtime
// without a migration shim will make previously-saved files disappear from
@@ -60,41 +56,104 @@ var workspaceFilePathPrefix = map[string]string{
// those runtimes actually have on disk.
}
func resolveWorkspaceFilePath(runtime, relPath string) (string, error) {
// resolveWorkspaceFilePath translates (runtime, root, relPath) into an
// absolute path on the workspace EC2.
//
// `root="/configs"` (or empty / unrecognized) is treated as the
// runtime's MANAGED-config dir via workspaceFilePathPrefix —
// /home/ubuntu/.hermes for hermes, /configs for claude-code, etc.
// This preserves the v1 ReadFile/WriteFile behavior where the canvas's
// Config tab GETs/PUTs "config.yaml" without specifying a root and
// lands in the runtime's own config dir, even though that dir's
// absolute path differs per runtime.
//
// Any other allow-listed root (`/home`, `/workspace`, `/plugins`) is
// treated as a LITERAL absolute path on the EC2 host. Those roots are
// universal Linux paths that don't need per-runtime indirection.
//
// Restricting the literal pass-through to allowedRoots is the
// security boundary — the handler also gates this same set, so the
// resolver is defence-in-depth: even if a future caller forgets the
// handler-side check, the resolver won't translate `?root=/etc` into
// a real absolute path.
//
// relPath is sanitised by validateRelPath (no absolute, no `..`).
func resolveWorkspaceFilePath(runtime, root, relPath string) (string, error) {
if err := validateRelPath(relPath); err != nil {
return "", err
}
base, ok := workspaceFilePathPrefix[strings.ToLower(strings.TrimSpace(runtime))]
if !ok {
base = "/configs"
}
base := resolveWorkspaceRootPath(runtime, root)
return filepath.Join(base, filepath.Clean(relPath)), nil
}
// eicFileWriteTimeout bounds the whole dance. Key push is <500ms, tunnel
// is 1-2s, ssh + write is <2s. 30s gives headroom for slow pulls without
// hanging the Files API forever under EIC misconfiguration.
const eicFileWriteTimeout = 30 * time.Second
// writeFileViaEIC writes a single file to the workspace EC2 at the
// absolute path that resolveWorkspaceFilePath computed. On success,
// optionally invokes the runtime's reload hook (not implemented yet —
// tracked as follow-up; for today the canvas issues a separate Restart
// after Save).
// resolveWorkspaceRootPath returns the absolute base directory on the
// workspace EC2 for a given (runtime, root) pair, without touching a
// relative file path. Used by listFilesViaEIC to compute the directory
// to walk; resolveWorkspaceFilePath joins this with relPath.
//
// instanceID: AWS EC2 instance id from workspaces.instance_id.
// runtime: used only for path-prefix resolution.
// relPath: the relative path the caller validated (no /, no ..).
// content: file body bytes.
func writeFileViaEIC(ctx context.Context, instanceID, runtime, relPath string, content []byte) error {
// Centralising the runtime-vs-literal indirection here means
// list/read/write/delete agree on what `?root=/configs` means for
// hermes vs claude-code vs an unknown runtime — otherwise list could
// show one directory while read/write target another.
func resolveWorkspaceRootPath(runtime, root string) string {
root = strings.TrimSpace(root)
// "/configs" + empty + unrecognized → runtime's managed-config dir.
// The runtime prefix map is the SSOT for that translation.
if root == "" || root == "/configs" || !allowedRoots[root] {
base, ok := workspaceFilePathPrefix[strings.ToLower(strings.TrimSpace(runtime))]
if !ok {
base = "/configs"
}
return base
}
// Literal universal path (`/home`, `/workspace`, `/plugins`).
return root
}
// eicFileOpTimeout bounds the whole tunnel + ssh dance. Key push is
// <500ms, tunnel is 1-2s, ssh + remote command is <2s for read/write.
// 30s gives headroom for slow EIC pulls + the larger `find` walk that
// listFilesViaEIC issues, without hanging the Files API forever under
// EIC misconfiguration.
const eicFileOpTimeout = 30 * time.Second
// eicFileOpTimeout was historically named eicFileWriteTimeout when the
// only EIC op was writeFile. Keep an alias so any external test that
// pinned the old name still compiles; rename can land as a follow-up
// once we've gone a release without the alias being touched.
//
//nolint:revive // intentional alias for back-compat with prior tests.
const eicFileWriteTimeout = eicFileOpTimeout
// eicSSHSession describes an open EIC tunnel ready for an ssh subprocess.
// Only valid inside the closure passed to withEICTunnel — the underlying
// keypair + tunnel are torn down when the closure returns.
type eicSSHSession struct {
keyPath string
localPort int
osUser string
instanceID string
}
// withEICTunnel sets up an EIC SSH session (ephemeral keypair → push
// → AWS open-tunnel → wait-for-port), invokes fn with a session handle,
// and tears everything down on return. The caller is responsible for
// applying the per-op context.WithTimeout before calling — this helper
// only owns the EIC dance, not the operation budget, so a caller that
// needs a different timeout (e.g. a large bulk import) doesn't have to
// fight a hard-coded one.
//
// All AWS calls go through the package-level func vars in terminal.go
// (sendSSHPublicKey, openTunnelCmd) so tests can stub them the same way
// terminal_test.go does. The whole helper is also assigned to a
// `var` (`withEICTunnel`) so handler-dispatch tests can stub the entire
// dance instead of having to wire up a fake tunnel + fake ssh server.
var withEICTunnel = realWithEICTunnel
func realWithEICTunnel(ctx context.Context, instanceID string, fn func(s eicSSHSession) error) error {
if instanceID == "" {
return fmt.Errorf("workspace has no instance_id — not a SaaS EC2 workspace")
}
absPath, err := resolveWorkspaceFilePath(runtime, relPath)
if err != nil {
return fmt.Errorf("invalid path: %w", err)
}
osUser := os.Getenv("WORKSPACE_EC2_OS_USER")
if osUser == "" {
osUser = "ubuntu"
@@ -104,11 +163,7 @@ func writeFileViaEIC(ctx context.Context, instanceID, runtime, relPath string, c
region = "us-east-2"
}
ctx, cancel := context.WithTimeout(ctx, eicFileWriteTimeout)
defer cancel()
// Ephemeral keypair.
keyDir, err := os.MkdirTemp("", "molecule-filewrite-*")
keyDir, err := os.MkdirTemp("", "molecule-eic-*")
if err != nil {
return fmt.Errorf("keydir mkdir: %w", err)
}
@@ -116,7 +171,7 @@ func writeFileViaEIC(ctx context.Context, instanceID, runtime, relPath string, c
keyPath := keyDir + "/id"
if out, kerr := exec.CommandContext(ctx, "ssh-keygen",
"-t", "ed25519", "-f", keyPath, "-N", "", "-q",
"-C", "molecule-filewrite",
"-C", "molecule-eic",
).CombinedOutput(); kerr != nil {
return fmt.Errorf("ssh-keygen: %w (%s)", kerr, strings.TrimSpace(string(out)))
}
@@ -125,24 +180,21 @@ func writeFileViaEIC(ctx context.Context, instanceID, runtime, relPath string, c
return fmt.Errorf("read pubkey: %w", err)
}
// 1. Push key.
if err := sendSSHPublicKey(ctx, region, instanceID, osUser, strings.TrimSpace(string(pubKey))); err != nil {
return fmt.Errorf("send-ssh-public-key: %w", err)
}
// 2. Open tunnel on an OS-picked free port.
localPort, err := pickFreePort()
if err != nil {
return fmt.Errorf("pick free port: %w", err)
}
opts := eicSSHOptions{
tunnel := openTunnelCmd(eicSSHOptions{
InstanceID: instanceID,
OSUser: osUser,
Region: region,
LocalPort: localPort,
PrivateKeyPath: keyPath,
}
tunnel := openTunnelCmd(opts)
})
tunnel.Env = os.Environ()
if err := tunnel.Start(); err != nil {
return fmt.Errorf("open-tunnel start: %w", err)
@@ -157,183 +209,330 @@ func writeFileViaEIC(ctx context.Context, instanceID, runtime, relPath string, c
return fmt.Errorf("tunnel never listened: %w", err)
}
// 3. SSH + install -D. `install` creates any missing parent dirs and
// writes the file atomically via temp-file-rename. Permissions 0644
// match the existing tar-unpack defaults on the Docker path.
//
// `sudo -n` (non-interactive) prefix: the canonical containerized
// workspace layout puts /configs at the root, owned by root because
// cloud-init runs as root (see
// molecule-controlplane/internal/provisioner/userdata_containerized.go).
// SSH-as-ubuntu can't write into /configs without escalation.
// Ubuntu has passwordless sudo on EC2 by default; sudo -n fails fast
// (no prompt) if that ever changes, surfacing a clean error instead
// of a hang. The hermes path /home/ubuntu/.hermes is ubuntu-owned
// and doesn't strictly need sudo, but using it uniformly avoids
// per-runtime branching here.
//
// The remote command is fully deterministic — no user-controlled
// input reaches a shell eval (absPath is built from a map + Clean()).
sshArgs := []string{
"-i", keyPath,
return fn(eicSSHSession{
keyPath: keyPath,
localPort: localPort,
osUser: osUser,
instanceID: instanceID,
})
}
// sshArgs returns the standard ssh CLI args for an EIC session pointed
// at the local tunnel port + a single remote command string.
//
// `LogLevel=ERROR` silences the benign "Warning: Permanently added
// '[127.0.0.1]:NNNNN' to known hosts" notice that ssh emits on every
// fresh tunnel connection. Without this, the notice lands on stderr
// and fools the read/list "empty stdout + empty stderr → not found"
// classifiers into thinking the warning is a real ssh-layer error → 500
// instead of 404 (Hermes config.yaml load, hongming tenant, 2026-05-05
// 02:38; PR #2822). Real auth/tunnel errors stay visible because they're
// emitted at ERROR level.
//
// Originally each helper assembled its own ssh args inline, so PR #2822's
// LogLevel=ERROR fix had to be applied to every copy. Centralising here
// means future ssh-option tweaks only land in one place.
func (s eicSSHSession) sshArgs(remoteCommand string) []string {
return []string{
"-i", s.keyPath,
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
// LogLevel=ERROR silences the benign "Warning: Permanently
// added '[127.0.0.1]:NNNNN' to known hosts" notice that ssh
// emits on every fresh tunnel connection. Without this, the
// notice lands on stderr and fools readFileViaEIC's "empty
// stdout + empty stderr → file not found" classifier into
// thinking the warning is a real ssh-layer error → 500
// instead of 404 (Hermes config.yaml load, hongming tenant,
// 2026-05-05 02:38). Real auth/tunnel errors stay visible
// because they're emitted at ERROR level.
"-o", "LogLevel=ERROR",
"-o", "ServerAliveInterval=15",
"-p", fmt.Sprintf("%d", localPort),
fmt.Sprintf("%s@127.0.0.1", osUser),
fmt.Sprintf("sudo -n install -D -m 0644 /dev/stdin %s", shellQuote(absPath)),
"-p", fmt.Sprintf("%d", s.localPort),
fmt.Sprintf("%s@127.0.0.1", s.osUser),
remoteCommand,
}
sshCmd := exec.CommandContext(ctx, "ssh", sshArgs...)
sshCmd.Env = os.Environ()
sshCmd.Stdin = bytes.NewReader(content)
var stderr bytes.Buffer
sshCmd.Stderr = &stderr
if err := sshCmd.Run(); err != nil {
return fmt.Errorf("ssh install: %w (%s)", err, strings.TrimSpace(stderr.String()))
}
// buildInstallShell returns the remote command for atomically writing
// `/dev/stdin` to absPath with mode 0644 via `sudo -n install -D`.
// `install -D` creates any missing parent dirs and writes via
// temp-file-rename (atomic). Pure function for direct testability —
// the only variable input (absPath) is shellQuote-wrapped to defeat
// any shell metachar in a future caller's path.
func buildInstallShell(absPath string) string {
return fmt.Sprintf("sudo -n install -D -m 0644 /dev/stdin %s", shellQuote(absPath))
}
// buildCatShell returns the remote command for reading absPath and
// swallowing missing-file stderr (so the empty-stdout + non-zero-exit
// case is unambiguous → os.ErrNotExist at the caller).
func buildCatShell(absPath string) string {
return fmt.Sprintf("sudo -n cat %s 2>/dev/null", shellQuote(absPath))
}
// buildRmShell returns the remote command for `sudo -n rm -f` against
// absPath. `-f` (not `-rf`) is intentional — directory removal needs
// its own explicit endpoint if/when the canvas grows that affordance,
// and `-rf` would let a misclassified directory entry trigger a
// recursive delete.
func buildRmShell(absPath string) string {
return fmt.Sprintf("sudo -n rm -f %s", shellQuote(absPath))
}
// buildFindShell returns the remote command for enumerating files
// under listPath up to maxDepth, emitting `TYPE|SIZE|REL_PATH` lines
// (matches the local-Docker container path's parser exactly).
//
// `2>/dev/null` swallows find's "No such file" error so a missing
// listing root surfaces as empty stdout (handler returns []) rather
// than 500.
//
// `stat -c %s` is GNU coreutils; `stat -f %z` is BSD. Try GNU first,
// fall back to BSD, then 0 — same shape the local-Docker `sh -c`
// version uses so a future cross-runtime fleet (Alpine vs Ubuntu)
// doesn't regress.
//
// Hidden / cache dir pruning matches the container path: .git,
// __pycache__, node_modules, .DS_Store. Without these the tree drowns
// in transient artefacts on a /workspace listing.
func buildFindShell(listPath string, maxDepth int) string {
return fmt.Sprintf(
`sudo -n find %s -maxdepth %d -not -path '*/.git/*' -not -path '*/__pycache__/*' -not -path '*/node_modules/*' -not -name .DS_Store 2>/dev/null | while IFS= read -r f; do `+
`rel="${f#%s/}"; [ "$rel" = %s ] && continue; [ -z "$rel" ] && continue; `+
`if [ -d "$f" ]; then echo "d|0|$rel"; else `+
`s=$(stat -c %%s "$f" 2>/dev/null || stat -f %%z "$f" 2>/dev/null || echo 0); echo "f|$s|$rel"; `+
`fi; done`,
shellQuote(listPath), maxDepth, shellQuote(listPath), shellQuote(listPath),
)
}
// parseFindOutput parses TYPE|SIZE|REL_PATH lines emitted by
// buildFindShell into eicFileEntry rows. Whitespace-only lines and
// malformed rows are silently skipped — the same behaviour as the
// local-Docker container parser for symmetric output.
func parseFindOutput(raw []byte) []eicFileEntry {
files := make([]eicFileEntry, 0)
for _, line := range strings.Split(string(raw), "\n") {
parts := strings.SplitN(line, "|", 3)
if len(parts) != 3 || parts[2] == "" {
continue
}
var size int64
fmt.Sscanf(parts[1], "%d", &size)
files = append(files, eicFileEntry{
Path: parts[2],
Size: size,
Dir: parts[0] == "d",
})
}
log.Printf("writeFileViaEIC: ws instance=%s runtime=%s wrote %d bytes → %s",
instanceID, runtime, len(content), absPath)
return nil
return files
}
// shellQuote wraps a value in single quotes + escapes embedded single
// quotes for POSIX sh. Used for the sole piece of variable data in the
// remote ssh command. (absPath is already built from a map + Clean() so
// traversal is blocked regardless; this is defence-in-depth against
// future refactor that might accept user paths here.)
// quotes for POSIX sh. Used for the variable parts of remote ssh
// commands (absolute paths). The paths are already built from a
// validated allowlist + Clean(), so traversal is blocked regardless;
// this is defence-in-depth against a future refactor that might accept
// user paths directly here.
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
// writeFileViaEIC writes a single file to the workspace EC2 at the
// absolute path that resolveWorkspaceFilePath computed. On success,
// optionally invokes the runtime's reload hook (not implemented yet —
// tracked as follow-up; for today the canvas issues a separate Restart
// after Save).
//
// `install -D` creates any missing parent dirs and writes atomically
// via temp-file-rename. Permissions 0644 match the existing tar-unpack
// defaults on the Docker path.
//
// `sudo -n` (non-interactive) prefix: the canonical containerized
// workspace layout puts /configs at the root, owned by root because
// cloud-init runs as root (see
// molecule-controlplane/internal/provisioner/userdata_containerized.go).
// SSH-as-ubuntu can't write into /configs without escalation. Ubuntu
// has passwordless sudo on EC2 by default; sudo -n fails fast (no
// prompt) if that ever changes, surfacing a clean error instead of a
// hang. The hermes path /home/ubuntu/.hermes is ubuntu-owned and
// doesn't strictly need sudo, but using it uniformly avoids per-runtime
// branching here.
func writeFileViaEIC(ctx context.Context, instanceID, runtime, root, relPath string, content []byte) error {
absPath, err := resolveWorkspaceFilePath(runtime, root, relPath)
if err != nil {
return fmt.Errorf("invalid path: %w", err)
}
ctx, cancel := context.WithTimeout(ctx, eicFileOpTimeout)
defer cancel()
return withEICTunnel(ctx, instanceID, func(s eicSSHSession) error {
sshCmd := exec.CommandContext(ctx, "ssh", s.sshArgs(buildInstallShell(absPath))...)
sshCmd.Env = os.Environ()
sshCmd.Stdin = bytes.NewReader(content)
var stderr bytes.Buffer
sshCmd.Stderr = &stderr
if err := sshCmd.Run(); err != nil {
return fmt.Errorf("ssh install: %w (%s)", err, strings.TrimSpace(stderr.String()))
}
log.Printf("writeFileViaEIC: ws instance=%s runtime=%s root=%s wrote %d bytes → %s",
instanceID, runtime, root, len(content), absPath)
return nil
})
}
// readFileViaEIC reads a single file from the workspace EC2 at the
// absolute path that resolveWorkspaceFilePath computes. Mirrors
// writeFileViaEIC end-to-end (ephemeral keypair, EIC tunnel, ssh) so
// canvas's Config tab can GET back what it just PUT. Pre-fix the GET
// path (templates.go ReadFile) only handled local Docker containers
// + a host-side template fallback; SaaS workspaces (EC2-per-workspace)
// always 404'd because neither handles their on-EC2 layout.
// writeFileViaEIC (ephemeral keypair, EIC tunnel, ssh) so the canvas's
// Config tab can GET back what it just PUT.
//
// Returns ("", os.ErrNotExist) when the remote path doesn't exist so
// the handler can map it to HTTP 404 cleanly. Other errors propagate.
func readFileViaEIC(ctx context.Context, instanceID, runtime, relPath string) ([]byte, error) {
if instanceID == "" {
return nil, fmt.Errorf("workspace has no instance_id — not a SaaS EC2 workspace")
}
absPath, err := resolveWorkspaceFilePath(runtime, relPath)
//
// `sudo -n cat`: /configs is root-owned (same reason writeFileViaEIC
// needs sudo). The path is built from a validated map + Clean(), so no
// user-controlled string reaches the shell here. `2>/dev/null` swallows
// `cat: ...: No such file` so the missing-file case returns empty
// stdout + non-zero exit, which we translate to os.ErrNotExist.
func readFileViaEIC(ctx context.Context, instanceID, runtime, root, relPath string) ([]byte, error) {
absPath, err := resolveWorkspaceFilePath(runtime, root, relPath)
if err != nil {
return nil, fmt.Errorf("invalid path: %w", err)
}
osUser := os.Getenv("WORKSPACE_EC2_OS_USER")
if osUser == "" {
osUser = "ubuntu"
}
region := os.Getenv("AWS_REGION")
if region == "" {
region = "us-east-2"
}
ctx, cancel := context.WithTimeout(ctx, eicFileWriteTimeout)
ctx, cancel := context.WithTimeout(ctx, eicFileOpTimeout)
defer cancel()
keyDir, err := os.MkdirTemp("", "molecule-fileread-*")
if err != nil {
return nil, fmt.Errorf("keydir mkdir: %w", err)
}
defer func() { _ = os.RemoveAll(keyDir) }()
keyPath := keyDir + "/id"
if out, kerr := exec.CommandContext(ctx, "ssh-keygen",
"-t", "ed25519", "-f", keyPath, "-N", "", "-q",
"-C", "molecule-fileread",
).CombinedOutput(); kerr != nil {
return nil, fmt.Errorf("ssh-keygen: %w (%s)", kerr, strings.TrimSpace(string(out)))
}
pubKey, err := os.ReadFile(keyPath + ".pub")
if err != nil {
return nil, fmt.Errorf("read pubkey: %w", err)
}
if err := sendSSHPublicKey(ctx, region, instanceID, osUser, strings.TrimSpace(string(pubKey))); err != nil {
return nil, fmt.Errorf("send-ssh-public-key: %w", err)
}
localPort, err := pickFreePort()
if err != nil {
return nil, fmt.Errorf("pick free port: %w", err)
}
tunnel := openTunnelCmd(eicSSHOptions{
InstanceID: instanceID,
OSUser: osUser,
Region: region,
LocalPort: localPort,
PrivateKeyPath: keyPath,
var out []byte
runErr := withEICTunnel(ctx, instanceID, func(s eicSSHSession) error {
sshCmd := exec.CommandContext(ctx, "ssh", s.sshArgs(buildCatShell(absPath))...)
sshCmd.Env = os.Environ()
var stdout, stderr bytes.Buffer
sshCmd.Stdout = &stdout
sshCmd.Stderr = &stderr
err := sshCmd.Run()
out = stdout.Bytes()
if err != nil {
// `cat` returns 1 on missing file; with 2>/dev/null we have no
// stderr distinguisher. Treat empty-stdout + empty-stderr +
// non-zero exit as not-found rather than a tunnel/auth error
// (those usually produce stderr from ssh itself, not from the
// remote command).
if len(out) == 0 && stderr.Len() == 0 {
return os.ErrNotExist
}
return fmt.Errorf("ssh cat: %w (%s)", err, strings.TrimSpace(stderr.String()))
}
log.Printf("readFileViaEIC: ws instance=%s runtime=%s root=%s read %d bytes ← %s",
instanceID, runtime, root, len(out), absPath)
return nil
})
tunnel.Env = os.Environ()
if err := tunnel.Start(); err != nil {
return nil, fmt.Errorf("open-tunnel start: %w", err)
}
defer func() {
if tunnel.Process != nil {
_ = tunnel.Process.Kill()
}
_ = tunnel.Wait()
}()
if err := waitForPort(ctx, "127.0.0.1", localPort, 10*time.Second); err != nil {
return nil, fmt.Errorf("tunnel never listened: %w", err)
}
// `sudo -n cat`: /configs is root-owned by cloud-init (same reason
// writeFileViaEIC needs sudo to install). The path is built from a
// validated map + Clean(), so no user-controlled string reaches the
// shell here. `2>/dev/null` swallows `cat: ...: No such file` so
// the missing-file case returns empty stdout + non-zero exit, which
// we translate to os.ErrNotExist below.
sshCmd := exec.CommandContext(ctx, "ssh",
"-i", keyPath,
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
// LogLevel=ERROR silences the benign "Warning: Permanently
// added '[127.0.0.1]:NNNNN' to known hosts" notice that ssh
// emits on every fresh tunnel connection. Without this, the
// notice lands on stderr and fools readFileViaEIC's "empty
// stdout + empty stderr → file not found" classifier into
// thinking the warning is a real ssh-layer error → 500
// instead of 404 (Hermes config.yaml load, hongming tenant,
// 2026-05-05 02:38). Real auth/tunnel errors stay visible
// because they're emitted at ERROR level.
"-o", "LogLevel=ERROR",
"-o", "ServerAliveInterval=15",
"-p", fmt.Sprintf("%d", localPort),
fmt.Sprintf("%s@127.0.0.1", osUser),
fmt.Sprintf("sudo -n cat %s 2>/dev/null", shellQuote(absPath)),
)
sshCmd.Env = os.Environ()
var stdout, stderr bytes.Buffer
sshCmd.Stdout = &stdout
sshCmd.Stderr = &stderr
runErr := sshCmd.Run()
out := stdout.Bytes()
if runErr != nil {
// `cat` returns 1 on missing file; with 2>/dev/null we have no
// stderr distinguisher. Treat empty-stdout + non-zero exit as
// not-found rather than a tunnel/auth error (those usually
// produce stderr from ssh itself, not from the remote command).
if len(out) == 0 && stderr.Len() == 0 {
return nil, os.ErrNotExist
}
return nil, fmt.Errorf("ssh cat: %w (%s)", runErr, strings.TrimSpace(stderr.String()))
return nil, runErr
}
log.Printf("readFileViaEIC: ws instance=%s runtime=%s read %d bytes ← %s",
instanceID, runtime, len(out), absPath)
return out, nil
}
// eicFileEntry is the wire shape returned by listFilesViaEIC. It
// matches the inline `fileEntry` in templates.go::ListFiles so the
// handler can emit either path's output without a translation layer.
type eicFileEntry struct {
Path string `json:"path"`
Size int64 `json:"size"`
Dir bool `json:"dir"`
}
// listFilesViaEIC enumerates files under <root>/<sub> on the workspace
// EC2 host, up to the given depth, returning entries with paths
// relative to the listing root (matching the local-Docker path's
// output). Closes the symmetry gap that left ListFiles silently
// returning [] for SaaS workspaces — see issue #2999.
//
// Output line format: TYPE|SIZE|REL_PATH (matches the container's find
// shell so the parser is identical). `find -maxdepth N` traverses up
// to N levels; the canvas requests depth=1 by default and re-fetches
// when the user expands a directory.
//
// Pruning: same hidden / cache dirs as the container path (.git,
// __pycache__, node_modules, .DS_Store) so the canvas's tree doesn't
// drown in transient artefacts.
//
// `sudo -n` matches the read/write paths — even though the universal
// roots (/home, /workspace, /plugins) are typically ubuntu-owned and
// don't need it, /configs and runtime-prefix dirs do (root-owned by
// cloud-init), and using sudo uniformly avoids per-root branching.
func listFilesViaEIC(ctx context.Context, instanceID, runtime, root, sub string, depth int) ([]eicFileEntry, error) {
if sub != "" {
if err := validateRelPath(sub); err != nil {
return nil, fmt.Errorf("invalid sub: %w", err)
}
}
if depth < 1 {
depth = 1
}
if depth > 5 {
depth = 5
}
listPath := resolveWorkspaceRootPath(runtime, root)
if sub != "" {
listPath = filepath.Join(listPath, filepath.Clean(sub))
}
ctx, cancel := context.WithTimeout(ctx, eicFileOpTimeout)
defer cancel()
var rawOutput []byte
runErr := withEICTunnel(ctx, instanceID, func(s eicSSHSession) error {
sshCmd := exec.CommandContext(ctx, "ssh", s.sshArgs(buildFindShell(listPath, depth))...)
sshCmd.Env = os.Environ()
var stdout, stderr bytes.Buffer
sshCmd.Stdout = &stdout
sshCmd.Stderr = &stderr
if err := sshCmd.Run(); err != nil {
// Empty stdout + empty stderr after we swallowed find's
// own error stream means the listing root genuinely
// doesn't exist on this workspace — return an empty
// slice rather than a 500. Real ssh/tunnel errors emit
// to stderr at LogLevel=ERROR.
if stdout.Len() == 0 && stderr.Len() == 0 {
rawOutput = nil
return nil
}
return fmt.Errorf("ssh find: %w (%s)", err, strings.TrimSpace(stderr.String()))
}
rawOutput = stdout.Bytes()
return nil
})
if runErr != nil {
return nil, runErr
}
files := parseFindOutput(rawOutput)
log.Printf("listFilesViaEIC: ws instance=%s runtime=%s root=%s sub=%s depth=%d → %d entries from %s",
instanceID, runtime, root, sub, depth, len(files), listPath)
return files, nil
}
// deleteFileViaEIC removes a single file from the workspace EC2.
// Returns nil for both "deleted" and "didn't exist" — `rm -f` doesn't
// distinguish, and the canvas's delete-then-refresh flow doesn't need
// it to.
//
// Symmetry note: pre-fix DeleteFile (templates.go:514) had no EIC
// branch, so right-click delete on a SaaS workspace would fall through
// to the local-Docker path, find no container (dockerCli is nil on
// SaaS), and try the ephemeral-volume path which itself only handles
// local Docker volumes. Net effect: silent no-op. Closing this gap is
// part of issue #2999.
func deleteFileViaEIC(ctx context.Context, instanceID, runtime, root, relPath string) error {
absPath, err := resolveWorkspaceFilePath(runtime, root, relPath)
if err != nil {
return fmt.Errorf("invalid path: %w", err)
}
ctx, cancel := context.WithTimeout(ctx, eicFileOpTimeout)
defer cancel()
return withEICTunnel(ctx, instanceID, func(s eicSSHSession) error {
sshCmd := exec.CommandContext(ctx, "ssh", s.sshArgs(buildRmShell(absPath))...)
sshCmd.Env = os.Environ()
var stderr bytes.Buffer
sshCmd.Stderr = &stderr
if err := sshCmd.Run(); err != nil {
return fmt.Errorf("ssh rm: %w (%s)", err, strings.TrimSpace(stderr.String()))
}
log.Printf("deleteFileViaEIC: ws instance=%s runtime=%s root=%s removed %s",
instanceID, runtime, root, absPath)
return nil
})
}
@@ -0,0 +1,303 @@
package handlers
// template_files_eic_dispatch_test.go — handler-level tests for the
// EIC dispatch added in PR-A of issue #2999. Pre-PR-A, ListFiles and
// DeleteFile silently fell through to the local-Docker path on SaaS
// workspaces (where dockerCli is nil) and returned [] / silent no-op.
// These tests pin the new behavior:
//
// 1. instance_id != "" → handler invokes the EIC helper
// 2. EIC success → 200 with the helper's payload
// 3. EIC error → 500 (does NOT fall through to local-Docker /
// template-dir, which would mask the real failure)
// 4. instance_id == "" → existing local-Docker / template-dir
// fallback (back-compat with self-hosted operators)
//
// Stubs `withEICTunnel` so the entire EIC dance (keypair, AWS calls,
// tunnel, ssh) is replaced with a fake closure that yields a captured
// session — lets the test capture what the inner closure would have
// done without spinning up a real sshd. The test for the actual
// remote shell shapes lives in template_files_eic_shells_test.go
// (pure-function tests on buildFindShell / buildInstallShell etc).
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
)
// stubWithEICTunnel replaces the package-level withEICTunnel with a
// closure that records its inputs and runs fn against a fake session,
// returning fnErr from the inner fn if non-nil. Restores the original
// on test cleanup.
func stubWithEICTunnel(t *testing.T, fnErr error) (calls *[]string) {
t.Helper()
captured := []string{}
calls = &captured
prev := withEICTunnel
withEICTunnel = func(ctx context.Context, instanceID string, fn func(s eicSSHSession) error) error {
captured = append(captured, instanceID)
// Hand the closure a sentinel session so any code that pulls
// session fields gets deterministic non-empty values. The
// closure's exec.Command call will fail at runtime because no
// real ssh exists for instanceID="i-test"; but most
// dispatch-tests inject fnErr directly to skip that.
return fnErr
}
t.Cleanup(func() { withEICTunnel = prev })
return calls
}
// stubWithEICTunnelReturning is like stubWithEICTunnel but lets the
// test substitute the inner fn entirely so it can populate `out` /
// return shaped errors without invoking the real ssh closure.
func stubWithEICTunnelReturning(t *testing.T, replacement func(s eicSSHSession) error) (calls *[]string) {
t.Helper()
captured := []string{}
calls = &captured
prev := withEICTunnel
withEICTunnel = func(ctx context.Context, instanceID string, _ func(s eicSSHSession) error) error {
captured = append(captured, instanceID)
return replacement(eicSSHSession{instanceID: instanceID, osUser: "ubuntu", localPort: 12345, keyPath: "/tmp/k"})
}
t.Cleanup(func() { withEICTunnel = prev })
return calls
}
// ---- ListFiles EIC dispatch ----
// TestListFiles_EICDispatch_Success: a workspace with instance_id set
// must route to listFilesViaEIC, NOT to local-Docker / template-dir.
// Verifies the handler hands the EIC helper's output back as JSON.
//
// Until PR-A this test would fail no matter what mocks were in place —
// the dispatch branch did not exist.
func TestListFiles_EICDispatch_Success(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
mock.ExpectQuery(`SELECT name, COALESCE\(instance_id, ''\), COALESCE\(runtime, ''\) FROM workspaces WHERE id =`).
WithArgs("ws-eic").
WillReturnRows(sqlmock.NewRows([]string{"name", "instance_id", "runtime"}).
AddRow("My Agent", "i-test", "claude-code"))
// The package-level withEICTunnel stub doesn't get to set the
// listFilesViaEIC outparam, so we have to override the helper at
// a higher level. Instead, we stub withEICTunnel to *return* the
// inner closure's err — but we can't reach the byte-output path.
// Use the dedicated stubWithEICTunnelReturning + intercept ssh:
// since the tunnel stub doesn't run the closure's ssh exec at all
// when we replace the inner fn, the helper's `rawOutput` stays
// nil and parseFindOutput returns []. Sufficient for "200 + empty"
// dispatch verification.
stubWithEICTunnelReturning(t, func(s eicSSHSession) error {
return nil // skip the real ssh; outer rawOutput stays nil → []
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-eic"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-eic/files?root=/configs", nil)
(&TemplatesHandler{}).ListFiles(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var got []map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("response not JSON array: %v (body=%s)", err, w.Body.String())
}
// EIC stub returned no output → empty list. The point of this
// assertion is "200 with [] from EIC", not "fell through to host
// template fallback which would 200 with []" — to discriminate,
// we ALSO assert mock expectations were met (proving the new SQL
// shape was queried) AND the local-Docker fallback path can't
// have run (handler.docker is nil here, so findContainer returns
// "" and the only paths that reach 200 are EIC or template-dir;
// template-dir requires a non-empty configsDir which we left at
// "" via the zero-value handler).
if got == nil {
t.Errorf("expected JSON array (even if empty); got null")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
// TestListFiles_EICDispatch_Error: a real EIC failure (network blip,
// AWS API throttle, sshd down) must surface as 500, NOT silently fall
// through to the local-Docker path which would mask the failure as
// "0 files" — which is the exact UX symptom the PR-A bug report cites.
func TestListFiles_EICDispatch_Error(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
mock.ExpectQuery(`SELECT name, COALESCE\(instance_id, ''\), COALESCE\(runtime, ''\) FROM workspaces WHERE id =`).
WithArgs("ws-eic-err").
WillReturnRows(sqlmock.NewRows([]string{"name", "instance_id", "runtime"}).
AddRow("My Agent", "i-test", "claude-code"))
stubWithEICTunnel(t, errors.New("eic open-tunnel: timeout"))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-eic-err"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-eic-err/files?root=/home", nil)
(&TemplatesHandler{}).ListFiles(c)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "failed to list files") {
t.Errorf("error body should describe ListFiles failure; got %s", w.Body.String())
}
}
// TestListFiles_EICBranch_NotTakenForSelfHosted: workspaces with no
// instance_id (self-hosted, local-Docker path) MUST NOT enter the EIC
// branch. Stubs withEICTunnel to fail loudly if it's called — the
// stub being invoked is itself the assertion failure.
func TestListFiles_EICBranch_NotTakenForSelfHosted(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
mock.ExpectQuery(`SELECT name, COALESCE\(instance_id, ''\), COALESCE\(runtime, ''\) FROM workspaces WHERE id =`).
WithArgs("ws-local").
WillReturnRows(sqlmock.NewRows([]string{"name", "instance_id", "runtime"}).
AddRow("Local Agent", "", ""))
prev := withEICTunnel
withEICTunnel = func(ctx context.Context, instanceID string, fn func(s eicSSHSession) error) error {
t.Errorf("withEICTunnel called for self-hosted workspace (instance_id=''); EIC branch must be gated on non-empty instance_id")
return errors.New("should not be called")
}
defer func() { withEICTunnel = prev }()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-local"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-local/files", nil)
(&TemplatesHandler{configsDir: t.TempDir()}).ListFiles(c)
// Don't pin the response code here — the local path's behavior is
// covered by TestListFiles_FallbackToHost_NoTemplate. Just confirm
// EIC wasn't called.
}
// ---- DeleteFile EIC dispatch ----
// TestDeleteFile_EICDispatch_Success: same shape as ListFiles —
// instance_id != "" routes to deleteFileViaEIC and returns 200 on
// success. Pre-PR-A right-click delete on a SaaS workspace silently
// no-op'd because findContainer returned "" and the ephemeral-volume
// fallback only handles local Docker volumes.
func TestDeleteFile_EICDispatch_Success(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
mock.ExpectQuery(`SELECT name, COALESCE\(instance_id, ''\), COALESCE\(runtime, ''\) FROM workspaces WHERE id =`).
WithArgs("ws-eic-del").
WillReturnRows(sqlmock.NewRows([]string{"name", "instance_id", "runtime"}).
AddRow("My Agent", "i-test", "claude-code"))
stubWithEICTunnel(t, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{
{Key: "id", Value: "ws-eic-del"},
{Key: "path", Value: "old.txt"},
}
c.Request = httptest.NewRequest("DELETE", "/workspaces/ws-eic-del/files/old.txt", nil)
(&TemplatesHandler{}).DeleteFile(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), `"deleted"`) {
t.Errorf("expected status:deleted; got %s", w.Body.String())
}
}
func TestDeleteFile_EICDispatch_Error(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
mock.ExpectQuery(`SELECT name, COALESCE\(instance_id, ''\), COALESCE\(runtime, ''\) FROM workspaces WHERE id =`).
WithArgs("ws-eic-del-err").
WillReturnRows(sqlmock.NewRows([]string{"name", "instance_id", "runtime"}).
AddRow("My Agent", "i-test", "hermes"))
stubWithEICTunnel(t, errors.New("ssh rm: connection refused"))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{
{Key: "id", Value: "ws-eic-del-err"},
{Key: "path", Value: "old.txt"},
}
c.Request = httptest.NewRequest("DELETE", "/workspaces/ws-eic-del-err/files/old.txt", nil)
(&TemplatesHandler{}).DeleteFile(c)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
}
}
// TestListFiles_RootValidation: the handler must reject roots outside
// the allowlist BEFORE any DB query (otherwise a bad root would burn
// a tunnel + EIC call to discover what a 400 already knows). Critical
// security guard — without it `?root=/etc` would translate via the
// resolver's literal-pass-through. Let me prove the gate exists by
// driving an out-of-allowlist root and asserting 400 + no DB query.
func TestListFiles_RootValidation(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-x"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-x/files?root=/etc", nil)
(&TemplatesHandler{}).ListFiles(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for /etc root, got %d: %s", w.Code, w.Body.String())
}
}
// TestDeleteFile_RootValidation mirrors the ListFiles guard. PR-A
// added ?root= handling to DeleteFile so the canvas's right-click
// delete works on any root (not just /configs) — that means the
// allowlist guard has to be present here too, otherwise an unsafe
// root flows straight into the resolver.
func TestDeleteFile_RootValidation(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{
{Key: "id", Value: "ws-x"},
{Key: "path", Value: "f.txt"},
}
c.Request = httptest.NewRequest("DELETE", "/workspaces/ws-x/files/f.txt?root=/etc", nil)
(&TemplatesHandler{}).DeleteFile(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for /etc root, got %d: %s", w.Code, w.Body.String())
}
}
@@ -0,0 +1,200 @@
package handlers
// template_files_eic_shells_test.go — pure-function tests for the
// remote shell builders + parser. Factored out of the EIC helpers so
// the wire shape can be pinned without standing up a real EIC tunnel
// or sshd. If a future edit changes the find/install/cat/rm shell in
// a way that drifts from the local-Docker container path, these tests
// catch it before staging.
import (
"strings"
"testing"
)
// TestBuildInstallShell pins the write-side remote command. `install`
// (not `cp`/`tee`) is load-bearing — it creates parent dirs (-D) and
// writes atomically via temp-file-rename. Permissions 0644 match the
// local-Docker tar-unpack defaults so a save → restart → save → restart
// cycle doesn't flip-flop file modes per backend.
func TestBuildInstallShell(t *testing.T) {
got := buildInstallShell("/configs/config.yaml")
wants := []string{
"sudo -n", // privilege escalation for root-owned /configs
"install -D", // creates parent dirs
"-m 0644", // permission contract
"/dev/stdin", // pipe-from-ssh source
"'/configs/config.yaml'", // shell-quoted destination
}
for _, w := range wants {
if !strings.Contains(got, w) {
t.Errorf("buildInstallShell missing %q in: %s", w, got)
}
}
}
// TestBuildCatShell pins the read-side remote command. `2>/dev/null`
// is load-bearing: without it the missing-file case emits "cat: ...:
// No such file" to stderr, and the helper's "empty stdout + empty
// stderr → os.ErrNotExist" classifier fires the wrong branch (500
// instead of 404). The tunnel-warning silencer (LogLevel=ERROR in
// sshArgs) handles the ssh side; this one handles the remote-cmd side.
func TestBuildCatShell(t *testing.T) {
got := buildCatShell("/home/ubuntu/.hermes/config.yaml")
wants := []string{
"sudo -n",
"cat",
"'/home/ubuntu/.hermes/config.yaml'",
"2>/dev/null", // missing-file → empty stdout + non-zero exit
}
for _, w := range wants {
if !strings.Contains(got, w) {
t.Errorf("buildCatShell missing %q in: %s", w, got)
}
}
}
// TestBuildRmShell pins `rm -f`, NOT `rm -rf`. A misclassified
// directory entry passing through the validator must NOT trigger a
// recursive delete. Directory removal needs its own explicit endpoint
// when/if the canvas grows that affordance.
func TestBuildRmShell(t *testing.T) {
got := buildRmShell("/configs/dead.yaml")
wants := []string{"sudo -n", "rm -f", "'/configs/dead.yaml'"}
for _, w := range wants {
if !strings.Contains(got, w) {
t.Errorf("buildRmShell missing %q in: %s", w, got)
}
}
// Negative assertion: NEVER emit -rf.
if strings.Contains(got, "rm -rf") {
t.Errorf("buildRmShell uses -rf, must use -f only: %s", got)
}
}
// TestBuildFindShell pins the listing-side remote command — it must
// match the local-Docker path's parser shape (TYPE|SIZE|REL_PATH per
// line) AND prune the same hidden / cache directories. If either
// side drifts, a /workspace listing on EC2 either drowns in node_modules
// noise (pruning regression) or drops files entirely (parser shape
// regression).
func TestBuildFindShell(t *testing.T) {
got := buildFindShell("/workspace", 2)
wants := []string{
"sudo -n find",
"'/workspace'",
"-maxdepth 2",
// Matches local-Docker container path; without these the EC2
// listing fills with VCS/build artefacts.
"-not -path '*/.git/*'",
"-not -path '*/__pycache__/*'",
"-not -path '*/node_modules/*'",
"-not -name .DS_Store",
"2>/dev/null", // missing-root → empty stdout + non-zero exit
// Wire shape — emit "TYPE|SIZE|REL_PATH" so parseFindOutput
// (and the canvas tree builder) can decode each line.
"d|0|",
"f|",
// Portable stat: GNU first, BSD fallback, then 0.
"stat -c %s",
"stat -f %z",
}
for _, w := range wants {
if !strings.Contains(got, w) {
t.Errorf("buildFindShell missing %q in: %s", w, got)
}
}
}
// TestBuildFindShell_DepthForwarding catches a regression where the
// helper hard-codes a depth instead of using the caller's value.
// `?depth=` on the canvas side controls how many levels expand on
// load — losing it means the file tree is either empty (depth=0) or
// the network blows up on a top-level /home with everyone's $HOME
// (uncapped).
func TestBuildFindShell_DepthForwarding(t *testing.T) {
for _, d := range []int{1, 3, 5} {
got := buildFindShell("/configs", d)
want := "-maxdepth " + intToStr(d)
if !strings.Contains(got, want) {
t.Errorf("buildFindShell depth=%d output missing %q: %s", d, want, got)
}
}
}
// intToStr avoids pulling strconv into a one-liner; matches the shell
// builder's fmt.Sprintf %d output exactly.
func intToStr(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
s := string(buf[i:])
if neg {
return "-" + s
}
return s
}
// TestParseFindOutput pins the parser. Each line is TYPE|SIZE|REL,
// blank/short lines silently skipped. Pre-PR-A this logic was inlined
// in the handler with the same shape; extracting + testing separately
// removes the "regex passes against the inline parser but a future
// refactor of the handler subtly changes the parse" failure mode.
func TestParseFindOutput(t *testing.T) {
in := []byte(`d|0|nested
f|123|nested/a.yaml
f|45|README.md
invalid-line
f||no-size
d|0|
`)
got := parseFindOutput(in)
// Want 4 entries: nested(d), nested/a.yaml(f,123), README.md(f,45),
// no-size(f,0). Blank lines, "invalid-line" (no pipes), and
// `d|0|` (empty rel) are skipped.
wantPaths := []string{"nested", "nested/a.yaml", "README.md", "no-size"}
if len(got) != len(wantPaths) {
t.Fatalf("got %d entries, want %d: %+v", len(got), len(wantPaths), got)
}
for i, w := range wantPaths {
if got[i].Path != w {
t.Errorf("entry[%d].Path = %q, want %q", i, got[i].Path, w)
}
}
if !got[0].Dir {
t.Errorf("entry[0] should be Dir")
}
if got[1].Size != 123 {
t.Errorf("entry[1].Size = %d, want 123", got[1].Size)
}
if got[3].Size != 0 {
t.Errorf("entry[3].Size on missing-size line = %d, want 0", got[3].Size)
}
}
// TestParseFindOutput_EmptyInput — a missing listing root yields
// empty stdout (find swallows the "No such file" via 2>/dev/null),
// which must round-trip to a JSON `[]`, not null. The handler does
// `make([]eicFileEntry, 0)` to enforce this; the test pins the
// helper-level guarantee independently.
func TestParseFindOutput_EmptyInput(t *testing.T) {
got := parseFindOutput([]byte(""))
if got == nil {
t.Errorf("parseFindOutput(\"\") returned nil; want empty slice for JSON []")
}
if len(got) != 0 {
t.Errorf("parseFindOutput(\"\") = %+v; want []", got)
}
}
@@ -7,39 +7,112 @@ import (
"testing"
)
// TestResolveWorkspaceFilePath_KnownRuntimes — the runtime → base-path
// map is the source of truth for where saved files land on the workspace
// EC2. Changing a base path without a migration shim silently orphans
// previously-saved files; this test pins the current contract.
func TestResolveWorkspaceFilePath_KnownRuntimes(t *testing.T) {
// TestResolveWorkspaceFilePath_RuntimeIndirection pins the
// `?root="/configs"` (or empty / unrecognized) → runtime managed-config
// dir behavior. Hermes uses /home/ubuntu/.hermes; claude-code uses
// /configs; unknowns fall back to /configs. This indirection is the
// reason hermes Config-tab edits land in the right place even though
// the canvas only ever sends `?root=/configs`. Changing it without a
// migration shim silently orphans previously-saved files.
func TestResolveWorkspaceFilePath_RuntimeIndirection(t *testing.T) {
cases := []struct {
runtime string
root string
relPath string
want string
}{
{"hermes", "config.yaml", "/home/ubuntu/.hermes/config.yaml"},
{"HERMES", "config.yaml", "/home/ubuntu/.hermes/config.yaml"}, // case-insensitive
{"hermes", "nested/a.yaml", "/home/ubuntu/.hermes/nested/a.yaml"},
{"hermes", "/configs", "config.yaml", "/home/ubuntu/.hermes/config.yaml"},
{"HERMES", "/configs", "config.yaml", "/home/ubuntu/.hermes/config.yaml"}, // case-insensitive
{"hermes", "/configs", "nested/a.yaml", "/home/ubuntu/.hermes/nested/a.yaml"},
{"hermes", "", "config.yaml", "/home/ubuntu/.hermes/config.yaml"}, // empty root → runtime indirection
{"hermes", "/etc", "config.yaml", "/home/ubuntu/.hermes/config.yaml"}, // out-of-allowlist → runtime indirection
// claude-code (and any future containerized runtime) lands at /configs —
// the path user-data creates and bind-mounts into the container. Pre-fix
// this fell through to /opt/configs which doesn't exist on workspace EC2s
// and would 500 with EACCES on save (the bug that motivated this gate).
{"claude-code", "config.yaml", "/configs/config.yaml"},
{"CLAUDE-CODE", "config.yaml", "/configs/config.yaml"}, // case-insensitive
{"langgraph", "config.yaml", "/opt/configs/config.yaml"},
{"external", "skills.json", "/opt/configs/skills.json"},
{"", "config.yaml", "/configs/config.yaml"}, // empty → default
{"unknown", "config.yaml", "/configs/config.yaml"}, // unknown → default
{"claude-code", "/configs", "config.yaml", "/configs/config.yaml"},
{"CLAUDE-CODE", "/configs", "config.yaml", "/configs/config.yaml"}, // case-insensitive
{"langgraph", "/configs", "config.yaml", "/opt/configs/config.yaml"},
{"external", "/configs", "skills.json", "/opt/configs/skills.json"},
{"", "/configs", "config.yaml", "/configs/config.yaml"}, // empty runtime → default
{"unknown", "/configs", "config.yaml", "/configs/config.yaml"}, // unknown → default
}
for _, tc := range cases {
t.Run(tc.runtime+"/"+tc.relPath, func(t *testing.T) {
got, err := resolveWorkspaceFilePath(tc.runtime, tc.relPath)
t.Run(tc.runtime+"+"+tc.root+"/"+tc.relPath, func(t *testing.T) {
got, err := resolveWorkspaceFilePath(tc.runtime, tc.root, tc.relPath)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if got != tc.want {
t.Errorf("resolveWorkspaceFilePath(%q,%q) = %q, want %q",
tc.runtime, tc.relPath, got, tc.want)
t.Errorf("resolveWorkspaceFilePath(%q,%q,%q) = %q, want %q",
tc.runtime, tc.root, tc.relPath, got, tc.want)
}
})
}
}
// TestResolveWorkspaceFilePath_LiteralRoots pins that the universal
// allow-listed roots (`/home`, `/workspace`, `/plugins`) pass through
// LITERALLY rather than getting rewritten to the runtime prefix. This
// is the half of the resolver that the FilesTab "/home" selector
// depends on — without it, picking /home on a hermes workspace would
// route to /home/ubuntu/.hermes (the runtime indirection) and the
// canvas's tree row would never line up with what the user sees on
// the EC2 host.
func TestResolveWorkspaceFilePath_LiteralRoots(t *testing.T) {
cases := []struct {
runtime string
root string
relPath string
want string
}{
// /home is always literal regardless of runtime — it's a
// universal Linux path, not a managed-config indirection.
{"hermes", "/home", "ubuntu/.bashrc", "/home/ubuntu/.bashrc"},
{"claude-code", "/home", "ubuntu/notes.md", "/home/ubuntu/notes.md"},
{"langgraph", "/home", "ubuntu/x", "/home/ubuntu/x"},
// /workspace and /plugins are also literal — runtime is ignored.
{"hermes", "/workspace", "src/main.go", "/workspace/src/main.go"},
{"claude-code", "/plugins", "p/manifest.yaml", "/plugins/p/manifest.yaml"},
}
for _, tc := range cases {
t.Run(tc.runtime+"+"+tc.root+"/"+tc.relPath, func(t *testing.T) {
got, err := resolveWorkspaceFilePath(tc.runtime, tc.root, tc.relPath)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if got != tc.want {
t.Errorf("resolveWorkspaceFilePath(%q,%q,%q) = %q, want %q",
tc.runtime, tc.root, tc.relPath, got, tc.want)
}
})
}
}
// TestResolveWorkspaceRootPath pins the directory-only translation
// used by listFilesViaEIC. Same indirection rules as
// resolveWorkspaceFilePath but without joining a relative path.
func TestResolveWorkspaceRootPath(t *testing.T) {
cases := []struct {
runtime string
root string
want string
}{
{"hermes", "/configs", "/home/ubuntu/.hermes"},
{"claude-code", "/configs", "/configs"},
{"hermes", "", "/home/ubuntu/.hermes"},
{"hermes", "/home", "/home"},
{"claude-code", "/workspace", "/workspace"},
{"hermes", "/plugins", "/plugins"},
{"unknown", "/configs", "/configs"},
{"hermes", "/etc", "/home/ubuntu/.hermes"}, // not allowlisted → runtime indirection
}
for _, tc := range cases {
t.Run(tc.runtime+"+"+tc.root, func(t *testing.T) {
got := resolveWorkspaceRootPath(tc.runtime, tc.root)
if got != tc.want {
t.Errorf("resolveWorkspaceRootPath(%q,%q) = %q, want %q",
tc.runtime, tc.root, got, tc.want)
}
})
}
@@ -53,48 +126,80 @@ func TestResolveWorkspaceFilePath_KnownRuntimes(t *testing.T) {
// We only assert the cases that Clean() can't rescue.
func TestResolveWorkspaceFilePath_RejectsTraversal(t *testing.T) {
bad := []string{
"../etc/shadow", // escapes base via ..
"/etc/shadow", // absolute path
"./../../etc", // multiple ..
"a/../../etc", // escapes via deeper ..
"../etc/shadow", // escapes base via ..
"/etc/shadow", // absolute path
"./../../etc", // multiple ..
"a/../../etc", // escapes via deeper ..
}
for _, rel := range bad {
t.Run(rel, func(t *testing.T) {
_, err := resolveWorkspaceFilePath("hermes", rel)
_, err := resolveWorkspaceFilePath("hermes", "/configs", rel)
if err == nil {
t.Errorf("resolveWorkspaceFilePath(hermes, %q) should have errored, got nil", rel)
t.Errorf("resolveWorkspaceFilePath(hermes,/configs,%q) should have errored, got nil", rel)
}
})
}
}
// TestSSHArgs_LogLevelErrorBothSites pins that BOTH ssh invocations
// (writeFileViaEIC + readFileViaEIC) include `-o LogLevel=ERROR`.
// TestSSHArgs_HardenedFlags pins the ssh option set returned by
// eicSSHSession.sshArgs(). Centralising the args was deliberate so a
// fix like PR #2822's `LogLevel=ERROR` (silences the benign
// known-hosts warning that fooled the read/list "empty stderr → not
// found" classifier) only needs to land in one place.
//
// Without that flag, ssh emits a "Warning: Permanently added
// '[127.0.0.1]:NNNNN' (ED25519) to the list of known hosts." line on
// every fresh tunnel connection (even with UserKnownHostsFile=/dev/null
// — that prevents persistence, not the warning). The warning lands on
// stderr, which fools readFileViaEIC's "empty stdout + empty stderr →
// file not found" classifier into thinking the warning is a real
// ssh-layer error and returning 500 instead of 404.
//
// Caught 2026-05-05 02:38 on hongming.moleculesai.app: opening Hermes
// workspace's Config tab returned 500 with body
// Caught 2026-05-05 02:38 on hongming.moleculesai.app: opening
// Hermes workspace's Config tab returned 500 with body
// `ssh cat: exit status 1 (Warning: Permanently added '[127.0.0.1]:37951'…)`.
//
// LogLevel=ERROR silences info+warning while keeping real auth/tunnel
// errors visible. This test reads the source and asserts the flag
// appears at least twice (one per ssh block) — fires if a future edit
// removes it from either site.
func TestSSHArgs_LogLevelErrorBothSites(t *testing.T) {
// Asserts each load-bearing flag appears in the args slice — fires if
// a future edit removes any of them.
func TestSSHArgs_HardenedFlags(t *testing.T) {
s := eicSSHSession{keyPath: "/tmp/k", localPort: 12345, osUser: "ubuntu", instanceID: "i-x"}
got := s.sshArgs("echo hi")
wantFragments := [][]string{
{"-i", "/tmp/k"},
{"-o", "StrictHostKeyChecking=no"},
{"-o", "UserKnownHostsFile=/dev/null"},
{"-o", "LogLevel=ERROR"},
{"-o", "ServerAliveInterval=15"},
{"-p", "12345"},
}
joined := strings.Join(got, " ")
for _, frag := range wantFragments {
if !strings.Contains(joined, strings.Join(frag, " ")) {
t.Errorf("sshArgs() missing fragment %v; got: %v", frag, got)
}
}
// Last two args must be `<user>@127.0.0.1` then the remote command.
if got[len(got)-2] != "ubuntu@127.0.0.1" {
t.Errorf("sshArgs() second-last must be user@127.0.0.1; got: %q", got[len(got)-2])
}
if got[len(got)-1] != "echo hi" {
t.Errorf("sshArgs() last must be the remote command; got: %q", got[len(got)-1])
}
}
// TestEicSSHSessionSingleSourceForSSHFlags is a structural guard: the
// production EIC source must invoke s.sshArgs() exclusively for ssh
// invocations — direct ssh args inlined in any helper would re-open
// the regression that PR #2822 closed (LogLevel=ERROR drift between
// helpers). Counts `s.sshArgs(` occurrences (one per file op) and
// fails if anyone copy-pastes a raw ssh args slice.
func TestEicSSHSessionSingleSourceForSSHFlags(t *testing.T) {
src, err := os.ReadFile("template_files_eic.go")
if err != nil {
t.Fatalf("read source: %v", err)
}
matches := regexp.MustCompile(`"-o", "LogLevel=ERROR"`).FindAllIndex(src, -1)
if len(matches) < 2 {
t.Errorf("expected LogLevel=ERROR in BOTH ssh blocks (write + read); found %d occurrences", len(matches))
// Each of write/read/list/delete should call s.sshArgs once.
matches := regexp.MustCompile(`s\.sshArgs\(`).FindAllIndex(src, -1)
if len(matches) < 4 {
t.Errorf("expected ≥4 s.sshArgs() callers (write/read/list/delete); found %d", len(matches))
}
// Belt and braces: no helper should be assembling its own
// `LogLevel=ERROR` literal outside of sshArgs.
literal := regexp.MustCompile(`"-o", "LogLevel=ERROR"`).FindAllIndex(src, -1)
if len(literal) != 1 {
t.Errorf("LogLevel=ERROR must appear exactly once (in sshArgs); found %d occurrences — drift risk", len(literal))
}
}
@@ -216,7 +216,12 @@ func (h *TemplatesHandler) ReplaceFiles(c *gin.Context) {
// as a follow-up.
if instanceID != "" {
for relPath, content := range body.Files {
if err := writeFileViaEIC(ctx, instanceID, runtime, relPath, []byte(content)); err != nil {
// ReplaceFiles is a bulk template-import endpoint — files
// always land in the runtime's managed-config dir. Pass
// "/configs" so resolveWorkspaceFilePath routes through the
// runtime prefix map (matches the local-Docker arm below
// which always copies to /configs).
if err := writeFileViaEIC(ctx, instanceID, runtime, "/configs", relPath, []byte(content)); err != nil {
log.Printf("ReplaceFiles EIC for %s path=%s: %v", workspaceID, relPath, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to write file %s: %v", relPath, err)})
return
+71 -10
View File
@@ -243,8 +243,11 @@ func (h *TemplatesHandler) ListFiles(c *gin.Context) {
listPath = rootPath + "/" + subPath
}
var wsName string
if err := db.DB.QueryRowContext(ctx, `SELECT name FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsName); err != nil {
var wsName, instanceID, runtime string
if err := db.DB.QueryRowContext(ctx,
`SELECT name, COALESCE(instance_id, ''), COALESCE(runtime, '') FROM workspaces WHERE id = $1`,
workspaceID,
).Scan(&wsName, &instanceID, &runtime); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
return
}
@@ -255,6 +258,32 @@ func (h *TemplatesHandler) ListFiles(c *gin.Context) {
Dir bool `json:"dir"`
}
// SaaS workspace (EC2-per-workspace) — no Docker on this tenant. List
// via SSH through the EIC endpoint, mirroring ReadFile/WriteFile's
// dispatch. Pre-fix this branch was missing and SaaS workspaces
// always fell through to local-Docker check (finds nothing on a SaaS
// tenant) + template-dir fallback (returns the seed template, not
// the persisted state, and almost never matches on user-named
// workspaces). Net effect: the canvas Files tab always rendered "0
// files / No config files yet" for SaaS workspaces, regardless of
// what was actually on disk. See issue #2999.
if instanceID != "" {
entries, err := listFilesViaEIC(ctx, instanceID, runtime, rootPath, subPath, depth)
if err != nil {
log.Printf("ListFiles EIC for %s root=%s sub=%s: %v", workspaceID, rootPath, subPath, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to list files: %v", err)})
return
}
// Translate to the handler's wire shape (the field names match
// 1:1, but Go can't implicit-convert named struct types).
out := make([]fileEntry, 0, len(entries))
for _, e := range entries {
out = append(out, fileEntry{Path: e.Path, Size: e.Size, Dir: e.Dir})
}
c.JSON(http.StatusOK, out)
return
}
// Try container filesystem first
if containerName := h.findContainer(ctx, workspaceID); containerName != "" {
// Portable file listing: works on both GNU and BusyBox/Alpine.
@@ -378,12 +407,13 @@ func (h *TemplatesHandler) ReadFile(c *gin.Context) {
// canvas Config tab always 404'd for SaaS workspaces — visible to
// users after #2781 added the "no config.yaml" error UX.
//
// The ?root= query param is intentionally ignored on the SaaS path —
// it's a local-Docker concept (arbitrary container roots). The
// runtime → base-path map (workspaceFilePathPrefix in
// template_files_eic.go) is the SaaS source of truth.
// `?root=` flows through resolveWorkspaceFilePath: "/configs" stays
// the per-runtime managed-config indirection (claude-code → /configs,
// hermes/home/ubuntu/.hermes); other allow-listed roots
// (`/home`, `/workspace`, `/plugins`) pass through literally so
// list/read/write/delete agree on what file a tree row points to.
if instanceID != "" {
content, err := readFileViaEIC(ctx, instanceID, runtime, filePath)
content, err := readFileViaEIC(ctx, instanceID, runtime, rootPath, filePath)
if err == nil {
c.JSON(http.StatusOK, gin.H{
"path": filePath,
@@ -468,6 +498,11 @@ func (h *TemplatesHandler) WriteFile(c *gin.Context) {
}
ctx := c.Request.Context()
rootPath := c.DefaultQuery("root", "/configs")
if !allowedRoots[rootPath] {
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins"})
return
}
var wsName, instanceID, runtime string
if err := db.DB.QueryRowContext(ctx,
`SELECT name, COALESCE(instance_id, ''), COALESCE(runtime, '') FROM workspaces WHERE id = $1`,
@@ -479,8 +514,11 @@ func (h *TemplatesHandler) WriteFile(c *gin.Context) {
// SaaS workspace (EC2-per-workspace) — no Docker on this tenant. Write
// via SSH through the EIC endpoint to the runtime-specific path.
// `?root=` flows through the same per-runtime / literal indirection
// as ReadFile so list/read/write/delete agree on what file a tree
// row points to.
if instanceID != "" {
if err := writeFileViaEIC(ctx, instanceID, runtime, filePath, []byte(body.Content)); err != nil {
if err := writeFileViaEIC(ctx, instanceID, runtime, rootPath, filePath, []byte(body.Content)); err != nil {
log.Printf("WriteFile EIC for %s path=%s: %v", workspaceID, filePath, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to write file: %v", err)})
return
@@ -528,12 +566,35 @@ func (h *TemplatesHandler) DeleteFile(c *gin.Context) {
}
ctx := c.Request.Context()
var wsName string
if err := db.DB.QueryRowContext(ctx, `SELECT name FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsName); err != nil {
rootPath := c.DefaultQuery("root", "/configs")
if !allowedRoots[rootPath] {
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins"})
return
}
var wsName, instanceID, runtime string
if err := db.DB.QueryRowContext(ctx,
`SELECT name, COALESCE(instance_id, ''), COALESCE(runtime, '') FROM workspaces WHERE id = $1`,
workspaceID,
).Scan(&wsName, &instanceID, &runtime); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
return
}
// SaaS workspace (EC2-per-workspace) — no Docker on this tenant. Delete
// via SSH through the EIC endpoint, mirroring ReadFile/WriteFile's
// dispatch. Pre-fix this branch was missing — DeleteFile fell through
// to local-Docker (no container) + ephemeral-volume (no Docker) and
// silently 500'd. See issue #2999.
if instanceID != "" {
if err := deleteFileViaEIC(ctx, instanceID, runtime, rootPath, filePath); err != nil {
log.Printf("DeleteFile EIC for %s root=%s path=%s: %v", workspaceID, rootPath, filePath, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to delete file: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{"status": "deleted", "path": filePath})
return
}
// Delete via docker exec when container is running
if containerName := h.findContainer(ctx, workspaceID); containerName != "" {
// CWE-78: use filepath.Join instead of string concat to prevent path
@@ -750,7 +750,11 @@ func TestListFiles_WorkspaceNotFound(t *testing.T) {
handler := NewTemplatesHandler(t.TempDir(), nil, nil)
mock.ExpectQuery("SELECT name FROM workspaces WHERE id =").
// SQL shape: SELECT name, COALESCE(instance_id, ''), COALESCE(runtime, '') FROM workspaces WHERE id = $1
// (matches the L/R/W/D unified shape so dispatchers can branch on
// instance_id; sqlmock matches via QueryMatcherRegexp so the parens
// need escaping.)
mock.ExpectQuery(`SELECT name, COALESCE\(instance_id, ''\), COALESCE\(runtime, ''\) FROM workspaces WHERE id =`).
WithArgs("ws-nonexist").
WillReturnError(sql.ErrNoRows)
@@ -777,9 +781,9 @@ func TestListFiles_FallbackToHost_NoTemplate(t *testing.T) {
tmpDir := t.TempDir()
handler := NewTemplatesHandler(tmpDir, nil, nil) // nil docker = no container
mock.ExpectQuery("SELECT name FROM workspaces WHERE id =").
mock.ExpectQuery(`SELECT name, COALESCE\(instance_id, ''\), COALESCE\(runtime, ''\) FROM workspaces WHERE id =`).
WithArgs("ws-fallback").
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("Unknown Agent"))
WillReturnRows(sqlmock.NewRows([]string{"name", "instance_id", "runtime"}).AddRow("Unknown Agent", "", ""))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -817,9 +821,9 @@ func TestListFiles_FallbackToHost_WithTemplate(t *testing.T) {
handler := NewTemplatesHandler(tmpDir, nil, nil)
mock.ExpectQuery("SELECT name FROM workspaces WHERE id =").
mock.ExpectQuery(`SELECT name, COALESCE\(instance_id, ''\), COALESCE\(runtime, ''\) FROM workspaces WHERE id =`).
WithArgs("ws-tmpl").
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("Test Agent"))
WillReturnRows(sqlmock.NewRows([]string{"name", "instance_id", "runtime"}).AddRow("Test Agent", "", ""))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -1103,7 +1107,7 @@ func TestDeleteFile_WorkspaceNotFound(t *testing.T) {
handler := NewTemplatesHandler(t.TempDir(), nil, nil)
mock.ExpectQuery("SELECT name FROM workspaces WHERE id =").
mock.ExpectQuery(`SELECT name, COALESCE\(instance_id, ''\), COALESCE\(runtime, ''\) FROM workspaces WHERE id =`).
WithArgs("ws-del-nf").
WillReturnError(sql.ErrNoRows)
+21 -22
View File
@@ -112,7 +112,6 @@ func (h *WorkspaceHandler) SetCPProvisioner(cp provisioner.CPProvisionerAPI) {
h.cpProv = cp
}
// SetEnvMutators wires a provisionhook.Registry into the handler. Plugins
// living in separate repos register on the same Registry instance during
// boot (see cmd/server/main.go) and main.go calls this setter once before
@@ -361,7 +360,7 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
// populate the Runtime pill on the side panel immediately — without it
// the node lives as "runtime: unknown" until something refetches the
// workspace row (which nothing does during provisioning).
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_PROVISIONING", id, map[string]interface{}{
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceProvisioning), id, map[string]interface{}{
"name": payload.Name,
"tier": payload.Tier,
"runtime": payload.Runtime,
@@ -388,7 +387,7 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
if err := db.CacheURL(ctx, id, payload.URL); err != nil {
log.Printf("External workspace: failed to cache URL for %s: %v", id, err)
}
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_ONLINE", id, map[string]interface{}{
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceOnline), id, map[string]interface{}{
"name": payload.Name, "external": true,
})
} else {
@@ -407,7 +406,7 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
} else {
connectionToken = tok
}
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_AWAITING_AGENT", id, map[string]interface{}{
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceAwaitingAgent), id, map[string]interface{}{
"name": payload.Name, "external": true,
})
}
@@ -539,24 +538,24 @@ func scanWorkspaceRow(rows interface {
}
ws := map[string]interface{}{
"id": id,
"name": name,
"tier": tier,
"status": status,
"url": url,
"parent_id": parentID,
"active_tasks": activeTasks,
"max_concurrent_tasks": maxConcurrentTasks,
"last_error_rate": errorRate,
"last_sample_error": sampleError,
"uptime_seconds": uptimeSeconds,
"current_task": currentTask,
"runtime": runtime,
"workspace_dir": nilIfEmpty(workspaceDir),
"monthly_spend": monthlySpend,
"x": x,
"y": y,
"collapsed": collapsed,
"id": id,
"name": name,
"tier": tier,
"status": status,
"url": url,
"parent_id": parentID,
"active_tasks": activeTasks,
"max_concurrent_tasks": maxConcurrentTasks,
"last_error_rate": errorRate,
"last_sample_error": sampleError,
"uptime_seconds": uptimeSeconds,
"current_task": currentTask,
"runtime": runtime,
"workspace_dir": nilIfEmpty(workspaceDir),
"monthly_spend": monthlySpend,
"x": x,
"y": y,
"collapsed": collapsed,
}
// budget_limit: nil when no limit set, int64 otherwise
@@ -6,6 +6,7 @@ import (
"strings"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
"github.com/gin-gonic/gin"
)
@@ -85,7 +86,7 @@ func (h *WorkspaceHandler) BootstrapFailed(c *gin.Context) {
return
}
h.broadcaster.RecordAndBroadcast(c.Request.Context(), "WORKSPACE_PROVISION_FAILED", id, map[string]interface{}{
h.broadcaster.RecordAndBroadcast(c.Request.Context(), string(events.EventWorkspaceProvisionFailed), id, map[string]interface{}{
"error": errMsg,
"log_tail": tail,
"source": "bootstrap_watcher",
@@ -16,12 +16,14 @@ import (
"time"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/lib/pq"
)
// State handles GET /workspaces/:id/state — minimal status payload for
// remote-agent polling (Phase 30.4). Returns `{status, paused, deleted,
// workspace_id}` so a remote agent can detect pause/resume/delete
@@ -380,7 +382,7 @@ func (h *WorkspaceHandler) Delete(c *gin.Context) {
pq.Array(allIDs)); err != nil {
log.Printf("Delete token revocation error for %s: %v", id, err)
}
// #1027: cascade-disable all schedules for the deleted workspaces so
// #1027: cascade-disable all schedules for the deleted workspaces so
// the scheduler never fires a cron into a removed container.
if _, err := db.DB.ExecContext(ctx,
`UPDATE workspace_schedules SET enabled = false, updated_at = now()
@@ -466,14 +468,14 @@ func (h *WorkspaceHandler) Delete(c *gin.Context) {
// leaving other WS clients ignorant of the cascade. The DB
// row is already 'removed' so it's recoverable, but the
// inconsistency is avoidable.
h.broadcaster.RecordAndBroadcast(cleanupCtx, "WORKSPACE_REMOVED", descID, map[string]interface{}{})
h.broadcaster.RecordAndBroadcast(cleanupCtx, string(events.EventWorkspaceRemoved), descID, map[string]interface{}{})
}
stopAndRemove(id)
db.ClearWorkspaceKeys(cleanupCtx, id)
restartStates.Delete(id) // #2269: same as descendants above
h.broadcaster.RecordAndBroadcast(cleanupCtx, "WORKSPACE_REMOVED", id, map[string]interface{}{
h.broadcaster.RecordAndBroadcast(cleanupCtx, string(events.EventWorkspaceRemoved), id, map[string]interface{}{
"cascade_deleted": len(descendantIDs),
})
@@ -41,6 +41,7 @@ import (
"path/filepath"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
@@ -212,7 +213,7 @@ func (h *WorkspaceHandler) markProvisionFailed(ctx context.Context, workspaceID,
} else if _, hasErr := extra["error"]; !hasErr {
extra["error"] = msg
}
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_PROVISION_FAILED", workspaceID, extra)
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceProvisionFailed), workspaceID, extra)
if _, dbErr := db.DB.ExecContext(ctx,
`UPDATE workspaces SET status = $3, last_sample_error = $2, updated_at = now() WHERE id = $1`,
workspaceID, msg, models.StatusFailed); dbErr != nil {
@@ -11,6 +11,7 @@ import (
"time"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provlog"
"github.com/gin-gonic/gin"
@@ -147,7 +148,7 @@ func (h *WorkspaceHandler) Restart(c *gin.Context) {
// Reset to provisioning
db.DB.ExecContext(ctx,
`UPDATE workspaces SET status = $1, url = '', updated_at = now() WHERE id = $2`, models.StatusProvisioning, id)
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_PROVISIONING", id, map[string]interface{}{
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceProvisioning), id, map[string]interface{}{
"name": wsName,
"tier": tier,
"runtime": containerRuntime,
@@ -341,7 +342,7 @@ func (h *WorkspaceHandler) HibernateWorkspace(ctx context.Context, workspaceID s
}
db.ClearWorkspaceKeys(ctx, workspaceID)
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_HIBERNATED", workspaceID, map[string]interface{}{
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceHibernated), workspaceID, map[string]interface{}{
"name": wsName,
"tier": tier,
})
@@ -552,7 +553,7 @@ func (h *WorkspaceHandler) runRestartCycle(workspaceID string) {
db.DB.ExecContext(ctx,
`UPDATE workspaces SET status = $1, url = '', updated_at = now() WHERE id = $2`, models.StatusProvisioning, workspaceID)
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_PROVISIONING", workspaceID, map[string]interface{}{
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceProvisioning), workspaceID, map[string]interface{}{
"name": wsName, "tier": tier, "runtime": dbRuntime,
})
@@ -640,7 +641,7 @@ func (h *WorkspaceHandler) Pause(c *gin.Context) {
db.DB.ExecContext(ctx,
`UPDATE workspaces SET status = $1, url = '', updated_at = now() WHERE id = $2`, models.StatusPaused, ws.id)
db.ClearWorkspaceKeys(ctx, ws.id)
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_PAUSED", ws.id, map[string]interface{}{
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspacePaused), ws.id, map[string]interface{}{
"name": ws.name,
})
}
@@ -709,7 +710,7 @@ func (h *WorkspaceHandler) Resume(c *gin.Context) {
for _, ws := range toResume {
db.DB.ExecContext(ctx,
`UPDATE workspaces SET status = $1, updated_at = now() WHERE id = $2`, models.StatusProvisioning, ws.id)
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_PROVISIONING", ws.id, map[string]interface{}{
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceProvisioning), ws.id, map[string]interface{}{
"name": ws.name, "tier": ws.tier, "runtime": ws.runtime,
})
payload := models.CreateWorkspacePayload{Name: ws.name, Tier: ws.tier, Runtime: ws.runtime}
@@ -35,6 +35,7 @@ import (
"time"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/memory/contract"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/textutil"
)
const (
@@ -340,7 +341,7 @@ func decodeError(resp *http.Response) error {
// have rather than dropping it.
return &contract.Error{
Code: httpStatusToCode(resp.StatusCode),
Message: fmt.Sprintf("status %d: %s", resp.StatusCode, truncate(string(body), 256)),
Message: fmt.Sprintf("status %d: %s", resp.StatusCode, textutil.TruncateBytes(string(body), 256)),
}
}
return &e
@@ -359,12 +360,7 @@ func httpStatusToCode(status int) contract.ErrorCode {
}
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
// truncation moved to internal/textutil.TruncateBytes (#2962 SSOT).
// --- Circuit breaker ---
@@ -499,14 +499,10 @@ func TestHttpStatusToCode(t *testing.T) {
}
}
func TestTruncate(t *testing.T) {
if got := truncate("short", 10); got != "short" {
t.Errorf("got %q", got)
}
if got := truncate(strings.Repeat("a", 300), 10); !strings.HasSuffix(got, "…") {
t.Errorf("expected ellipsis: %q", got)
}
}
// Truncate moved to internal/textutil — coverage lives in
// internal/textutil/truncate_test.go (TestTruncateBytes_RuneBoundary).
// memory/client just calls it as a wire-shape helper for error
// messages; no client-specific behavior to pin here.
// --- Circuit breaker ---
@@ -213,10 +213,15 @@ func setupSwapEnv(t *testing.T) (*handlers.MCPHandler, *flatPlugin, sqlmock.Sqlm
// expectChainQuery sets up the recursive-CTE expectation matching
// the resolver for a root workspace. Reusable across tests.
//
// The resolver SELECTs `name` so it can populate Namespace.DisplayName
// (#2988); we pass an empty string here because the e2e tests don't
// assert on label rendering — the namespace string ("workspace:root-1"
// etc) is what the plugin sees.
func expectChainQueryRoot(mock sqlmock.Sqlmock) {
mock.ExpectQuery("WITH RECURSIVE chain").
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}).
AddRow("root-1", nil, 0))
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}).
AddRow("root-1", "", nil, 0))
}
// --- The actual E2E ---
@@ -33,6 +33,25 @@ type Namespace struct {
Kind contract.NamespaceKind `json:"kind"`
Description string `json:"description"`
Writable bool `json:"writable"`
// DisplayName is the human-readable label for this namespace,
// derived from the workspace tree:
// - workspace: this workspace's own name (`workspaces.name`)
// - team: parent's name if child, this workspace's name if root
// (degenerate case — team semantically means "memories
// shared with peers in this team", so for a root workspace
// with no peers, "your team" is conceptually correct.)
// - org: the root workspace's name (org-wide memories — every
// workspace under this root sees them)
//
// Empty string when the lookup failed (workspace row missing). The
// canvas uses DisplayName for the dropdown; falls back to a short
// UUID prefix when it's empty.
//
// Issue #2988: pre-fix, the canvas labelled all three namespaces
// with the SAME shortID-truncated UUID prefix on a root workspace
// because workspace==team==org IDs collide. The display name
// disambiguates them by surfacing real workspace names.
DisplayName string `json:"display_name,omitempty"`
}
// ErrWorkspaceNotFound is returned when the input workspace ID does
@@ -54,6 +73,7 @@ func New(db *sql.DB) *Resolver {
// chainNode is one row from the recursive CTE.
type chainNode struct {
id string
name string // workspaces.name (display label for the namespace)
parentID *string
depth int
}
@@ -64,16 +84,16 @@ type chainNode struct {
func (r *Resolver) walkChain(ctx context.Context, workspaceID string) ([]chainNode, error) {
const query = `
WITH RECURSIVE chain AS (
SELECT id, parent_id, 0 AS depth
SELECT id, name, parent_id, 0 AS depth
FROM workspaces
WHERE id = $1
UNION ALL
SELECT w.id, w.parent_id, c.depth + 1
SELECT w.id, w.name, w.parent_id, c.depth + 1
FROM workspaces w
JOIN chain c ON w.id = c.parent_id
WHERE c.depth < $2
)
SELECT id::text, parent_id::text, depth FROM chain ORDER BY depth ASC
SELECT id::text, COALESCE(name, ''), parent_id::text, depth FROM chain ORDER BY depth ASC
`
rows, err := r.db.QueryContext(ctx, query, workspaceID, maxChainDepth)
if err != nil {
@@ -85,7 +105,7 @@ func (r *Resolver) walkChain(ctx context.Context, workspaceID string) ([]chainNo
for rows.Next() {
var n chainNode
var parentStr sql.NullString
if err := rows.Scan(&n.id, &parentStr, &n.depth); err != nil {
if err := rows.Scan(&n.id, &n.name, &parentStr, &n.depth); err != nil {
return nil, fmt.Errorf("scan chain: %w", err)
}
if parentStr.Valid && parentStr.String != "" {
@@ -122,6 +142,33 @@ func derive(chain []chainNode) (workspace, team, org string) {
return
}
// deriveNames computes the display name for each of the three
// canonical namespaces. Mirrors derive() — same lookup logic, but
// returns workspace/parent/root NAMES instead of IDs.
//
// For a root workspace (no parent), team and org both alias to self;
// callers should still render them as semantically distinct (the
// `kind` field on the Namespace carries that distinction). The name
// itself collides on a depth-1 tree — that's expected; the kind
// prefix in the canvas label disambiguates.
//
// Returns the empty string for any name that's missing on the chain
// row (defensive — workspaces.name is NOT NULL today, but a future
// migration could change that). Callers fall back to UUID prefix
// when DisplayName is empty.
func deriveNames(chain []chainNode) (workspace, team, org string) {
self := chain[0]
workspace = self.name
if self.parentID != nil && len(chain) > 1 {
// Parent is the next node in the chain (depth 1).
team = chain[1].name
} else {
team = self.name
}
org = chain[len(chain)-1].name
return
}
// ReadableNamespaces returns the namespaces the workspace can read
// from. Order is deterministic (workspace, team, org) so callers can
// reason about precedence.
@@ -131,6 +178,7 @@ func (r *Resolver) ReadableNamespaces(ctx context.Context, workspaceID string) (
return nil, err
}
wsID, teamID, orgID := derive(chain)
wsName, teamName, orgName := deriveNames(chain)
isRoot := chain[0].parentID == nil
out := []Namespace{
@@ -139,12 +187,14 @@ func (r *Resolver) ReadableNamespaces(ctx context.Context, workspaceID string) (
Kind: contract.NamespaceKindWorkspace,
Description: "This workspace's private memories",
Writable: true,
DisplayName: wsName,
},
{
Name: "team:" + teamID,
Kind: contract.NamespaceKindTeam,
Description: "Memories shared across team members (parent + siblings)",
Writable: true,
DisplayName: teamName,
},
}
// Org namespace is readable by every workspace in the tree, but
@@ -155,6 +205,7 @@ func (r *Resolver) ReadableNamespaces(ctx context.Context, workspaceID string) (
Kind: contract.NamespaceKindOrg,
Description: "Org-wide memories visible to every workspace under this root",
Writable: isRoot,
DisplayName: orgName,
})
return out, nil
}
@@ -46,8 +46,8 @@ func TestWalkChain_RootOnly(t *testing.T) {
// Root workspace: parent_id is NULL, depth 0, single row.
mock.ExpectQuery(chainQuerySnippet).
WithArgs("ws-root", maxChainDepth).
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}).
AddRow("ws-root", nil, 0))
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}).
AddRow("ws-root", "", nil, 0))
chain, err := r.walkChain(context.Background(), "ws-root")
if err != nil {
@@ -68,9 +68,9 @@ func TestWalkChain_ChildToParent(t *testing.T) {
mock.ExpectQuery(chainQuerySnippet).
WithArgs("ws-child", maxChainDepth).
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}).
AddRow("ws-child", "ws-root", 0).
AddRow("ws-root", nil, 1))
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}).
AddRow("ws-child", "", "ws-root", 0).
AddRow("ws-root", "", nil, 1))
chain, err := r.walkChain(context.Background(), "ws-child")
if err != nil {
@@ -93,7 +93,7 @@ func TestWalkChain_DeepTreeRespectsMaxDepth(t *testing.T) {
r := New(db)
// Simulate a 51-deep chain: should be capped at maxChainDepth.
rows := sqlmock.NewRows([]string{"id", "parent_id", "depth"})
rows := sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"})
for i := 0; i <= maxChainDepth; i++ {
var parent interface{}
if i < maxChainDepth {
@@ -101,7 +101,7 @@ func TestWalkChain_DeepTreeRespectsMaxDepth(t *testing.T) {
} else {
parent = nil // would be the cap point
}
rows.AddRow("ws-"+itoa(i), parent, i)
rows.AddRow("ws-"+itoa(i), "", parent, i)
}
mock.ExpectQuery(chainQuerySnippet).
WithArgs("ws-0", maxChainDepth).
@@ -127,7 +127,7 @@ func TestWalkChain_WorkspaceNotFound(t *testing.T) {
mock.ExpectQuery(chainQuerySnippet).
WithArgs("ws-missing", maxChainDepth).
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}))
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}))
_, err := r.walkChain(context.Background(), "ws-missing")
if !errors.Is(err, ErrWorkspaceNotFound) {
@@ -172,8 +172,8 @@ func TestWalkChain_RowsErr(t *testing.T) {
mock.ExpectQuery(chainQuerySnippet).
WithArgs("ws-x", maxChainDepth).
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}).
AddRow("ws-x", nil, 0).
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}).
AddRow("ws-x", "", nil, 0).
RowError(0, errors.New("mid-iteration")))
_, err := r.walkChain(context.Background(), "ws-x")
@@ -238,8 +238,8 @@ func TestReadableNamespaces_Root(t *testing.T) {
mock.ExpectQuery(chainQuerySnippet).
WithArgs("root-1", maxChainDepth).
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}).
AddRow("root-1", nil, 0))
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}).
AddRow("root-1", "", nil, 0))
got, err := r.ReadableNamespaces(context.Background(), "root-1")
if err != nil {
@@ -274,9 +274,9 @@ func TestReadableNamespaces_Child(t *testing.T) {
mock.ExpectQuery(chainQuerySnippet).
WithArgs("child-1", maxChainDepth).
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}).
AddRow("child-1", "root-1", 0).
AddRow("root-1", nil, 1))
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}).
AddRow("child-1", "", "root-1", 0).
AddRow("root-1", "", nil, 1))
got, err := r.ReadableNamespaces(context.Background(), "child-1")
if err != nil {
@@ -297,13 +297,93 @@ func TestReadableNamespaces_Child(t *testing.T) {
}
}
func TestReadableNamespaces_DisplayName_Root(t *testing.T) {
// Root workspace with a real name. All three derived namespaces
// (workspace/team/org) should carry the workspace's display name —
// for a root workspace they collapse on UUID but the name is the
// disambiguator surfaced in the canvas dropdown (issue #2988).
db, mock := setupMockDB(t)
r := New(db)
mock.ExpectQuery(chainQuerySnippet).
WithArgs("root-1", maxChainDepth).
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}).
AddRow("root-1", "mac laptop", nil, 0))
got, err := r.ReadableNamespaces(context.Background(), "root-1")
if err != nil {
t.Fatalf("ReadableNamespaces: %v", err)
}
for i, ns := range got {
if ns.DisplayName != "mac laptop" {
t.Errorf("[%d] %q DisplayName = %q, want %q", i, ns.Name, ns.DisplayName, "mac laptop")
}
}
}
func TestReadableNamespaces_DisplayName_Child(t *testing.T) {
// Child has its own workspace name; team should pick up the
// PARENT's name (not the child's), and org follows the chain root.
db, mock := setupMockDB(t)
r := New(db)
mock.ExpectQuery(chainQuerySnippet).
WithArgs("child-1", maxChainDepth).
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}).
AddRow("child-1", "Hongming Personal Brand", "root-1", 0).
AddRow("root-1", "mac laptop", nil, 1))
got, err := r.ReadableNamespaces(context.Background(), "child-1")
if err != nil {
t.Fatalf("ReadableNamespaces: %v", err)
}
want := map[string]string{
"workspace:child-1": "Hongming Personal Brand", // self
"team:root-1": "mac laptop", // parent
"org:root-1": "mac laptop", // root
}
for _, ns := range got {
w, ok := want[ns.Name]
if !ok {
t.Errorf("unexpected namespace %q", ns.Name)
continue
}
if ns.DisplayName != w {
t.Errorf("%q DisplayName = %q, want %q", ns.Name, ns.DisplayName, w)
}
}
}
func TestReadableNamespaces_DisplayName_EmptyOnNULL(t *testing.T) {
// COALESCE in the query produces "" when name is NULL. The
// resolver must propagate that as DisplayName="" so the handler's
// label shaper can fall back to the UUID-prefix shape.
db, mock := setupMockDB(t)
r := New(db)
mock.ExpectQuery(chainQuerySnippet).
WithArgs("root-1", maxChainDepth).
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}).
AddRow("root-1", "", nil, 0))
got, err := r.ReadableNamespaces(context.Background(), "root-1")
if err != nil {
t.Fatalf("ReadableNamespaces: %v", err)
}
for _, ns := range got {
if ns.DisplayName != "" {
t.Errorf("%q DisplayName = %q, want empty (NULL fallback)", ns.Name, ns.DisplayName)
}
}
}
func TestReadableNamespaces_NotFound(t *testing.T) {
db, mock := setupMockDB(t)
r := New(db)
mock.ExpectQuery(chainQuerySnippet).
WithArgs("ghost", maxChainDepth).
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}))
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}))
_, err := r.ReadableNamespaces(context.Background(), "ghost")
if !errors.Is(err, ErrWorkspaceNotFound) {
@@ -319,8 +399,8 @@ func TestWritableNamespaces_RootSeesAll(t *testing.T) {
mock.ExpectQuery(chainQuerySnippet).
WithArgs("root-1", maxChainDepth).
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}).
AddRow("root-1", nil, 0))
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}).
AddRow("root-1", "", nil, 0))
got, err := r.WritableNamespaces(context.Background(), "root-1")
if err != nil {
@@ -337,9 +417,9 @@ func TestWritableNamespaces_ChildExcludesOrg(t *testing.T) {
mock.ExpectQuery(chainQuerySnippet).
WithArgs("child-1", maxChainDepth).
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}).
AddRow("child-1", "root-1", 0).
AddRow("root-1", nil, 1))
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}).
AddRow("child-1", "", "root-1", 0).
AddRow("root-1", "", nil, 1))
got, err := r.WritableNamespaces(context.Background(), "child-1")
if err != nil {
@@ -361,7 +441,7 @@ func TestWritableNamespaces_NotFound(t *testing.T) {
mock.ExpectQuery(chainQuerySnippet).
WithArgs("ghost", maxChainDepth).
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}))
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}))
_, err := r.WritableNamespaces(context.Background(), "ghost")
if !errors.Is(err, ErrWorkspaceNotFound) {
@@ -390,9 +470,9 @@ func TestCanWrite(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
db, mock := setupMockDB(t)
r := New(db)
rows := sqlmock.NewRows([]string{"id", "parent_id", "depth"})
rows := sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"})
if tc.isRoot {
rows.AddRow("root-1", nil, 0)
rows.AddRow("root-1", "", nil, 0)
mock.ExpectQuery(chainQuerySnippet).WithArgs("root-1", maxChainDepth).WillReturnRows(rows)
ok, err := r.CanWrite(context.Background(), "root-1", tc.namespace)
if err != nil {
@@ -402,7 +482,7 @@ func TestCanWrite(t *testing.T) {
t.Errorf("CanWrite(%q) = %v, want %v", tc.namespace, ok, tc.want)
}
} else {
rows.AddRow("child-1", "root-1", 0).AddRow("root-1", nil, 1)
rows.AddRow("child-1", "", "root-1", 0).AddRow("root-1", "", nil, 1)
mock.ExpectQuery(chainQuerySnippet).WithArgs("child-1", maxChainDepth).WillReturnRows(rows)
ok, err := r.CanWrite(context.Background(), "child-1", tc.namespace)
if err != nil {
@@ -435,9 +515,9 @@ func TestIntersectReadable_DefaultAll(t *testing.T) {
r := New(db)
mock.ExpectQuery(chainQuerySnippet).
WithArgs("child-1", maxChainDepth).
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}).
AddRow("child-1", "root-1", 0).
AddRow("root-1", nil, 1))
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}).
AddRow("child-1", "", "root-1", 0).
AddRow("root-1", "", nil, 1))
// Empty requested → return everything readable.
got, err := r.IntersectReadable(context.Background(), "child-1", nil)
@@ -455,9 +535,9 @@ func TestIntersectReadable_Filters(t *testing.T) {
r := New(db)
mock.ExpectQuery(chainQuerySnippet).
WithArgs("child-1", maxChainDepth).
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}).
AddRow("child-1", "root-1", 0).
AddRow("root-1", nil, 1))
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}).
AddRow("child-1", "", "root-1", 0).
AddRow("root-1", "", nil, 1))
// Requested: one allowed, one disallowed (foreign workspace), one allowed
requested := []string{"workspace:child-1", "workspace:foreign", "team:root-1"}
@@ -476,8 +556,8 @@ func TestIntersectReadable_AllFiltered(t *testing.T) {
r := New(db)
mock.ExpectQuery(chainQuerySnippet).
WithArgs("ws-1", maxChainDepth).
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}).
AddRow("ws-1", nil, 0))
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}).
AddRow("ws-1", "", nil, 0))
// Request only namespaces the caller cannot read.
got, err := r.IntersectReadable(context.Background(), "ws-1", []string{"workspace:other", "team:other"})
@@ -0,0 +1,118 @@
// Package messagestore defines the read-side interface and canonical
// data shapes for chat-history retrieval.
//
// Origin: RFC #2945 PR-D (issue #3026). PR-A extracted the WRITE path
// (AgentMessageWriter), PR-B/B-1 typed the WS event taxonomy, PR-C
// centralized read-side parsing in the server. PR-D abstracts the
// underlying storage layer so OSS operators can plug in alternative
// backends without forking the handler.
//
// # Why this package exists
//
// Today's only consumer is ChatHistoryHandler, but exposing storage as
// an interface is what makes the platform's chat-history layer pluggable
// for OSS operators. Operators wanting to:
//
// - Tier hot/warm/cold storage (recent in Postgres, archival in S3 parquet)
// - Use a vector store with hybrid search (Pinecone, Weaviate)
// - Run an in-memory store for ephemeral tests / sandbox tenants
// - Federate history across regions
//
// …implement MessageStore against their backend. The platform-default
// PostgresMessageStore wraps today's activity_logs query + parser
// behavior unchanged.
//
// # Implementation contract
//
// Implementations MUST:
//
// - Return messages newest-first, up to opts.Limit. Caller (the
// handler) is responsible for opts.Limit clamping.
// - Honor opts.BeforeTS as a strict less-than cursor when
// opts.HasBefore is true; ignore it when false. Use HasBefore (not
// a zero-time check) so a legitimate "start of epoch" cursor is
// distinguishable from "no cursor."
// - Set reachedEnd=true when the underlying store has no more
// messages older than the returned page. Caller uses this to
// disable further older-batch fetches in the lazy-load UX.
// - Parse agent-emitted JSON DEFENSIVELY. Any malformed message body
// becomes an empty ChatMessage (or is dropped); never panic, never
// return an error for parse failures alone — chat falls through to
// text-only rather than 500.
// - NEVER log full message bodies, attachment URIs, or anything that
// would be a sensitive screenshot. Workspace ID + activity-log
// row id at DEBUG is the ceiling.
// - Honor ctx cancellation. A canceled ctx must abort the lookup
// and return ctx.Err().
//
// Implementations MAY:
//
// - Cache aggressively (history is read-only).
// - Filter out additional rows beyond what the interface requires
// (e.g., role-based redaction in regulated environments) as long
// as reachedEnd is set conservatively (false if uncertain).
//
// # Threading
//
// Implementations MUST be safe for concurrent calls. The handler
// dispatches a goroutine per request; a non-thread-safe impl would
// race on every chat reload.
package messagestore
import (
"context"
"time"
)
// ChatMessage is the canonical shape returned to chat-history clients.
// Mirrors canvas's ChatMessage TS type so the canvas can render
// without per-row mapping.
//
// ID is server-minted per ChatMessage. Activity-log rows don't carry
// message-shaped ids; canvas dedupes by (role, content, timestamp
// window) not by id, so id stability across requests is not required.
type ChatMessage struct {
ID string `json:"id"`
Role string `json:"role"` // "user" | "agent" | "system"
Content string `json:"content"`
Attachments []ChatAttachment `json:"attachments,omitempty"`
Timestamp string `json:"timestamp"` // RFC3339, pinned to row.created_at
}
// ChatAttachment mirrors canvas ChatAttachment / ParsedFilePart.
// Size is *int64 (not int64) so JSON omits the field when unknown,
// rather than emitting `"size": 0` which the renderer would interpret
// as "zero-byte file."
type ChatAttachment struct {
Name string `json:"name"`
URI string `json:"uri"`
MimeType string `json:"mimeType,omitempty"`
Size *int64 `json:"size,omitempty"`
}
// ListOptions is the page-window the handler hands to the store.
// Constructed by the handler from query parameters; the store should
// not inspect the request directly.
type ListOptions struct {
// Limit is the page size. Caller (the handler) clamps to a sane
// bound (default 100, max 1000); store treats Limit ≤ 0 as a
// programming error.
Limit int
// BeforeTS is the cursor for paginating backward. The store MUST
// only consider this when HasBefore is true; using a zero-time
// fallback would silently exclude the legitimate epoch-start case.
BeforeTS time.Time
HasBefore bool
}
// MessageStore is the read-side interface. Implementations pluggable
// via constructor injection at handler creation time.
//
// Why "List" and not "GetMessages" / "ReadHistory" / etc: List matches
// the verb on /workspaces/:id/chat-history (HTTP GET on a collection)
// and the existing handler method. One-name-one-thing keeps the
// interface and the route lined up.
type MessageStore interface {
List(ctx context.Context, workspaceID string, opts ListOptions) (messages []ChatMessage, reachedEnd bool, err error)
}
@@ -0,0 +1,497 @@
package messagestore
// postgres_store.go — default MessageStore impl that wraps today's
// activity_logs query + the A2A-envelope parser ported in PR-C.
//
// Behavior is byte-identical to the pre-PR-D ChatHistoryHandler:
// same SQL, same role-decision rules, same v0/v1 wire-shape support.
// The only structural change is that the handler now depends on an
// interface; this file is what was the pre-PR-D handler internals.
//
// This is the baseline impl OSS operators compare against when
// writing alternative stores. Read it as the contract spec.
import (
"context"
"database/sql"
"encoding/json"
"path"
"strings"
"time"
"github.com/google/uuid"
)
// PostgresMessageStore is the platform-default impl. It queries the
// activity_logs table directly and parses request_body / response_body
// JSONB columns into ChatMessage values.
type PostgresMessageStore struct {
db *sql.DB
}
// NewPostgresMessageStore wraps a *sql.DB. The store does not own the
// pool — closing it is the caller's responsibility.
func NewPostgresMessageStore(db *sql.DB) *PostgresMessageStore {
return &PostgresMessageStore{db: db}
}
// internalSelfPrefixes — message texts that should be filtered from
// chat history because they're internal self-triggers (heartbeats,
// scheduled-task self-fire, delegation-result self-notify), not
// user-typed messages. Mirrors canvas isInternalSelfMessage.
//
// Centralizing here means a future internal-trigger pattern is added
// in one place; alternative impls of MessageStore are expected to
// apply the same filter (or override deliberately).
var internalSelfPrefixes = []string{
"Delegation results are ready",
}
// IsInternalSelfMessage reports whether text starts with any registered
// internal-self prefix. Empty text returns false (legitimate
// attachments-only bubble). Exported for impls that want to share the
// same predicate.
func IsInternalSelfMessage(text string) bool {
if text == "" {
return false
}
for _, prefix := range internalSelfPrefixes {
if strings.HasPrefix(text, prefix) {
return true
}
}
return false
}
// List implements MessageStore. Newest-first, optionally paged by
// BeforeTS. Filters to a2a_receive activity rows from the canvas
// (source_id IS NULL) — same scope canvas applies via
// /activity?source=canvas, centralized so future API consumers don't
// need to know it.
func (s *PostgresMessageStore) List(ctx context.Context, workspaceID string, opts ListOptions) ([]ChatMessage, bool, error) {
if opts.Limit <= 0 {
// Caller bug. Programmers learn quickly when the store
// fails fast on bad opts; a silent clamp would hide the bug.
return nil, true, errInvalidLimit
}
rows, err := s.queryActivityRows(ctx, workspaceID, opts)
if err != nil {
return nil, false, err
}
defer rows.Close()
var messages []ChatMessage
rowCount := 0
for rows.Next() {
var (
createdAt time.Time
status string
rawRequest sql.NullString
rawResponse sql.NullString
)
if err := rows.Scan(&createdAt, &status, &rawRequest, &rawResponse); err != nil {
// Skip malformed row, continue. The error is logged at
// the caller (handler) layer; an isolated bad row should
// not abort the whole page.
continue
}
rowCount++
var requestBody, responseBody json.RawMessage
if rawRequest.Valid {
requestBody = json.RawMessage(rawRequest.String)
}
if rawResponse.Valid {
responseBody = json.RawMessage(rawResponse.String)
}
messages = append(messages, activityRowToChatMessages(createdAt, status, requestBody, responseBody, IsInternalSelfMessage)...)
}
if err := rows.Err(); err != nil {
return nil, false, err
}
reachedEnd := rowCount < opts.Limit
return messages, reachedEnd, nil
}
// queryActivityRows is split from List so unit tests can exercise the
// parser without spinning a real DB. Internal — alternative impls
// shouldn't depend on the SQL shape.
func (s *PostgresMessageStore) queryActivityRows(ctx context.Context, workspaceID string, opts ListOptions) (*sql.Rows, error) {
if opts.HasBefore {
return s.db.QueryContext(ctx, `
SELECT created_at, status, request_body::text, response_body::text
FROM activity_logs
WHERE workspace_id = $1
AND activity_type = 'a2a_receive'
AND source_id IS NULL
AND created_at < $2
ORDER BY created_at DESC
LIMIT $3
`, workspaceID, opts.BeforeTS, opts.Limit)
}
return s.db.QueryContext(ctx, `
SELECT created_at, status, request_body::text, response_body::text
FROM activity_logs
WHERE workspace_id = $1
AND activity_type = 'a2a_receive'
AND source_id IS NULL
ORDER BY created_at DESC
LIMIT $2
`, workspaceID, opts.Limit)
}
// errInvalidLimit is returned by List when opts.Limit ≤ 0.
type sentinelError string
func (e sentinelError) Error() string { return string(e) }
const errInvalidLimit sentinelError = "messagestore: List opts.Limit must be > 0"
// activityRowToChatMessages converts ONE activity_logs row into 0-2
// ChatMessages. Direct port of canvas activityRowToMessages.
//
// - Up to 1 user-side bubble from request_body, unless internal-self.
// - Up to 1 agent-side bubble from response_body. Role is "system"
// when status='error' OR text starts with "agent error" (case-
// insensitive — matches canvas predicate exactly).
//
// Both bubbles MUST adopt row.created_at as their timestamp. This
// pins the regression cover for the 2026-04-25 bubble-collapse bug.
func activityRowToChatMessages(
createdAt time.Time,
status string,
requestBody json.RawMessage,
responseBody json.RawMessage,
internalSelf func(string) bool,
) []ChatMessage {
var out []ChatMessage
timestamp := createdAt.UTC().Format(time.RFC3339Nano)
userText := extractRequestText(requestBody)
userAttachments := extractFilesFromUserMessage(requestBody)
if !internalSelf(userText) && (userText != "" || len(userAttachments) > 0) {
out = append(out, ChatMessage{
ID: newMessageID(),
Role: "user",
Content: userText,
Attachments: userAttachments,
Timestamp: timestamp,
})
}
if len(responseBody) > 0 {
agentText := extractChatResponseText(responseBody)
agentAttachments := extractFilesFromResponse(responseBody)
if agentText != "" || len(agentAttachments) > 0 {
role := "agent"
if status == "error" || strings.HasPrefix(strings.ToLower(agentText), "agent error") {
role = "system"
}
out = append(out, ChatMessage{
ID: newMessageID(),
Role: role,
Content: agentText,
Attachments: agentAttachments,
Timestamp: timestamp,
})
}
}
return out
}
// extractRequestText pulls the user's typed text from
// request_body.params.message.parts[0].text. Returns "" on any
// malformed shape; callers pair with extractFilesFromUserMessage to
// catch attachments-only bubbles.
func extractRequestText(body json.RawMessage) string {
if len(body) == 0 {
return ""
}
var env struct {
Params struct {
Message struct {
Parts []map[string]any `json:"parts"`
} `json:"message"`
} `json:"params"`
}
if err := json.Unmarshal(body, &env); err != nil {
return ""
}
for _, p := range env.Params.Message.Parts {
if t, ok := p["text"].(string); ok && t != "" {
return t
}
}
return ""
}
func extractFilesFromUserMessage(body json.RawMessage) []ChatAttachment {
if len(body) == 0 {
return nil
}
var env struct {
Params struct {
Message json.RawMessage `json:"message"`
} `json:"params"`
}
if err := json.Unmarshal(body, &env); err != nil {
return nil
}
if len(env.Params.Message) == 0 {
return nil
}
return extractFilesFromTask(env.Params.Message)
}
// extractChatResponseText collects text from any of the response
// shapes canvas extractResponseText handles, joining with "\n":
//
// - {"result": "<text>"}
// - {"result": {"parts": [{"kind":"text","text":""}]}}
// - {"parts": [{"root": {"text": "..."}}]} (older nested)
// - {"result": {"artifacts": [{"parts": [...]}]}} (task shape)
// - {"task": "<text>"} (fallback)
//
// Why collect rather than first-source-wins: claude-code emits
// multiple text parts; hermes emits summary-in-parts +
// details-in-artifacts. The pre-collect first-wins silently
// truncated 15k-char briefs and dropped artifact details.
func extractChatResponseText(body json.RawMessage) string {
if len(body) == 0 {
return ""
}
// {"result": "string"}
var asString struct {
Result string `json:"result"`
}
if err := json.Unmarshal(body, &asString); err == nil && asString.Result != "" {
return asString.Result
}
// {"result": {object}} — try the structured shapes
var asObject struct {
Result json.RawMessage `json:"result"`
Task string `json:"task"`
}
if err := json.Unmarshal(body, &asObject); err != nil {
return ""
}
var collected []string
if len(asObject.Result) > 0 {
var resultObj struct {
Parts []map[string]any `json:"parts"`
Artifacts []json.RawMessage `json:"artifacts"`
}
if err := json.Unmarshal(asObject.Result, &resultObj); err == nil {
if t := joinTextParts(resultObj.Parts); t != "" {
collected = append(collected, t)
}
var rootTexts []string
for _, p := range resultObj.Parts {
if root, ok := p["root"].(map[string]any); ok {
if t, ok := root["text"].(string); ok && t != "" {
rootTexts = append(rootTexts, t)
}
}
}
if len(rootTexts) > 0 {
collected = append(collected, strings.Join(rootTexts, "\n"))
}
for _, raw := range resultObj.Artifacts {
var art struct {
Parts []map[string]any `json:"parts"`
}
if err := json.Unmarshal(raw, &art); err == nil {
if t := joinTextParts(art.Parts); t != "" {
collected = append(collected, t)
}
}
}
}
}
if len(collected) > 0 {
return strings.Join(collected, "\n")
}
if asObject.Task != "" {
return asObject.Task
}
return ""
}
func joinTextParts(parts []map[string]any) string {
var texts []string
for _, p := range parts {
isText := false
if k, ok := p["kind"].(string); ok && k == "text" {
isText = true
}
if t, ok := p["type"].(string); ok && t == "text" {
isText = true
}
if !isText {
continue
}
if t, ok := p["text"].(string); ok && t != "" {
texts = append(texts, t)
}
}
return strings.Join(texts, "\n")
}
func extractFilesFromResponse(body json.RawMessage) []ChatAttachment {
if len(body) == 0 {
return nil
}
var probe struct {
Result json.RawMessage `json:"result"`
}
_ = json.Unmarshal(body, &probe)
feed := body
if len(probe.Result) > 0 {
trimmed := bytesTrimSpace(probe.Result)
if len(trimmed) > 0 && trimmed[0] == '{' {
feed = probe.Result
}
}
return extractFilesFromTask(feed)
}
// extractFilesFromTask walks parts[] + artifacts[].parts[] +
// status.message.parts[] + message.parts[]. Mirrors canvas
// extractFilesFromTask exactly — same v0 hot path + v1 protobuf
// flat shape.
func extractFilesFromTask(taskJSON json.RawMessage) []ChatAttachment {
if len(taskJSON) == 0 {
return nil
}
var task struct {
Parts []map[string]any `json:"parts"`
Artifacts []json.RawMessage `json:"artifacts"`
Status json.RawMessage `json:"status"`
Message json.RawMessage `json:"message"`
}
if err := json.Unmarshal(taskJSON, &task); err != nil {
return nil
}
var out []ChatAttachment
out = appendFilesFromParts(out, task.Parts)
for _, raw := range task.Artifacts {
var art struct {
Parts []map[string]any `json:"parts"`
}
if err := json.Unmarshal(raw, &art); err == nil {
out = appendFilesFromParts(out, art.Parts)
}
}
if len(task.Status) > 0 {
var st struct {
Message struct {
Parts []map[string]any `json:"parts"`
} `json:"message"`
}
if err := json.Unmarshal(task.Status, &st); err == nil {
out = appendFilesFromParts(out, st.Message.Parts)
}
}
if len(task.Message) > 0 {
var msg struct {
Parts []map[string]any `json:"parts"`
}
if err := json.Unmarshal(task.Message, &msg); err == nil {
out = appendFilesFromParts(out, msg.Parts)
}
}
return out
}
func appendFilesFromParts(out []ChatAttachment, parts []map[string]any) []ChatAttachment {
for _, raw := range parts {
v0 := false
if k, ok := raw["kind"].(string); ok && k == "file" {
v0 = true
}
if t, ok := raw["type"].(string); ok && t == "file" {
v0 = true
}
v1URL, _ := raw["url"].(string)
if !v0 && v1URL == "" {
continue
}
var att ChatAttachment
if v0 {
file, _ := raw["file"].(map[string]any)
if file == nil {
file = raw
}
uri, _ := file["uri"].(string)
if uri == "" {
continue
}
att.URI = uri
if name, _ := file["name"].(string); name != "" {
att.Name = name
} else {
att.Name = basename(uri)
}
if mt, ok := file["mimeType"].(string); ok {
att.MimeType = mt
}
if sz, ok := numericSize(file["size"]); ok {
att.Size = &sz
}
} else {
att.URI = v1URL
if name, _ := raw["filename"].(string); name != "" {
att.Name = name
} else {
att.Name = basename(v1URL)
}
if mt, ok := raw["mediaType"].(string); ok {
att.MimeType = mt
}
}
out = append(out, att)
}
return out
}
func numericSize(v any) (int64, bool) {
switch n := v.(type) {
case float64:
return int64(n), true
case int64:
return n, true
case int:
return int64(n), true
}
return 0, false
}
func basename(uri string) string {
cleaned := strings.TrimPrefix(uri, "workspace:")
cleaned = strings.TrimPrefix(cleaned, "https://")
cleaned = strings.TrimPrefix(cleaned, "http://")
if cleaned == "" {
return "file"
}
return path.Base(cleaned)
}
func bytesTrimSpace(b json.RawMessage) json.RawMessage {
for len(b) > 0 && (b[0] == ' ' || b[0] == '\t' || b[0] == '\n' || b[0] == '\r') {
b = b[1:]
}
for len(b) > 0 && (b[len(b)-1] == ' ' || b[len(b)-1] == '\t' || b[len(b)-1] == '\n' || b[len(b)-1] == '\r') {
b = b[:len(b)-1]
}
return b
}
func newMessageID() string {
return uuid.New().String()
}
// Compile-time assertion: PostgresMessageStore satisfies MessageStore.
// Catches any future drift between interface and impl at build time.
var _ MessageStore = (*PostgresMessageStore)(nil)
@@ -0,0 +1,422 @@
package messagestore
// postgres_store_test.go — parser-level parity tests against the
// canvas TS test fixtures in
// canvas/src/components/tabs/chat/__tests__/historyHydration.test.ts.
//
// Originally lived in handlers/chat_history_test.go (RFC #2945 PR-C);
// PR-D moved them here when the parser was extracted to this package.
// Every test case in the TS file has a Go counterpart, named after
// the TS describe/it block.
//
// Mutation guidance: when adding behavior, add the case to BOTH
// historyHydration.test.ts AND this file. The canvas TS is the
// legacy source the server replaces; divergence == regression.
import (
"encoding/json"
"strings"
"testing"
"time"
)
const fixedTimestamp = "2026-04-25T18:00:00Z"
func mustParseTime(t *testing.T, s string) time.Time {
t.Helper()
tt, err := time.Parse(time.RFC3339, s)
if err != nil {
t.Fatalf("parse %s: %v", s, err)
}
return tt
}
func neverInternal(_ string) bool { return false }
// =====================================================================
// timestamp preservation (regression cover)
//
// The canvas bug that motivated extracting the helper: every reload
// re-stamped historical bubbles to render-time. Pin row.created_at
// adoption.
// =====================================================================
func TestChatHistory_UserMessageTimestampPinsToCreatedAt(t *testing.T) {
created := mustParseTime(t, "2026-04-25T18:00:00Z")
body := json.RawMessage(`{"params":{"message":{"parts":[{"kind":"text","text":"hello from earlier today"}]}}}`)
msgs := activityRowToChatMessages(created, "ok", body, nil, neverInternal)
if len(msgs) != 1 {
t.Fatalf("expected 1 user message, got %d", len(msgs))
}
if msgs[0].Role != "user" {
t.Errorf("role=%q want user", msgs[0].Role)
}
if !strings.HasPrefix(msgs[0].Timestamp, "2026-04-25T18:00:00") {
t.Errorf("user message timestamp %q does NOT pin to row.created_at — regression of the 2026-04-25 bubble-collapse bug", msgs[0].Timestamp)
}
}
func TestChatHistory_AgentMessageTimestampPinsToCreatedAt(t *testing.T) {
created := mustParseTime(t, "2026-04-25T18:05:00Z")
body := json.RawMessage(`{"result":"agent reply"}`)
msgs := activityRowToChatMessages(created, "ok", nil, body, neverInternal)
if len(msgs) != 1 {
t.Fatalf("expected 1 agent message, got %d", len(msgs))
}
if msgs[0].Role != "agent" {
t.Errorf("role=%q want agent", msgs[0].Role)
}
if !strings.HasPrefix(msgs[0].Timestamp, "2026-04-25T18:05:00") {
t.Errorf("agent message timestamp %q does NOT pin to row.created_at", msgs[0].Timestamp)
}
}
func TestChatHistory_TwoRowsDistinctTimestamps(t *testing.T) {
bodyA := json.RawMessage(`{"params":{"message":{"parts":[{"kind":"text","text":"first"}]}}}`)
bodyB := json.RawMessage(`{"params":{"message":{"parts":[{"kind":"text","text":"second"}]}}}`)
a := activityRowToChatMessages(mustParseTime(t, "2026-04-25T14:00:00Z"), "ok", bodyA, nil, neverInternal)
b := activityRowToChatMessages(mustParseTime(t, "2026-04-25T21:01:58Z"), "ok", bodyB, nil, neverInternal)
if len(a) != 1 || len(b) != 1 {
t.Fatalf("expected 1 message each; got %d and %d", len(a), len(b))
}
if a[0].Timestamp == b[0].Timestamp {
t.Errorf("two distinct created_at values produced same timestamp: %q", a[0].Timestamp)
}
if !strings.HasPrefix(a[0].Timestamp, "2026-04-25T14:00:00") || !strings.HasPrefix(b[0].Timestamp, "2026-04-25T21:01:58") {
t.Errorf("timestamps drifted: a=%q b=%q", a[0].Timestamp, b[0].Timestamp)
}
}
// =====================================================================
// user-message extraction
// =====================================================================
func TestChatHistory_EmitsUserMessageWhenRequestHasText(t *testing.T) {
body := json.RawMessage(`{"params":{"message":{"parts":[{"kind":"text","text":"hi agent"}]}}}`)
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", body, nil, neverInternal)
if len(msgs) != 1 {
t.Fatalf("expected 1 message, got %d", len(msgs))
}
if msgs[0].Role != "user" || msgs[0].Content != "hi agent" {
t.Errorf("role=%q content=%q want user/hi agent", msgs[0].Role, msgs[0].Content)
}
}
func TestChatHistory_DropsInternalSelfMessages(t *testing.T) {
body := json.RawMessage(`{"params":{"message":{"parts":[{"kind":"text","text":"Delegation results are ready..."}]}}}`)
predicate := func(t string) bool { return strings.HasPrefix(t, "Delegation results are ready") }
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", body, nil, predicate)
for _, m := range msgs {
if m.Role == "user" {
t.Errorf("internal-self message rendered as user bubble: %q", m.Content)
}
}
}
func TestChatHistory_NoUserMessageWhenRequestBodyNull(t *testing.T) {
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", nil, nil, neverInternal)
for _, m := range msgs {
if m.Role == "user" {
t.Errorf("emitted user bubble despite null request_body: %+v", m)
}
}
}
func TestChatHistory_UserAttachmentsHydratedFromRequestBody(t *testing.T) {
body := json.RawMessage(`{
"params": {
"message": {
"parts": [
{"kind":"text","text":"here's the screenshot"},
{"kind":"file","file":{"name":"shot.png","mimeType":"image/png","uri":"workspace:/uploads/shot.png","size":4096}}
]
}
}
}`)
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", body, nil, neverInternal)
var user *ChatMessage
for i := range msgs {
if msgs[i].Role == "user" {
user = &msgs[i]
break
}
}
if user == nil {
t.Fatalf("no user bubble produced")
}
if user.Content != "here's the screenshot" {
t.Errorf("content=%q", user.Content)
}
if len(user.Attachments) != 1 {
t.Fatalf("attachments=%d want 1", len(user.Attachments))
}
att := user.Attachments[0]
if att.Name != "shot.png" || att.URI != "workspace:/uploads/shot.png" || att.MimeType != "image/png" {
t.Errorf("attachment shape wrong: %+v", att)
}
if att.Size == nil || *att.Size != 4096 {
t.Errorf("size=%v want 4096", att.Size)
}
}
func TestChatHistory_AttachmentsOnlyUserBubbleWhenTextEmpty(t *testing.T) {
// Drag-drop a file with no caption — bubble should still render.
body := json.RawMessage(`{
"params": {
"message": {
"parts": [
{"kind":"file","file":{"name":"report.pdf","uri":"workspace:/uploads/report.pdf"}}
]
}
}
}`)
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", body, nil, neverInternal)
if len(msgs) != 1 {
t.Fatalf("expected 1 attachments-only bubble, got %d", len(msgs))
}
if msgs[0].Role != "user" || msgs[0].Content != "" || len(msgs[0].Attachments) != 1 {
t.Errorf("unexpected: role=%q content=%q attachments=%d", msgs[0].Role, msgs[0].Content, len(msgs[0].Attachments))
}
if msgs[0].Attachments[0].Name != "report.pdf" {
t.Errorf("attachment name=%q want report.pdf", msgs[0].Attachments[0].Name)
}
}
func TestChatHistory_InternalSelfPredicateSuppressesEvenWithAttachments(t *testing.T) {
body := json.RawMessage(`{
"params": {
"message": {
"parts": [
{"kind":"text","text":"Delegation results are ready..."},
{"kind":"file","file":{"name":"x.zip","uri":"workspace:/x.zip"}}
]
}
}
}`)
predicate := func(t string) bool { return strings.HasPrefix(t, "Delegation results are ready") }
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", body, nil, predicate)
for _, m := range msgs {
if m.Role == "user" {
t.Errorf("internal-self predicate did NOT suppress user bubble despite attachments: %+v", m)
}
}
}
// =====================================================================
// agent-message extraction
// =====================================================================
func TestChatHistory_AgentMessageFromResultString(t *testing.T) {
body := json.RawMessage(`{"result":"agent says hi"}`)
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", nil, body, neverInternal)
if len(msgs) != 1 || msgs[0].Role != "agent" || msgs[0].Content != "agent says hi" {
t.Errorf("got %+v", msgs)
}
}
func TestChatHistory_RoleSystemWhenStatusError(t *testing.T) {
body := json.RawMessage(`{"result":"delegation failed"}`)
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "error", nil, body, neverInternal)
if len(msgs) != 1 || msgs[0].Role != "system" {
t.Errorf("status=error did NOT promote role to system: %+v", msgs)
}
}
func TestChatHistory_RoleSystemWhenAgentErrorPrefix(t *testing.T) {
// Defense-in-depth — if a runtime returns ok status but the text
// itself starts with "agent error", the canvas would still
// render system role. Mirror that here.
body := json.RawMessage(`{"result":"Agent error: ProcessError(exit=1)"}`)
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", nil, body, neverInternal)
if len(msgs) != 1 || msgs[0].Role != "system" {
t.Errorf("agent-error prefix did NOT promote to system: %+v", msgs)
}
}
func TestChatHistory_AgentAttachmentsFromResponseBodyParts(t *testing.T) {
// Notify shape: response_body = {"result":"<text>","parts":[{"kind":"file",...}]}
body := json.RawMessage(`{
"result": "Done — see attached.",
"parts": [
{"kind":"file","file":{"name":"build.zip","uri":"workspace:/tmp/build.zip","size":12345}}
]
}`)
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", nil, body, neverInternal)
var agent *ChatMessage
for i := range msgs {
if msgs[i].Role == "agent" {
agent = &msgs[i]
break
}
}
if agent == nil {
t.Fatalf("no agent bubble")
}
if len(agent.Attachments) != 1 || agent.Attachments[0].Name != "build.zip" {
t.Errorf("agent attachments shape wrong: %+v", agent.Attachments)
}
if agent.Attachments[0].Size == nil || *agent.Attachments[0].Size != 12345 {
t.Errorf("size=%v want 12345", agent.Attachments[0].Size)
}
}
func TestChatHistory_NoAgentMessageWhenResponseBodyNull(t *testing.T) {
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", nil, nil, neverInternal)
for _, m := range msgs {
if m.Role == "agent" || m.Role == "system" {
t.Errorf("emitted agent/system bubble despite null response_body: %+v", m)
}
}
}
func TestChatHistory_NoAgentMessageWhenResponseHasNoTextNoFiles(t *testing.T) {
body := json.RawMessage(`{"unrelated":"metadata"}`)
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", nil, body, neverInternal)
for _, m := range msgs {
if m.Role == "agent" {
t.Errorf("emitted agent bubble despite empty content: %+v", m)
}
}
}
// =====================================================================
// end-to-end shape — paired user + agent with same timestamp
// =====================================================================
func TestChatHistory_PairedUserAndAgentSameTimestamp(t *testing.T) {
created := mustParseTime(t, "2026-04-25T18:00:00Z")
req := json.RawMessage(`{"params":{"message":{"parts":[{"kind":"text","text":"what's 2+2?"}]}}}`)
resp := json.RawMessage(`{"result":"4"}`)
msgs := activityRowToChatMessages(created, "ok", req, resp, neverInternal)
if len(msgs) != 2 {
t.Fatalf("expected 2 messages, got %d", len(msgs))
}
if msgs[0].Role != "user" || msgs[0].Content != "what's 2+2?" {
t.Errorf("first message wrong: %+v", msgs[0])
}
if msgs[1].Role != "agent" || msgs[1].Content != "4" {
t.Errorf("second message wrong: %+v", msgs[1])
}
if msgs[0].Timestamp != msgs[1].Timestamp {
t.Errorf("paired bubbles have different timestamps: %q vs %q", msgs[0].Timestamp, msgs[1].Timestamp)
}
}
// =====================================================================
// Go-specific: defensive parsing
// =====================================================================
func TestChatHistory_MalformedJSONInRequestBodyReturnsEmpty(t *testing.T) {
// Should NOT panic; should return no user bubble (or no message at all).
body := json.RawMessage(`{not valid json}`)
defer func() {
if r := recover(); r != nil {
t.Fatalf("panic on malformed json: %v", r)
}
}()
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", body, nil, neverInternal)
for _, m := range msgs {
if m.Role == "user" && (m.Content != "" || len(m.Attachments) > 0) {
t.Errorf("malformed JSON yielded a non-empty user bubble: %+v", m)
}
}
}
func TestChatHistory_V1ProtobufFlatFileShape(t *testing.T) {
// v1 a2a-sdk shape: flat parts with url/filename/mediaType
body := json.RawMessage(`{
"result": {
"parts": [
{"url":"https://example.com/data.csv","filename":"data.csv","mediaType":"text/csv"}
]
}
}`)
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", nil, body, neverInternal)
var agent *ChatMessage
for i := range msgs {
if msgs[i].Role == "agent" {
agent = &msgs[i]
break
}
}
if agent == nil {
t.Fatalf("no agent bubble for v1 shape")
}
if len(agent.Attachments) != 1 {
t.Fatalf("attachments=%d want 1", len(agent.Attachments))
}
att := agent.Attachments[0]
if att.Name != "data.csv" || att.URI != "https://example.com/data.csv" || att.MimeType != "text/csv" {
t.Errorf("v1 shape extracted wrong: %+v", att)
}
}
func TestChatHistory_TaskShapeArtifactsExtracted(t *testing.T) {
// {"result":{"artifacts":[{"parts":[{"kind":"text","text":"..."}]}]}}
body := json.RawMessage(`{
"result": {
"artifacts": [
{"parts": [{"kind":"text","text":"hermes detail line"}]}
]
}
}`)
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", nil, body, neverInternal)
if len(msgs) != 1 || msgs[0].Content != "hermes detail line" {
t.Errorf("artifact text not extracted: %+v", msgs)
}
}
func TestChatHistory_OlderNestedRootTextShape(t *testing.T) {
// Older shape: {parts: [{root: {text: "..."}}]}
body := json.RawMessage(`{
"result": {
"parts": [{"root":{"text":"legacy nested text"}}]
}
}`)
msgs := activityRowToChatMessages(mustParseTime(t, fixedTimestamp), "ok", nil, body, neverInternal)
if len(msgs) != 1 || !strings.Contains(msgs[0].Content, "legacy nested text") {
t.Errorf("nested root.text not extracted: %+v", msgs)
}
}
// =====================================================================
// IsInternalSelfMessage predicate itself
// =====================================================================
func TestChatHistory_IsInternalSelfMessage_DelegationPrefix(t *testing.T) {
if !IsInternalSelfMessage("Delegation results are ready... <body>") {
t.Errorf("Delegation-results prefix should be flagged internal-self")
}
if IsInternalSelfMessage("Delegation completed but not ready") {
t.Errorf("non-prefix match should NOT flag")
}
if IsInternalSelfMessage("") {
t.Errorf("empty text should NOT flag (legitimate attachments-only bubble)")
}
}
// =====================================================================
// basename helper — mirrors canvas basename() semantics
// =====================================================================
func TestChatHistory_BasenameStripsSchemeAndPath(t *testing.T) {
cases := []struct {
in, want string
}{
{"workspace:/uploads/shot.png", "shot.png"},
{"workspace:/a/b/c/file.txt", "file.txt"},
{"https://example.com/path/file.csv", "file.csv"},
{"http://x/y", "y"},
{"", "file"},
{"workspace:", "file"}, // scheme-only collapses to "" → "file" sentinel, matches canvas basename
}
for _, tc := range cases {
got := basename(tc.in)
if got != tc.want {
t.Errorf("basename(%q) = %q want %q", tc.in, got, tc.want)
}
}
}
@@ -119,6 +119,18 @@ type Storage interface {
// the whole batch succeeds or the user re-uploads.
PutBatch(ctx context.Context, workspaceID uuid.UUID, items []PutItem) ([]uuid.UUID, error)
// PutBatchTx is the Tx-aware variant of PutBatch. It runs its INSERTs
// inside the caller-provided tx so multi-file uploads can commit
// atomically with sibling writes (e.g. activity_logs rows in
// chat_files uploadPollMode). Pre-input validation runs before any
// DB work; on validation failure no INSERT is issued.
//
// Caller owns the Tx lifecycle: BeginTx before, Commit/Rollback
// after. PutBatchTx does NOT call Commit — a successful return only
// means the inserts queued cleanly inside the Tx. The caller's
// Commit is what actually persists the rows.
PutBatchTx(ctx context.Context, tx *sql.Tx, workspaceID uuid.UUID, items []PutItem) ([]uuid.UUID, error)
// Get returns the full row including content. Returns ErrNotFound
// when the row is absent, acked, or past expires_at. Caller should
// not differentiate the three cases in the response — from the
@@ -207,19 +219,8 @@ func (p *PostgresStorage) PutBatch(ctx context.Context, workspaceID uuid.UUID, i
if len(items) == 0 {
return nil, nil
}
for i, it := range items {
if len(it.Content) == 0 {
return nil, fmt.Errorf("pendinguploads: item %d: empty content", i)
}
if len(it.Content) > MaxFileBytes {
return nil, ErrTooLarge
}
if it.Filename == "" {
return nil, fmt.Errorf("pendinguploads: item %d: empty filename", i)
}
if len(it.Filename) > 100 {
return nil, fmt.Errorf("pendinguploads: item %d: filename exceeds 100 chars", i)
}
if err := validatePutBatchItems(items); err != nil {
return nil, err
}
tx, err := p.db.BeginTx(ctx, nil)
@@ -232,6 +233,53 @@ func (p *PostgresStorage) PutBatch(ctx context.Context, workspaceID uuid.UUID, i
_ = tx.Rollback()
}()
out, err := putBatchInsertRows(ctx, tx, workspaceID, items)
if err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("pendinguploads: commit batch: %w", err)
}
return out, nil
}
// PutBatchTx runs the same INSERT sequence as PutBatch but inside the
// caller's tx. The caller is responsible for Commit/Rollback. Pre-input
// validation still happens; on validation failure the tx is left in
// whatever state it had (the caller will typically Rollback). On a
// per-row INSERT error the caller MUST Rollback — pending_uploads rows
// already inserted in this tx (rows 0..i-1) are not yet visible and
// disappear with the rollback.
func (p *PostgresStorage) PutBatchTx(ctx context.Context, tx *sql.Tx, workspaceID uuid.UUID, items []PutItem) ([]uuid.UUID, error) {
if len(items) == 0 {
return nil, nil
}
if err := validatePutBatchItems(items); err != nil {
return nil, err
}
return putBatchInsertRows(ctx, tx, workspaceID, items)
}
func validatePutBatchItems(items []PutItem) error {
for i, it := range items {
if len(it.Content) == 0 {
return fmt.Errorf("pendinguploads: item %d: empty content", i)
}
if len(it.Content) > MaxFileBytes {
return ErrTooLarge
}
if it.Filename == "" {
return fmt.Errorf("pendinguploads: item %d: empty filename", i)
}
if len(it.Filename) > 100 {
return fmt.Errorf("pendinguploads: item %d: filename exceeds 100 chars", i)
}
}
return nil
}
func putBatchInsertRows(ctx context.Context, tx *sql.Tx, workspaceID uuid.UUID, items []PutItem) ([]uuid.UUID, error) {
out := make([]uuid.UUID, 0, len(items))
for i, it := range items {
var fid uuid.UUID
@@ -245,10 +293,6 @@ func (p *PostgresStorage) PutBatch(ctx context.Context, workspaceID uuid.UUID, i
}
out = append(out, fid)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("pendinguploads: commit batch: %w", err)
}
return out, nil
}
@@ -731,3 +731,138 @@ func TestPutBatch_CommitError_Wrapped(t *testing.T) {
t.Errorf("expectations: %v", err)
}
}
// ----- PutBatchTx ----------------------------------------------------------
//
// PutBatchTx is the Tx-aware variant added in #149 so chat_files
// uploadPollMode can commit pending_uploads + activity_logs atomically
// in one Tx. Pre-validation is shared with PutBatch (extracted into
// validatePutBatchItems); these tests pin the contract that PutBatchTx
// runs INSERTs in the caller's tx and never calls Begin/Commit itself.
func TestPutBatchTx_HappyPath_RowsInsertedInTx_NoCommitFromHere(t *testing.T) {
db, mock := newMockDB(t)
store := pendinguploads.NewPostgres(db)
wsID := uuid.New()
id1, id2 := uuid.New(), uuid.New()
mock.ExpectBegin()
mock.ExpectQuery(insertSQL).
WithArgs(wsID, []byte("aaa"), int64(3), "a.txt", "text/plain").
WillReturnRows(sqlmock.NewRows([]string{"file_id"}).AddRow(id1))
mock.ExpectQuery(insertSQL).
WithArgs(wsID, []byte("bbbb"), int64(4), "b.bin", "application/octet-stream").
WillReturnRows(sqlmock.NewRows([]string{"file_id"}).AddRow(id2))
mock.ExpectCommit()
tx, err := db.BeginTx(context.Background(), nil)
if err != nil {
t.Fatalf("BeginTx: %v", err)
}
got, err := store.PutBatchTx(context.Background(), tx, wsID, []pendinguploads.PutItem{
{Content: []byte("aaa"), Filename: "a.txt", Mimetype: "text/plain"},
{Content: []byte("bbbb"), Filename: "b.bin", Mimetype: "application/octet-stream"},
})
if err != nil {
t.Fatalf("PutBatchTx: %v", err)
}
if len(got) != 2 || got[0] != id1 || got[1] != id2 {
t.Errorf("ids out of order: got %v want [%s %s]", got, id1, id2)
}
// Caller is responsible for Commit — PutBatchTx must NOT have called it.
if err := tx.Commit(); err != nil {
t.Fatalf("caller Commit: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("expectations: %v", err)
}
}
func TestPutBatchTx_EmptyItems_NoDBWork(t *testing.T) {
db, mock := newMockDB(t)
store := pendinguploads.NewPostgres(db)
// No expectations — PutBatchTx with empty items must short-circuit
// before any tx interaction.
got, err := store.PutBatchTx(context.Background(), nil, uuid.New(), nil)
if err != nil {
t.Fatalf("expected nil error on empty batch, got %v", err)
}
if got != nil {
t.Errorf("expected nil ids on empty batch, got %v", got)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("expectations: %v", err)
}
}
func TestPutBatchTx_ValidationFails_NoTxQuery(t *testing.T) {
db, mock := newMockDB(t)
store := pendinguploads.NewPostgres(db)
// Caller opens the Tx; PutBatchTx must reject the invalid item
// before issuing any tx.QueryRowContext. Rollback comes from the
// caller's defer, not from PutBatchTx.
mock.ExpectBegin()
mock.ExpectRollback()
tx, err := db.BeginTx(context.Background(), nil)
if err != nil {
t.Fatalf("BeginTx: %v", err)
}
_, err = store.PutBatchTx(context.Background(), tx, uuid.New(), []pendinguploads.PutItem{
{Content: []byte("hi"), Filename: ""},
})
if err == nil || !strings.Contains(err.Error(), "empty filename") {
t.Fatalf("expected empty-filename error, got %v", err)
}
if err := tx.Rollback(); err != nil {
t.Fatalf("Rollback: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("expectations: %v", err)
}
}
func TestPutBatchTx_PerRowErrorPropagates_CallerRollsBack(t *testing.T) {
// PutBatchTx returns an error on per-row INSERT failure but does
// NOT call Rollback itself — that's the caller's job. This pins
// the Tx-lifecycle ownership contract: the caller controls Begin
// and Rollback/Commit, PutBatchTx only runs INSERTs.
db, mock := newMockDB(t)
store := pendinguploads.NewPostgres(db)
wsID := uuid.New()
id1 := uuid.New()
mock.ExpectBegin()
mock.ExpectQuery(insertSQL).
WithArgs(wsID, []byte("ok"), int64(2), "a.txt", "text/plain").
WillReturnRows(sqlmock.NewRows([]string{"file_id"}).AddRow(id1))
mock.ExpectQuery(insertSQL).
WithArgs(wsID, []byte("xx"), int64(2), "b.txt", "text/plain").
WillReturnError(errors.New("connection lost mid-insert"))
mock.ExpectRollback()
tx, err := db.BeginTx(context.Background(), nil)
if err != nil {
t.Fatalf("BeginTx: %v", err)
}
_, err = store.PutBatchTx(context.Background(), tx, wsID, []pendinguploads.PutItem{
{Content: []byte("ok"), Filename: "a.txt", Mimetype: "text/plain"},
{Content: []byte("xx"), Filename: "b.txt", Mimetype: "text/plain"},
})
if err == nil || !strings.Contains(err.Error(), "batch insert item 1") {
t.Fatalf("expected wrapped item-1 error, got %v", err)
}
if err := tx.Rollback(); err != nil {
t.Fatalf("caller Rollback: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("expectations: %v", err)
}
}
@@ -2,6 +2,7 @@ package pendinguploads_test
import (
"context"
"database/sql"
"errors"
"sync/atomic"
"testing"
@@ -47,6 +48,9 @@ func (f *fakeSweepStorage) Ack(_ context.Context, _ uuid.UUID) error {
func (f *fakeSweepStorage) PutBatch(_ context.Context, _ uuid.UUID, _ []pendinguploads.PutItem) ([]uuid.UUID, error) {
return nil, errors.New("not used")
}
func (f *fakeSweepStorage) PutBatchTx(_ context.Context, _ *sql.Tx, _ uuid.UUID, _ []pendinguploads.PutItem) ([]uuid.UUID, error) {
return nil, errors.New("not used")
}
func (f *fakeSweepStorage) Sweep(_ context.Context, ackRetention time.Duration) (pendinguploads.SweepResult, error) {
idx := int(f.calls.Load())
f.calls.Add(1)
@@ -35,36 +35,37 @@ import (
// drift-risk #6.
var ErrNoBackend = errors.New("provisioner: no backend configured (zero-valued receiver)")
// RuntimeImages maps runtime names to their Docker image refs on GHCR.
// RuntimeImages maps runtime names to their Docker image refs.
// Each standalone template repo publishes its image via the reusable
// publish-template-image workflow in molecule-ci on every main merge.
// The provisioner pulls these on demand (see ensureImageLocal) — no
// pre-build step on the tenant host.
//
// The registry prefix is determined by RegistryPrefix() in registry.go;
// defaults to ghcr.io/molecule-ai (upstream OSS) and is overridden via the
// MOLECULE_IMAGE_REGISTRY env var in production tenants that mirror to
// AWS ECR or another registry. The map is computed at package init and
// captures whatever prefix was active then.
//
// Legacy local-build path (`docker build -t workspace-template:<runtime>`
// via scripts/build-images.sh) is still supported for development:
// when a bare `workspace-template:<runtime>` image is present locally,
// Docker's image resolver matches it before any pull is attempted. Set
// the env var WORKSPACE_IMAGE_LOCAL_OVERRIDE=1 (enforced by callers) to
// short-circuit pulls entirely if needed.
var RuntimeImages = map[string]string{
"langgraph": "ghcr.io/molecule-ai/workspace-template-langgraph:latest",
"claude-code": "ghcr.io/molecule-ai/workspace-template-claude-code:latest",
"openclaw": "ghcr.io/molecule-ai/workspace-template-openclaw:latest",
"deepagents": "ghcr.io/molecule-ai/workspace-template-deepagents:latest",
"crewai": "ghcr.io/molecule-ai/workspace-template-crewai:latest",
"autogen": "ghcr.io/molecule-ai/workspace-template-autogen:latest",
"hermes": "ghcr.io/molecule-ai/workspace-template-hermes:latest", // Hermes (Nous Research) — real hermes-agent behind A2A bridge
"gemini-cli": "ghcr.io/molecule-ai/workspace-template-gemini-cli:latest", // Google Gemini CLI
}
var RuntimeImages = computeRuntimeImages()
// DefaultImage is the fallback workspace Docker image (langgraph is the
// most common runtime). Computed via RegistryPrefix() so the prefix
// override applies to the fallback path too.
//
// NOTE: Every runtime MUST have an entry in knownRuntimes (registry.go).
// If a runtime is missing, it falls back to DefaultImage which may have
// wrong deps. Add new runtimes to knownRuntimes AND create the standalone
// template repo.
var DefaultImage = RuntimeImage(defaultRuntime)
const (
// DefaultImage is the fallback workspace Docker image (langgraph is the most common runtime).
DefaultImage = "ghcr.io/molecule-ai/workspace-template-langgraph:latest"
// NOTE: Every runtime MUST have an entry in RuntimeImages above. If a runtime is missing,
// it falls back to DefaultImage which may have wrong deps. Add new runtimes to both
// RuntimeImages AND create the standalone template repo.
// DefaultNetwork is the Docker network workspaces join.
DefaultNetwork = "molecule-monorepo-net"
@@ -0,0 +1,95 @@
package provisioner
import (
"fmt"
"os"
)
// defaultRegistryPrefix is the upstream OSS face for all workspace template
// images. Self-hosted Molecule deployments without the MOLECULE_IMAGE_REGISTRY
// override pull from here.
const defaultRegistryPrefix = "ghcr.io/molecule-ai"
// knownRuntimes is the canonical list of workspace template runtimes shipped
// in main. Any runtime added here MUST also have a standalone template repo
// (Molecule-AI/molecule-ai-workspace-template-<name>) and an entry in the
// publish-template-image workflow that builds it.
//
// Order matters for deterministic test snapshots; keep alphabetical.
var knownRuntimes = []string{
"autogen",
"claude-code",
"codex",
"crewai",
"deepagents",
"gemini-cli",
"hermes",
"langgraph",
"openclaw",
}
// defaultRuntime is the fallback when a workspace's config doesn't specify a
// runtime. Picked because LangGraph is the most common in our org templates
// and has the smallest "first impression" cold-start surface.
const defaultRuntime = "langgraph"
// RegistryPrefix returns the registry prefix all workspace-template image
// references should use. Defaults to ghcr.io/molecule-ai (the upstream OSS
// face) and is overridden by the MOLECULE_IMAGE_REGISTRY env var in
// production tenants where we mirror images to a private registry.
//
// The override is set at deploy time (Railway env, EC2 user-data) — never
// from user-supplied input — so the value is trusted by the time it reaches
// this code. Validation is deliberately minimal: an operator-supplied
// prefix that points at a registry the EC2 can't authenticate to will fail
// loudly at docker-pull time, which is the right blast radius.
//
// Example values:
//
// (unset) → ghcr.io/molecule-ai (OSS default)
// "123456789012.dkr.ecr.us-east-2.amazonaws.com/molecule-ai" → AWS ECR mirror
// "git.moleculesai.app/molecule-ai" → self-hosted Gitea Container Registry (future)
//
// Auth is registry-specific and configured outside this function:
// - GHCR: GHCR_USER/GHCR_TOKEN env vars consumed by ghcrAuthHeader()
// - ECR: docker credential helper (amazon-ecr-credential-helper) configured
// in EC2 user-data; ~/.docker/config.json has credHelpers entry; the
// daemon resolves auth automatically on every pull.
func RegistryPrefix() string {
if v := os.Getenv("MOLECULE_IMAGE_REGISTRY"); v != "" {
return v
}
return defaultRegistryPrefix
}
// RuntimeImage returns the canonical image reference for the given runtime,
// using the current RegistryPrefix() and the moving `:latest` tag.
//
// For SHA-pinned references (production thin-AMI launches), the
// runtime_image_pins lookup in handlers/runtime_image_pin.go strips the
// `:latest` suffix and appends an immutable `@sha256:<digest>` from the DB.
// That code path naturally inherits any RegistryPrefix() change because it
// reads from RuntimeImages[runtime] and only re-formats the tag suffix.
//
// Returns the empty string for unknown runtimes; callers should fall through
// to DefaultImage in that case (matching legacy behavior).
func RuntimeImage(runtime string) string {
for _, r := range knownRuntimes {
if r == runtime {
return fmt.Sprintf("%s/workspace-template-%s:latest", RegistryPrefix(), runtime)
}
}
return ""
}
// computeRuntimeImages returns the {runtime: image-ref} map evaluated against
// the current RegistryPrefix(). Called at package init to populate the
// exported RuntimeImages var. Tests that flip MOLECULE_IMAGE_REGISTRY between
// expected values use this helper to rebuild the map mid-run.
func computeRuntimeImages() map[string]string {
out := make(map[string]string, len(knownRuntimes))
for _, r := range knownRuntimes {
out[r] = RuntimeImage(r)
}
return out
}
@@ -0,0 +1,140 @@
package provisioner
import (
"strings"
"testing"
)
// TestRegistryPrefix_DefaultsToGHCR pins the OSS-default behavior. If a future
// refactor accidentally drops the default, OSS users self-hosting Molecule
// would silently lose image pulls — this test should fail loudly instead.
func TestRegistryPrefix_DefaultsToGHCR(t *testing.T) {
t.Setenv("MOLECULE_IMAGE_REGISTRY", "")
got := RegistryPrefix()
want := "ghcr.io/molecule-ai"
if got != want {
t.Fatalf("RegistryPrefix() = %q, want %q (default must remain GHCR for OSS users)", got, want)
}
}
// TestRegistryPrefix_RespectsEnv verifies the override path used in
// production tenants where MOLECULE_IMAGE_REGISTRY points at a private
// mirror (AWS ECR, self-hosted Harbor, etc.).
func TestRegistryPrefix_RespectsEnv(t *testing.T) {
t.Setenv("MOLECULE_IMAGE_REGISTRY", "123456789012.dkr.ecr.us-east-2.amazonaws.com/molecule-ai")
got := RegistryPrefix()
want := "123456789012.dkr.ecr.us-east-2.amazonaws.com/molecule-ai"
if got != want {
t.Fatalf("RegistryPrefix() = %q, want %q (env override path is the production cutover mechanism)", got, want)
}
}
// TestRegistryPrefix_EmptyEnvFallsBackToDefault — guard against an operator
// setting MOLECULE_IMAGE_REGISTRY="" by mistake (e.g. unset deploy variable
// becomes empty string, not literally absent). We treat "" as "use default"
// so a misconfigured env doesn't mean an empty registry prefix.
func TestRegistryPrefix_EmptyEnvFallsBackToDefault(t *testing.T) {
t.Setenv("MOLECULE_IMAGE_REGISTRY", "")
if RegistryPrefix() != defaultRegistryPrefix {
t.Fatalf("empty MOLECULE_IMAGE_REGISTRY should fall back to %q, got %q", defaultRegistryPrefix, RegistryPrefix())
}
}
// TestRuntimeImage_AllKnownRuntimes — every runtime in the canonical list
// must produce a properly-formatted image ref. If a new runtime is added to
// knownRuntimes but the format changes, this catches it.
func TestRuntimeImage_AllKnownRuntimes(t *testing.T) {
t.Setenv("MOLECULE_IMAGE_REGISTRY", "")
for _, r := range knownRuntimes {
got := RuntimeImage(r)
want := "ghcr.io/molecule-ai/workspace-template-" + r + ":latest"
if got != want {
t.Errorf("RuntimeImage(%q) = %q, want %q", r, got, want)
}
}
// Pin the count so adding a runtime requires explicit test acknowledgement.
if len(knownRuntimes) != 9 {
t.Errorf("knownRuntimes length = %d, want 9 (autogen, claude-code, codex, crewai, deepagents, gemini-cli, hermes, langgraph, openclaw)", len(knownRuntimes))
}
}
// TestRuntimeImage_UnknownRuntime — defensive: callers must fall back to
// DefaultImage when a runtime is unknown, never silently use the wrong
// prefix. Returning "" enforces an explicit fallback at every call site.
func TestRuntimeImage_UnknownRuntime(t *testing.T) {
for _, name := range []string{"", "nonexistent", "WORKSPACE-TEMPLATE-FAKE", "../../../etc/passwd"} {
if got := RuntimeImage(name); got != "" {
t.Errorf("RuntimeImage(%q) = %q, want empty string for unknown runtime", name, got)
}
}
}
// TestRuntimeImage_RegistryOverrideAppliesToAllRuntimes — the override
// flips ALL runtimes consistently. If a refactor accidentally hardcoded
// the prefix in some runtimes but not others (the failure mode that
// triggered this whole rollout), this test catches it.
func TestRuntimeImage_RegistryOverrideAppliesToAllRuntimes(t *testing.T) {
const ecr = "999999999999.dkr.ecr.us-east-2.amazonaws.com/molecule-ai"
t.Setenv("MOLECULE_IMAGE_REGISTRY", ecr)
for _, r := range knownRuntimes {
got := RuntimeImage(r)
if !strings.HasPrefix(got, ecr+"/workspace-template-") {
t.Errorf("RuntimeImage(%q) = %q, must start with override prefix %q", r, got, ecr)
}
if !strings.HasSuffix(got, ":latest") {
t.Errorf("RuntimeImage(%q) = %q, must keep :latest tag suffix", r, got)
}
}
}
// TestComputeRuntimeImages_AllRuntimesPresent — the map must contain every
// known runtime. Drift between knownRuntimes and computeRuntimeImages would
// silently break the runtime → image lookup that provisioner.Start uses.
func TestComputeRuntimeImages_AllRuntimesPresent(t *testing.T) {
t.Setenv("MOLECULE_IMAGE_REGISTRY", "")
m := computeRuntimeImages()
if len(m) != len(knownRuntimes) {
t.Fatalf("computeRuntimeImages() has %d entries, want %d (one per knownRuntime)", len(m), len(knownRuntimes))
}
for _, r := range knownRuntimes {
img, ok := m[r]
if !ok {
t.Errorf("computeRuntimeImages() missing runtime %q", r)
continue
}
if img == "" {
t.Errorf("computeRuntimeImages()[%q] is empty", r)
}
}
}
// TestComputeRuntimeImages_ReflectsCurrentEnv — calling computeRuntimeImages
// after env change rebuilds the map with new prefix. Tests + ops procedures
// that flip the env in-process rely on this.
func TestComputeRuntimeImages_ReflectsCurrentEnv(t *testing.T) {
t.Setenv("MOLECULE_IMAGE_REGISTRY", "")
defaultMap := computeRuntimeImages()
if !strings.HasPrefix(defaultMap["claude-code"], "ghcr.io/molecule-ai/") {
t.Fatalf("default map should be GHCR-prefixed, got %q", defaultMap["claude-code"])
}
const mirror = "registry.example.com/molecule-ai"
t.Setenv("MOLECULE_IMAGE_REGISTRY", mirror)
mirrorMap := computeRuntimeImages()
if !strings.HasPrefix(mirrorMap["claude-code"], mirror+"/") {
t.Fatalf("mirror-prefixed map should start with %q, got %q", mirror, mirrorMap["claude-code"])
}
}
// TestKnownRuntimes_AlphabeticalOrder — pin the order so test snapshots
// (and human readers diffing the file) see deterministic output. Adding a
// new runtime out of alphabetical order will fail this test, which is the
// nudge to keep the file readable.
func TestKnownRuntimes_AlphabeticalOrder(t *testing.T) {
for i := 1; i < len(knownRuntimes); i++ {
if knownRuntimes[i-1] >= knownRuntimes[i] {
t.Errorf("knownRuntimes not alphabetical: %q comes before %q", knownRuntimes[i-1], knownRuntimes[i])
}
}
}
@@ -8,6 +8,7 @@ import (
"time"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
)
@@ -197,7 +198,7 @@ func sweepStuckProvisioning(ctx context.Context, emitter ProvisionTimeoutEmitter
// A separate event type was considered but the UI reaction is
// identical either way — operators who need to distinguish can
// tell from the `source` payload field.
if emitErr := emitter.RecordAndBroadcast(ctx, "WORKSPACE_PROVISION_FAILED", c.id, map[string]interface{}{
if emitErr := emitter.RecordAndBroadcast(ctx, string(events.EventWorkspaceProvisionFailed), c.id, map[string]interface{}{
"error": msg,
"timeout_secs": timeoutSec,
"runtime": c.runtime,
@@ -11,6 +11,7 @@ import (
"github.com/Molecule-AI/molecule-monorepo/platform/internal/buildinfo"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/channels"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/messagestore"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/handlers"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/pendinguploads"
@@ -315,6 +316,18 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
wsAuth.POST("/activity", acth.Report)
wsAuth.POST("/notify", acth.Notify)
// Chat history — RFC #2945 PR-C (issue #3017) + PR-D (issue
// #3026). Server-side rendering of activity_logs rows into
// the canonical ChatMessage shape; storage is plugin-shaped
// via the messagestore.MessageStore interface so OSS
// operators can swap in S3 / vector / in-memory backends
// without forking the handler. Platform default uses
// PostgresMessageStore wrapping the existing activity_logs
// table.
chatStore := messagestore.NewPostgresMessageStore(db.DB)
chh := handlers.NewChatHistoryHandler(chatStore)
wsAuth.GET("/chat-history", chh.List)
// Config
cfgh := handlers.NewConfigHandler()
wsAuth.GET("/config", cfgh.Get)
@@ -14,8 +14,10 @@ import (
cronlib "github.com/robfig/cron/v3"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/metrics"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/supervised"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/textutil"
)
const (
@@ -521,7 +523,7 @@ func (s *Scheduler) fireSchedule(ctx context.Context, sched scheduleRow) {
"schedule_id": sched.ID,
"schedule_name": sched.Name,
"cron_expr": sched.CronExpr,
"prompt": sanitizeUTF8(truncate(sched.Prompt, 200)),
"prompt": sanitizeUTF8(textutil.TruncateBytes(sched.Prompt, 200)),
})
// #152: persist lastError into error_detail on the activity_logs row
// so GET /workspaces/:id/schedules/:id/history can surface why a run
@@ -541,7 +543,7 @@ func (s *Scheduler) fireSchedule(ctx context.Context, sched scheduleRow) {
insertCancel()
if s.broadcaster != nil {
s.broadcaster.RecordAndBroadcast(ctx, "CRON_EXECUTED", sched.WorkspaceID, map[string]interface{}{
s.broadcaster.RecordAndBroadcast(ctx, string(events.EventCronExecuted), sched.WorkspaceID, map[string]interface{}{
"schedule_id": sched.ID,
"schedule_name": sched.Name,
"status": lastStatus,
@@ -618,7 +620,7 @@ func (s *Scheduler) recordSkipped(ctx context.Context, sched scheduleRow, active
skipInsCancel()
if s.broadcaster != nil {
_ = s.broadcaster.RecordAndBroadcast(ctx, "CRON_SKIPPED", sched.WorkspaceID, map[string]interface{}{
_ = s.broadcaster.RecordAndBroadcast(ctx, string(events.EventCronSkipped), sched.WorkspaceID, map[string]interface{}{
"schedule_id": sched.ID,
"schedule_name": sched.Name,
"reason": reason,
@@ -806,27 +808,10 @@ func isEmptyResponse(body []byte) bool {
return false
}
// truncate shortens s to at most maxLen bytes, appending "..." if truncated.
// #2026: UTF-8 safe — byte-slicing at maxLen-3 would split multi-byte runes
// (observed: U+2026 `…` = 0xe2 0x80 0xa6, sliced mid-char, concatenated with
// "..." producing 0xe2 0x80 0x2e — rejected by Postgres as invalid UTF-8,
// which wedged the activity_logs INSERT with no deadline and stalled the
// scheduler).
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
cut := maxLen - 3
if cut < 0 {
cut = 0
}
// Back up to a rune boundary — utf8.RuneStart returns true for any
// non-continuation byte (ASCII, or the lead byte of a multi-byte rune).
for cut > 0 && !utf8.RuneStart(s[cut]) {
cut--
}
return s[:cut] + "..."
}
// truncation moved to internal/textutil.TruncateBytes (#2962 SSOT).
// The original #2026 fix lives in textutil's package docs as canonical
// prior art. Ellipsis was previously "..." (3 ASCII bytes); the SSOT
// uses "…" (3 UTF-8 bytes) — same byte budget, single-glyph display.
// short returns up to n leading characters of s without panicking when s is
// shorter than n. Used to safely display UUID prefixes in log lines where
@@ -10,6 +10,7 @@ import (
sqlmock "github.com/DATA-DOG/go-sqlmock"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/textutil"
)
// errDBDown is a sentinel error used by tests to simulate a DB connection failure.
@@ -618,7 +619,7 @@ func TestTruncate_utf8Safe_regression2026(t *testing.T) {
filler += "a"
}
input := filler + "…xxx" // 195 ASCII + 3-byte rune + 3 trailing
out := truncate(input, 200)
out := textutil.TruncateBytes(input, 200)
if !utf8.ValidString(out) {
t.Fatalf("truncate produced invalid UTF-8: %x", []byte(out))
@@ -0,0 +1,130 @@
// Package textutil provides string-handling helpers that respect UTF-8
// rune boundaries.
//
// Why this package exists
// -----------------------
// `s[:max]` truncates by BYTES; for any string with a multi-byte
// codepoint at byte `max` (CJK, emoji, accented Latin), the slice
// produces invalid UTF-8. Postgres `text` and `jsonb` columns reject
// invalid UTF-8 with `invalid byte sequence for encoding "UTF8"`,
// which silently fails the INSERT and holds the surrounding tx open
// — a class of audit-gap that has bitten this codebase three times
// (scheduler.go #2026, agent_message_writer.go #2959,
// delegation_ledger.go #2962). Six per-package helpers had
// independently re-implemented this logic with varying correctness;
// this package is the single source of truth.
//
// Use sites
// ---------
// - DB writes whose column is bytes-bounded (jsonb preview field,
// varchar(N)): TruncateBytes / TruncateBytesNoMarker.
// - UI summaries whose cap is in display chars, not bytes:
// TruncateRunes.
//
// All functions guarantee `utf8.ValidString(out) == true` for any
// `s` where `utf8.ValidString(s) == true`. Inputs that are already
// invalid UTF-8 should be sanitized at the trust boundary (e.g. via
// `strings.ToValidUTF8`); this package does not silently fix
// upstream invalid input.
package textutil
import "unicode/utf8"
// ellipsis is the truncation marker. U+2026 HORIZONTAL ELLIPSIS —
// 3 bytes in UTF-8, 1 rune, 1 display column. Standardized across
// the codebase to avoid the "..." (3 ASCII chars) vs "…" (1 char)
// inconsistency the per-package helpers had drifted into.
const ellipsis = "…"
// TruncateBytes returns s if `len(s) <= maxBytes`, otherwise returns
// the longest rune-aligned prefix of s that fits in `maxBytes - 3`
// bytes followed by the ellipsis marker. The returned string is
// always at most `maxBytes` bytes long.
//
// Example: TruncateBytes("你好世界你好", 10) returns "你好世…" (9 bytes)
// — three "你好" runes (each 3 bytes = 9 bytes) plus "…" (3 bytes)
// would be 12 bytes, so we walk back to "你好" (6 bytes) + "…" (3) = 9.
//
// Edge cases:
// - maxBytes <= 0: returns "" (no room even for input or marker)
// - maxBytes < len(ellipsis): returns "" (can't add marker without
// exceeding cap, and we won't return a marker-less truncation
// here — caller wanted a marker; use TruncateBytesNoMarker if
// they don't)
// - s contains invalid UTF-8: continuation bytes are walked over
// same as valid runes; the result preserves the (invalid) input
// bytes up to the truncation point. Caller is responsible for
// pre-sanitizing if Postgres validity is required.
func TruncateBytes(s string, maxBytes int) string {
if len(s) <= maxBytes {
return s
}
if maxBytes < len(ellipsis) {
return ""
}
// Reserve room for the marker, then walk back to the nearest
// rune boundary at or below the cut point.
cut := maxBytes - len(ellipsis)
for cut > 0 && !utf8.RuneStart(s[cut]) {
cut--
}
return s[:cut] + ellipsis
}
// TruncateBytesNoMarker returns s if `len(s) <= maxBytes`, otherwise
// returns the longest rune-aligned prefix of s that fits in
// `maxBytes` bytes. No marker is appended — useful when the caller's
// storage already conveys "preview" / "snippet" semantics and an
// extra ellipsis would push the result over a hard column cap.
//
// Example: TruncateBytesNoMarker("hello world", 5) returns "hello".
//
// Edge case: maxBytes <= 0 returns "".
func TruncateBytesNoMarker(s string, maxBytes int) string {
if len(s) <= maxBytes {
return s
}
if maxBytes <= 0 {
return ""
}
cut := maxBytes
for cut > 0 && !utf8.RuneStart(s[cut]) {
cut--
}
return s[:cut]
}
// TruncateRunes returns s if it has at most maxRunes runes, otherwise
// returns the first maxRunes runes followed by the ellipsis marker.
// Use this when the cap is in user-visible characters (UI summary,
// activity feed line) rather than bytes (DB column).
//
// Example: TruncateRunes("你好世界你好", 3) returns "你好世…" — three
// runes plus the marker, regardless of the resulting byte count.
//
// Edge case: maxRunes <= 0 returns "" (caller asked for no content).
func TruncateRunes(s string, maxRunes int) string {
if maxRunes <= 0 {
return ""
}
// Fast path: if every byte is a single-byte rune, the byte-length
// upper-bounds the rune count. This avoids a runes alloc for the
// common ASCII case where the input fits.
if len(s) <= maxRunes {
return s
}
// Walk by rune boundaries; stop at the (maxRunes+1)-th rune so we
// know the cut point and that truncation is needed.
count := 0
for i := range s {
if count == maxRunes {
return s[:i] + ellipsis
}
count++
}
// Reachable when the byte count exceeded maxRunes but the actual
// rune count didn't (e.g. all single-byte runes that just happen
// to be more than maxRunes). The fast path catches len(s) <=
// maxRunes; this catches maxRunes < runeCount(s) <= len(s).
return s
}
@@ -0,0 +1,222 @@
package textutil
import (
"testing"
"unicode/utf8"
)
// TestTruncateBytes_RuneBoundary pins the byte-cap, marker-bearing
// truncation path. Every case asserts both:
// 1. the exact expected output (so a refactor that flips ellipsis or
// drops a rune is caught), and
// 2. utf8.ValidString on the output (the invariant that the bug class
// in #2026/#2959/#2962 violated by slicing mid-codepoint).
//
// Per memory feedback_assert_exact_not_substring.md, asserts are exact
// equality, not substring matches.
func TestTruncateBytes_RuneBoundary(t *testing.T) {
cases := []struct {
name string
in string
maxBytes int
want string
}{
// Under-cap: returns input verbatim.
{"empty", "", 10, ""},
{"under-cap ASCII", "hi", 10, "hi"},
{"exactly-at-cap ASCII", "hello", 5, "hello"},
{"under-cap CJK", "你好", 10, "你好"}, // 6 bytes
{"exactly-at-cap CJK", "你好", 6, "你好"},
// Over-cap ASCII: trims to (maxBytes - 3) bytes + "…".
{"over-cap ASCII", "abcdefghij", 6, "abc…"},
// Over-cap CJK where cut would land mid-codepoint. The
// pre-fix bug shape: 7 - 3 = 4, but byte 4 is mid-"好"
// (好 is bytes 3..5 of "你好世界"). Walking back to byte 3
// (start of 好 — wait, that IS the start). Actually 你=0..2,
// 好=3..5, 世=6..8, 界=9..11. Cut=4, walk back to 3 (start
// of 好), then s[:3]="你", + "…" = "你…" (3+3=6 bytes ≤ 7).
{"over-cap CJK lands mid-codepoint", "你好世界", 7, "你…"},
// Over-cap CJK where cut lands exactly on rune boundary.
// 9 - 3 = 6, byte 6 is start of 世. Walk-back is no-op.
// s[:6]="你好" + "…" = "你好…" (9 bytes).
{"over-cap CJK rune-aligned", "你好世界", 9, "你好…"},
// Emoji: 😀 is 4 bytes (U+1F600). 7 - 3 = 4, byte 4 is start
// of second 😀 — walk-back no-op. s[:4]="😀" + "…" = "😀…".
{"over-cap emoji", "😀😀😀", 7, "😀…"},
// Mixed ASCII + CJK. "ab你好世界": a(1) b(1) 你(3) 好(3) 世(3) 界(3) = 14 bytes.
// maxBytes=8, 8-3=5. byte 5 is mid-好. Walk back to start of 好 = byte 5? Let me
// recompute: a=0, b=1, 你=2..4, 好=5..7, 世=8..10. Byte 5 IS start of 好.
// Walk-back keeps cut at 5. s[:5] = "ab你" + "…" = "ab你…" (8 bytes).
{"mixed prefix ASCII over-cap CJK", "ab你好世界", 8, "ab你…"},
// Pathological: maxBytes too small to even fit the marker.
{"cap below ellipsis len", "hello", 2, ""},
{"cap zero", "hello", 0, ""},
{"cap negative", "hello", -1, ""},
// Cap exactly == ellipsis len: no room for content, but
// the marker fits. This returns "" (cut = 0, s[:0] = "").
{"cap equals ellipsis len", "hello", 3, "…"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := TruncateBytes(c.in, c.maxBytes)
if got != c.want {
t.Errorf("TruncateBytes(%q, %d) = %q, want %q", c.in, c.maxBytes, got, c.want)
}
if !utf8.ValidString(got) {
t.Errorf("TruncateBytes(%q, %d) returned invalid UTF-8: %q", c.in, c.maxBytes, got)
}
// Output never exceeds the byte cap (when one is set).
if c.maxBytes > 0 && len(got) > c.maxBytes {
t.Errorf("TruncateBytes(%q, %d) overflowed cap: len(out)=%d > %d",
c.in, c.maxBytes, len(got), c.maxBytes)
}
})
}
}
// TestTruncateBytesNoMarker pins the marker-less variant. Same
// boundary handling as TruncateBytes but no ellipsis cost — the cut
// happens at maxBytes itself, walking back only if that lands
// mid-codepoint.
func TestTruncateBytesNoMarker(t *testing.T) {
cases := []struct {
name string
in string
maxBytes int
want string
}{
{"empty", "", 10, ""},
{"under-cap ASCII", "hi", 10, "hi"},
{"exactly-at-cap ASCII", "hello", 5, "hello"},
{"over-cap ASCII", "abcdefghij", 5, "abcde"},
// Over-cap CJK rune-aligned: "你好世界", maxBytes=6, byte 6 is start of 世.
// s[:6]="你好" — perfect cut.
{"over-cap CJK rune-aligned", "你好世界", 6, "你好"},
// Over-cap CJK mid-codepoint: maxBytes=4, byte 4 is mid-好.
// Walk back to byte 3 (start of 好), s[:3]="你".
{"over-cap CJK mid-codepoint", "你好世界", 4, "你"},
// Emoji: maxBytes=5, "😀😀" is bytes 0..3 then 4..7. byte 5 is mid-second-😀.
// Walk back to byte 4 (start of second 😀), s[:4]="😀".
{"over-cap emoji", "😀😀", 5, "😀"},
// Edge: cap zero or negative → "".
{"cap zero", "hello", 0, ""},
{"cap negative", "hello", -1, ""},
// Cap = 1 and first rune is multi-byte: walk-back to 0, return "".
{"cap one with leading CJK", "你hello", 1, ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := TruncateBytesNoMarker(c.in, c.maxBytes)
if got != c.want {
t.Errorf("TruncateBytesNoMarker(%q, %d) = %q, want %q", c.in, c.maxBytes, got, c.want)
}
if !utf8.ValidString(got) {
t.Errorf("TruncateBytesNoMarker(%q, %d) returned invalid UTF-8: %q", c.in, c.maxBytes, got)
}
if c.maxBytes > 0 && len(got) > c.maxBytes {
t.Errorf("TruncateBytesNoMarker(%q, %d) overflowed cap: len(out)=%d > %d",
c.in, c.maxBytes, len(got), c.maxBytes)
}
})
}
}
// TestTruncateRunes pins the rune-cap variant. The key contract is
// that maxRunes counts user-visible characters (Go runes, which line
// up with Unicode codepoints), not bytes — so "你好世界" with
// maxRunes=2 returns "你好…", regardless of the resulting byte count.
func TestTruncateRunes(t *testing.T) {
cases := []struct {
name string
in string
maxRunes int
want string
}{
{"empty", "", 5, ""},
{"under-cap ASCII", "hi", 5, "hi"},
{"exactly-at-cap ASCII", "hello", 5, "hello"},
{"over-cap ASCII", "abcdefghij", 5, "abcde…"},
{"under-cap CJK", "你好", 5, "你好"},
{"exactly-at-cap CJK", "你好", 2, "你好"},
// Over-cap CJK: maxRunes=3, expect first 3 runes + marker.
{"over-cap CJK", "你好世界你好", 3, "你好世…"},
// Emoji is one rune per glyph in Go (no ZWJ here).
{"over-cap emoji", "😀😀😀😀😀", 2, "😀😀…"},
// Mixed: maxRunes=3 of "ab你好世界" → "ab你…".
{"mixed prefix", "ab你好世界", 3, "ab你…"},
// Edge: maxRunes 0 / negative → "".
{"cap zero", "hello", 0, ""},
{"cap negative", "hello", -1, ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := TruncateRunes(c.in, c.maxRunes)
if got != c.want {
t.Errorf("TruncateRunes(%q, %d) = %q, want %q", c.in, c.maxRunes, got, c.want)
}
if !utf8.ValidString(got) {
t.Errorf("TruncateRunes(%q, %d) returned invalid UTF-8: %q", c.in, c.maxRunes, got)
}
})
}
}
// TestTruncate_FuzzInvariants stays as a property-style sanity check:
// for any rune-valid input and any cap, the output is rune-valid and
// (for byte-cap variants) within the cap. This catches off-by-one
// regressions in cuts that slip past the table-test cases above.
func TestTruncate_FuzzInvariants(t *testing.T) {
inputs := []string{
"",
"a",
"hello world",
"你好世界",
"😀😀😀",
"ab你c好d世e界",
"日本語の文字列",
"🇺🇸🇯🇵", // flags: each is 2 codepoints (regional indicators)
}
for _, in := range inputs {
for cap := -1; cap <= len(in)+5; cap++ {
t.Run("", func(t *testing.T) {
gotB := TruncateBytes(in, cap)
if !utf8.ValidString(gotB) {
t.Errorf("TruncateBytes(%q, %d) invalid UTF-8: %q", in, cap, gotB)
}
if cap > 0 && len(gotB) > cap {
t.Errorf("TruncateBytes(%q, %d) overflowed: %q (%d bytes)", in, cap, gotB, len(gotB))
}
gotN := TruncateBytesNoMarker(in, cap)
if !utf8.ValidString(gotN) {
t.Errorf("TruncateBytesNoMarker(%q, %d) invalid UTF-8: %q", in, cap, gotN)
}
if cap > 0 && len(gotN) > cap {
t.Errorf("TruncateBytesNoMarker(%q, %d) overflowed: %q (%d bytes)", in, cap, gotN, len(gotN))
}
gotR := TruncateRunes(in, cap)
if !utf8.ValidString(gotR) {
t.Errorf("TruncateRunes(%q, %d) invalid UTF-8: %q", in, cap, gotR)
}
})
}
}
}
@@ -0,0 +1,8 @@
-- Reverse of 20260506000000_workspaces_unique_parent_name.up.sql.
--
-- DROP CONCURRENTLY for the same reason CREATE was CONCURRENTLY:
-- avoid an ACCESS EXCLUSIVE lock during teardown on tenants under
-- live traffic. IF EXISTS makes this idempotent if the up-migration
-- never landed (e.g. resumed deploy on a fresh tenant).
DROP INDEX CONCURRENTLY IF EXISTS workspaces_parent_name_uniq;
@@ -0,0 +1,46 @@
-- TOCTOU backstop on workspaces(parent_id, name)
--
-- Origin: #2872 Critical 1 — /org/import had no per-tenant mutex,
-- advisory lock, or DB-level uniqueness, so two concurrent admin
-- POSTs (rapid double-click in canvas, retry-after-timeout, two
-- operators on the same template) both saw "not found" in
-- lookupExistingChild and both INSERT'd the same (parent_id, name)
-- row. Sweeper #2860 cleaned residual drift; this migration prevents
-- new collisions.
--
-- Why a partial index keyed on COALESCE(parent_id, sentinel):
--
-- - Postgres treats NULL ≠ NULL in a UNIQUE constraint, so root
-- workspaces (parent_id = NULL) would not collide pairwise even
-- if they shared the same name. COALESCE collapses NULLs to a
-- sentinel UUID so root collisions are caught.
--
-- - The `WHERE status != 'removed'` partial-index filter makes a
-- tombstoned row (collapsed team, deleted workspace) NOT block a
-- re-import using the same name, which preserves the existing
-- org-import semantics (lookupExistingChild already excludes
-- status='removed').
--
-- Why CONCURRENTLY:
--
-- - Builds the index without an ACCESS EXCLUSIVE lock on workspaces.
-- Production tenants serve live traffic during migration; a
-- blocking index build would cause request stalls.
-- - CONCURRENTLY MUST run outside a transaction. The migration
-- runner is configured to honour this (no BEGIN/COMMIT wrapper
-- around an idempotent CREATE INDEX CONCURRENTLY IF NOT EXISTS).
-- - IF NOT EXISTS makes this resumable: a partial build (e.g. CI
-- killed mid-flight) leaves an INVALID index; re-running CONCURRENTLY
-- after a manual REINDEX repairs without erroring on already-built.
--
-- Drift detection: companion test
-- workspaces_unique_parent_name_test.go pre-flights the index on a
-- live test DB to confirm the migration applied + the constraint is
-- enforceable.
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS workspaces_parent_name_uniq
ON workspaces (
COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid),
name
)
WHERE status != 'removed';
+75 -27
View File
@@ -17,6 +17,7 @@ from concurrent.futures import ThreadPoolExecutor
import httpx
import a2a_response
from platform_auth import auth_headers, self_source_headers
logger = logging.getLogger(__name__)
@@ -353,6 +354,20 @@ def _agent_card_url_for(peer_id: str) -> str:
# Used by delegate_task to distinguish real errors from normal response text.
_A2A_ERROR_PREFIX = "[A2A_ERROR] "
# Sentinel prefix for queued-for-poll-mode-peer outcomes (#2967).
# When the target workspace is registered as delivery_mode=poll (no
# public URL — typical for external molecule-mcp standalone runtimes),
# the platform's a2a_proxy.go:402 short-circuit returns a synthetic
# {"status":"queued","delivery_mode":"poll","method":"..."} envelope
# instead of dispatching over HTTP. The message IS delivered (written
# to the platform's inbox queue); there's just no synchronous reply
# to relay. Pre-#2967 the client treated this as "unexpected response
# shape" → caller saw DELEGATION FAILED → retried → recipient saw
# duplicates. The Queued prefix lets callers branch on this outcome
# explicitly: "delivered async, no synchronous reply expected" is
# different from both success-with-text and failure.
_A2A_QUEUED_PREFIX = "[A2A_QUEUED] "
# Workspace IDs are UUIDs everywhere we generate them (platform's
# workspaces.id column, /registry/discover/:id route param, etc.) but
# the agent-facing tool surface receives them as free-form strings via
@@ -564,17 +579,43 @@ async def send_a2a_message(peer_id: str, message: str, source_workspace_id: str
},
)
data = resp.json()
if "result" in data:
parts = data["result"].get("parts", [])
text = parts[0].get("text", "") if parts else "(no response)"
# Tag child-reported errors so the caller can detect them reliably
# Dispatch via the SSOT response model (a2a_response.py).
# All shape detection lives in one place — the parser
# never raises and routes unknown shapes to Malformed
# so a future server-side change is loud, not silent.
variant = a2a_response.parse(data)
if isinstance(variant, a2a_response.Result):
# Match legacy semantics:
# parts non-empty + first part has no text → ""
# parts empty → "(no response)"
# Differentiation matters for callers that assert
# on the empty-string case (test_a2a_client).
if variant.parts:
text = variant.text
else:
text = "(no response)"
# Tag child-reported errors so the caller can
# detect them reliably — agent-side bug surfaces
# text like "Agent error: <traceback>" inside a
# JSON-RPC success envelope.
if text.startswith("Agent error:"):
return f"{_A2A_ERROR_PREFIX}{text}"
return text
elif "error" in data:
err = data["error"]
msg = (err.get("message") or "").strip()
code = err.get("code")
if isinstance(variant, a2a_response.Queued):
# Poll-mode peer — message accepted into the inbox
# queue, target agent will fetch via poll. NOT a
# failure. Return the queued sentinel so callers
# (delegate_task etc.) can render the outcome
# accurately instead of treating it as an error.
logger.info(
"send_a2a_message: queued for poll-mode peer (target=%s method=%s)",
target_url,
variant.method,
)
return f"{_A2A_QUEUED_PREFIX}target={safe_id} method={variant.method}"
if isinstance(variant, a2a_response.Error):
msg = variant.message
code = variant.code
if msg and code is not None:
detail = f"{msg} (code={code})"
elif msg:
@@ -583,26 +624,33 @@ async def send_a2a_message(peer_id: str, message: str, source_workspace_id: str
detail = f"JSON-RPC error with no message (code={code})"
else:
detail = "JSON-RPC error with no message"
if variant.restarting:
# Surface platform-restart-in-progress
# explicitly — caller (UI / delegating agent)
# can render a softer "agent is restarting"
# message rather than a generic failure.
retry = (
f", retry_after={variant.retry_after}s"
if variant.retry_after is not None
else ""
)
detail = f"{detail} (restarting{retry})"
return f"{_A2A_ERROR_PREFIX}{detail} [target={target_url}]"
elif data.get("status") == "queued" and data.get("delivery_mode") == "poll":
# Workspace-server's poll-mode short-circuit envelope
# (workspace-server/internal/handlers/a2a_proxy.go ~line 402).
# The peer is poll-mode and has no URL to dispatch to, so
# the server queued the message for the peer's next inbox
# poll instead of forwarding it. Delivery is acknowledged
# but pending consumption.
#
# Pre-fix this fell through to the "unexpected response
# shape" error path → callers logged false failures, then
# delegate_task retried, and the peer received duplicate
# delegations. Issue #2967.
method = data.get("method") or "message/send"
logger.info(
"send_a2a_message: queued for poll-mode peer (method=%s, target=%s)",
method, target_url,
)
return f"queued for poll-mode peer (method={method})"
return f"{_A2A_ERROR_PREFIX}unexpected response shape (no result, no error): {str(data)[:200]} [target={target_url}]"
# Malformed — log loud + surface as error so the
# operator notices a server change. SSOT refactor
# subsumes the inline "queued" check that landed in
# the #2972 hotfix; that branch is now the typed
# Queued variant above.
logger.warning(
"send_a2a_message: malformed response (target=%s body=%.200s)",
target_url,
str(variant.raw),
)
return (
f"{_A2A_ERROR_PREFIX}unexpected response shape "
f"(no result, error, or queued envelope): "
f"{str(variant.raw)[:200]} [target={target_url}]"
)
except _TRANSIENT_HTTP_ERRORS as e:
last_exc = e
attempts_remaining = _DELEGATE_MAX_ATTEMPTS - (attempt + 1)
+246
View File
@@ -0,0 +1,246 @@
"""Single source of truth for A2A ``/workspaces/<id>/a2a`` response shapes.
The workspace-server proxy at
``workspace-server/internal/handlers/a2a_proxy.go`` (the canonical
emitter) returns one of the following shapes for a single A2A call:
* **JSON-RPC success**
``{"jsonrpc": "2.0", "result": {...}, "id": "..."}``
The agent's reply, passed through unchanged.
* **JSON-RPC error**
``{"jsonrpc": "2.0", "error": {"message": "...", "code": ...}, "id": "..."}``
The agent reported a structured error.
* **Poll-queued** (synthesized at proxy, RFC #2339 PR 2 — see
``a2a_proxy.go:402-406``)
``{"status": "queued", "delivery_mode": "poll", "method": "..."}``
The target is a poll-mode workspace (no public URL); the message
was written to the platform's inbox queue. The target agent will
fetch it via ``GET /activity?since_id=`` polling. NOT a failure
delivery succeeded, there's just no synchronous reply to relay.
* **Platform error** ``{"error": "...", "restarting": true?, "retry_after": int?}``
HTTP-level failure synthesized by the proxy when the agent is
unreachable, the container is restarting, or some other infrastructure
failure happened. ``restarting=true`` flags the platform-initiated
container-restart path.
* **Malformed** anything else. Surfaced explicitly so a future server
change is loud rather than silent.
The ``parse(data)`` function classifies a pre-decoded JSON body into a
typed variant. Callers ``match`` on the variant and never re-implement
shape detection that's the SSOT discipline.
# SSOT contract
This file is the Python half. The Go server emits these shapes today
via inline ``gin.H{...}`` literals. A future PR can introduce a Go
mirror (e.g. ``workspace-server/internal/models/a2a_response.go``)
with a typed marshaller until then, **any change to the wire shape
must be reflected here** and gated by ``test_a2a_response.py``'s
fixture corpus. The corpus exists specifically so a one-sided edit
breaks CI.
# Why a typed model (vs. dict-key sniffing at every site)
The pre-2967 client at ``a2a_client.py:567-587`` sniffed for ``result``
or ``error`` keys inline and treated everything else as malformed
which silently broke poll-mode peers (the queued envelope has neither
key). Inline sniffing per call site multiplies the surface area where
a new shape gets misclassified. A single typed parser with an
explicit ``Malformed`` escape hatch makes shape additions a
one-line change here + a fixture entry in the test corpus, instead of
a hunt through every parsing site in the runtime.
"""
from __future__ import annotations
import dataclasses
import logging
from typing import Any, Optional, Union
logger = logging.getLogger(__name__)
@dataclasses.dataclass(frozen=True)
class Result:
"""JSON-RPC success — agent's reply available synchronously.
``text`` is the convenience extraction from ``parts[0].text`` (the
A2A multipart shape). ``parts`` is the full list, available for
callers that need richer rendering (multiple parts, non-text parts).
``raw_result`` preserves the unparsed ``result`` field for any
caller that needs it (e.g. activity-row response_body audit).
"""
text: str
parts: list[dict[str, Any]] = dataclasses.field(default_factory=list)
raw_result: Optional[dict[str, Any]] = None
@dataclasses.dataclass(frozen=True)
class Error:
"""JSON-RPC error or platform-level error response.
``code`` is the JSON-RPC integer code when present, else None.
``restarting`` / ``retry_after`` are platform-restart-in-progress
metadata: when both are set, the caller knows the container is
being recycled and may surface a softer error to the user.
"""
message: str
code: Optional[int] = None
restarting: bool = False
retry_after: Optional[int] = None
@dataclasses.dataclass(frozen=True)
class Queued:
"""Platform poll-mode short-circuit — message accepted, peer will pick up async.
Returned when the target workspace is registered as
``delivery_mode=poll`` (no public URL typical for external
standalone ``molecule-mcp`` runtimes). The message was written to
the platform's inbox queue; the target agent will fetch it via
``GET /activity?since_id=`` polling.
NOT a failure. Callers that expect a synchronous reply (the agent's
response text) won't get one here — they should either:
* Tolerate the absence of a reply (fire-and-forget semantics).
* Fall back to the durable ``/workspaces/:id/delegate`` +
``/delegations`` polling path (see ``a2a_tools_delegation``'s
``_delegate_sync_via_polling``), which writes the same A2A
request through the platform's executeDelegation goroutine
and lets the caller poll for the result row.
``method`` echoes the request method (``message/send``, ``notify``,
etc.) so callers can correlate.
"""
method: str
delivery_mode: str = "poll"
@dataclasses.dataclass(frozen=True)
class Malformed:
"""Server returned a body the parser can't classify.
Carries the raw decoded payload for diagnostic logging. Callers
typically render this as an error to the user (see
``send_a2a_message``) but the Malformed variant is a separate
type so logging / metrics can distinguish it from genuine
JSON-RPC ``Error`` responses.
"""
raw: Any # whatever the server returned: dict / list / str / number / etc.
Variant = Union[Result, Error, Queued, Malformed]
# Field-name constants — the wire vocabulary. Single source of truth;
# the parser references these by name so a change here is a
# one-line edit instead of a hunt through string literals.
_KEY_RESULT = "result"
_KEY_ERROR = "error"
_KEY_STATUS = "status"
_KEY_DELIVERY_MODE = "delivery_mode"
_KEY_METHOD = "method"
_KEY_RESTARTING = "restarting"
_KEY_RETRY_AFTER = "retry_after"
_STATUS_QUEUED = "queued"
_DELIVERY_MODE_POLL = "poll"
def parse(data: Any) -> Variant:
"""Classify a pre-decoded ``/a2a`` JSON response into a typed variant.
Never raises. Every branch is total: any input that doesn't match a
known shape routes to ``Malformed`` so the caller can decide how
to surface it.
The order of checks matters:
1. Non-dict input Malformed (server contract is dict-shaped).
2. Poll-queued envelope is checked BEFORE result/error because a
server bug that sets both ``status=queued`` and ``result``
should be loud, not silently treated as Result.
3. ``result`` Result (the JSON-RPC success path).
4. ``error`` Error (JSON-RPC error or platform error).
5. Anything else Malformed.
"""
if not isinstance(data, dict):
logger.warning(
"a2a_response.parse: non-dict body — got %s",
type(data).__name__,
)
return Malformed(raw=data)
# Poll-queued envelope. Both keys must be present — the workspace
# server sets them together; if only one is present the body is
# ambiguous and we route to Malformed for visibility.
if (
data.get(_KEY_STATUS) == _STATUS_QUEUED
and data.get(_KEY_DELIVERY_MODE) == _DELIVERY_MODE_POLL
):
method_raw = data.get(_KEY_METHOD)
method = str(method_raw) if method_raw is not None else "unknown"
logger.info(
"a2a_response.parse: queued for poll-mode peer (method=%s)",
method,
)
return Queued(method=method)
# JSON-RPC success.
if _KEY_RESULT in data:
result = data[_KEY_RESULT]
if isinstance(result, dict):
parts_raw = result.get("parts")
parts = parts_raw if isinstance(parts_raw, list) else []
text = ""
if parts:
first = parts[0]
if isinstance(first, dict):
text_raw = first.get("text")
text = str(text_raw) if text_raw is not None else ""
return Result(text=text, parts=parts, raw_result=result)
# ``result`` present but not a dict — unusual but not an error;
# surface as a Result with the value rendered to text.
return Result(text=str(result), parts=[], raw_result=None)
# JSON-RPC error or platform error.
if _KEY_ERROR in data:
err_raw = data[_KEY_ERROR]
message = ""
code: Optional[int] = None
if isinstance(err_raw, dict):
msg_raw = err_raw.get("message")
if msg_raw is not None:
message = str(msg_raw).strip()
code_raw = err_raw.get("code")
if isinstance(code_raw, int):
code = code_raw
elif isinstance(err_raw, str):
message = err_raw.strip()
else:
message = str(err_raw)
restarting = bool(data.get(_KEY_RESTARTING, False))
retry_after_raw = data.get(_KEY_RETRY_AFTER)
retry_after = retry_after_raw if isinstance(retry_after_raw, int) else None
return Error(
message=message,
code=code,
restarting=restarting,
retry_after=retry_after,
)
logger.warning(
"a2a_response.parse: unrecognized shape — keys=%s",
sorted(data.keys()),
)
return Malformed(raw=data)
+27
View File
@@ -29,14 +29,18 @@ from __future__ import annotations
import hashlib
import json
import logging
import os
import httpx
logger = logging.getLogger(__name__)
from a2a_client import (
PLATFORM_URL,
WORKSPACE_ID,
_A2A_ERROR_PREFIX,
_A2A_QUEUED_PREFIX,
_peer_names,
_peer_to_source,
discover_peer,
@@ -245,6 +249,29 @@ async def tool_delegate_task(
# (the platform proxy) so the same code works for in-container and
# external (standalone molecule-mcp) callers.
result = await send_a2a_message(workspace_id, task, source_workspace_id=src)
# #2967: when the target is a poll-mode peer, the platform's
# a2a_proxy short-circuits and returns a queued envelope —
# send_a2a_message surfaces that as the _A2A_QUEUED_PREFIX
# sentinel. The synchronous proxy path can't deliver a reply
# because the target has no public URL; fall back to the
# durable /delegate + /delegations polling path which DOES
# work for poll-mode peers (the executeDelegation goroutine
# writes to the inbox queue and the result row arrives when
# the target picks it up + replies).
#
# This is what makes external-runtime-to-external-runtime
# A2A actually deliver synchronous replies — without the
# fallback the calling agent sees the queued sentinel as
# success-with-no-text and never gets the peer's response.
if result.startswith(_A2A_QUEUED_PREFIX):
logger.info(
"tool_delegate_task: target=%s is poll-mode; "
"falling back from message/send to /delegate-poll path",
workspace_id,
)
result = await _delegate_sync_via_polling(
workspace_id, task, src or WORKSPACE_ID,
)
# Detect delegation failures — wrap them clearly so the calling agent
# can decide to retry, use another peer, or handle the task itself.
+1 -1
View File
@@ -2,7 +2,7 @@
# build-all.sh — Rebuild base image and optionally adapter images.
#
# NOTE: Adapters have been extracted to standalone template repos:
# https://github.com/Molecule-AI/molecule-ai-workspace-template-<runtime>
# https://git.moleculesai.app/molecule-ai/molecule-ai-workspace-template-<runtime>
#
# This script now only builds the base image from workspace/Dockerfile.
# Each adapter repo has its own Dockerfile that installs molecule-ai-workspace-runtime
+102 -8
View File
@@ -281,11 +281,11 @@ class TestSendA2AMessage:
to the 'unexpected response shape' error path callers retried,
peer got duplicate delegations.
Pin: poll-queued envelope returns a clean success string that does
NOT start with _A2A_ERROR_PREFIX, so callers route it through the
normal-outcome path. Verified discriminating: assert_NOT_startswith
the error prefix would FAIL on the old code (which returned an
error-prefixed string) and PASSES on the new code.
Pin: poll-queued envelope returns a string tagged with the
_A2A_QUEUED_PREFIX sentinel (not _A2A_ERROR_PREFIX), so callers
can branch on the typed outcome without substring-sniffing.
Verified discriminating: pre-fix returned _A2A_ERROR_PREFIX so
the not-startswith assertion would FAIL on the old code.
"""
import a2a_client
@@ -301,12 +301,13 @@ class TestSendA2AMessage:
# Discriminating: pre-fix returned a string that startswith
# _A2A_ERROR_PREFIX, so this assertion would have FAILED on the
# old code. New code returns a queued-success string.
# old code. New code returns the queued-success sentinel.
assert not result.startswith(a2a_client._A2A_ERROR_PREFIX), (
f"poll-queued envelope must not be tagged as A2A error; got: {result!r}"
)
assert "queued" in result.lower()
assert "poll" in result.lower()
assert result.startswith(a2a_client._A2A_QUEUED_PREFIX), (
f"poll-queued envelope must use the queued sentinel; got: {result!r}"
)
# The method is included so a structured-log scraper can route by
# protocol verb if needed.
assert "message/send" in result
@@ -329,6 +330,7 @@ class TestSendA2AMessage:
result = await a2a_client.send_a2a_message(_TEST_PEER_ID, "task")
assert not result.startswith(a2a_client._A2A_ERROR_PREFIX)
assert result.startswith(a2a_client._A2A_QUEUED_PREFIX)
assert "message/sendStream" in result
async def test_status_queued_without_poll_mode_still_falls_through(self):
@@ -462,6 +464,98 @@ def _make_seq_mock_client(post_side_effect):
return mock_client
class TestSendA2AMessagePollMode:
"""Pin the #2967 fix: send_a2a_message recognizes the platform's
poll-mode short-circuit envelope and returns a queued sentinel
instead of an "unexpected response shape" error.
Pre-#2967 the client treated the queued envelope as malformed,
causing the calling agent to retry, which delivered the same
message twice to the (poll-mode) recipient. The Queued sentinel
lets delegate_task fall back to the durable polling path
transparently see test_delegation_sync_via_polling for the
fallback verification.
"""
async def test_poll_queued_envelope_returns_queued_sentinel(self):
# Workspace-server returns this shape (a2a_proxy.go:402-406)
# when the target workspace is registered as delivery_mode=poll
# (no public URL, typical for external molecule-mcp standalone
# runtimes).
import a2a_client
resp = _make_response(200, {
"status": "queued",
"delivery_mode": "poll",
"method": "message/send",
})
mock_client = _make_mock_client(post_resp=resp)
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client):
result = await a2a_client.send_a2a_message(_TEST_PEER_ID, "task")
# Sentinel + structured payload so callers can branch on it.
assert result.startswith(a2a_client._A2A_QUEUED_PREFIX)
# Critically: NOT the error sentinel. Pre-#2967 it was the error path.
assert not result.startswith(a2a_client._A2A_ERROR_PREFIX)
# Carries enough info for the caller to log meaningfully.
assert _TEST_PEER_ID in result
assert "message/send" in result
async def test_poll_queued_envelope_method_is_recorded(self):
import a2a_client
resp = _make_response(200, {
"status": "queued",
"delivery_mode": "poll",
"method": "notify",
})
mock_client = _make_mock_client(post_resp=resp)
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client):
result = await a2a_client.send_a2a_message(_TEST_PEER_ID, "task")
assert result.startswith(a2a_client._A2A_QUEUED_PREFIX)
assert "notify" in result
async def test_status_queued_without_delivery_mode_is_unexpected_shape(self):
# Server bug: only ``status=queued`` set, ``delivery_mode``
# missing. Surface as the malformed branch (not Queued) — the
# SSOT parser treats this as Malformed because the documented
# contract requires both keys.
import a2a_client
resp = _make_response(200, {"status": "queued", "method": "message/send"})
mock_client = _make_mock_client(post_resp=resp)
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client):
result = await a2a_client.send_a2a_message(_TEST_PEER_ID, "task")
assert result.startswith(a2a_client._A2A_ERROR_PREFIX)
assert "unexpected response shape" in result
# Must explicitly mention "or queued envelope" so an operator
# debugging this knows the parser HAS a Queued branch and the
# body just didn't match — not that the parser is missing the
# logic entirely (the pre-#2967 confusion).
assert "queued envelope" in result
async def test_platform_error_with_restart_metadata_surfaces_in_message(self):
# The platform error envelope: 503 with restart metadata.
# Surfaced as an error string that includes "restarting" so
# the caller / agent can render a softer error to the user.
import a2a_client
resp = _make_response(200, {
"error": "workspace agent unreachable — container restart triggered",
"restarting": True,
"retry_after": 15,
})
mock_client = _make_mock_client(post_resp=resp)
with patch("a2a_client.httpx.AsyncClient", return_value=mock_client):
result = await a2a_client.send_a2a_message(_TEST_PEER_ID, "task")
assert result.startswith(a2a_client._A2A_ERROR_PREFIX)
assert "restarting" in result
assert "retry_after=15" in result
class TestSendA2AMessageRetry:
"""Verify auto-retry on transient transport errors (RemoteProtocolError,
ConnectError, ReadTimeout, etc.) up to _DELEGATE_MAX_ATTEMPTS times.

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