Skip to content

Implement navigation for integration tooltip in agent list component - #8

Merged
pedrofrxncx merged 1 commit into
mainfrom
chore/navigate-to-integration
Apr 4, 2025
Merged

Implement navigation for integration tooltip in agent list component#8
pedrofrxncx merged 1 commit into
mainfrom
chore/navigate-to-integration

Conversation

@pedrofrxncx

Copy link
Copy Markdown
Collaborator

No description provided.

@pedrofrxncx
pedrofrxncx merged commit 17100a6 into main Apr 4, 2025
@pedrofrxncx
pedrofrxncx deleted the chore/navigate-to-integration branch May 20, 2025 21:58
AriOliv added a commit to AriOliv/studio that referenced this pull request May 20, 2026
- Follow-up decocms#1: mark fixed by c17706d / 14cf5d3 / d357d8a with
  the BUILTIN_ROLES root-cause note. Keep historical context intact.
- Follow-up decocms#8: add a status block summarising the post-Phase-3
  state of the `member` role.
- New "Operating the strict access model" section spelling out the
  owner / admin / member contract for whoever picks this back up.
tlgimenes added a commit that referenced this pull request Jun 6, 2026
…y e2e (round-trip, SSE, cancel, no-gap, fail-fast)

Part A (landmine #8): createProxyDispatch now watches the user's link-claim
presence (the cross-pod port of ws-gateway.ts's WS-close fanout — the claim,
re-armed by the daemon's work/control/proxy polls, IS the liveness signal). Any
`null` watcher fire (claim expired/deleted) aborts the in-flight await and
throws a 502-equivalent so runner.ts rejects promptly instead of hanging on a
reply that never arrives. No new idle timeout (the /events SSE body has
legitimate long gaps). Wired via linkClaimRegistry.watch in app.ts; the
POST/GET-drop error-frame path (S1) is untouched and still backstops.

Part B: apps/mesh/e2e/tests/link-proxy.spec.ts drives a real createProxyDispatch
round-trip over real NATS + the real /links/proxy routes via a non-production
test-trigger route (mounted only when NODE_ENV!=="production"). A proxy-daemon
simulator does continuous-overlap GET /links/proxy + duplex NDJSON POST replies
+ a /links/control cancel poll. Asserts: byte-exact round-trip (incl. a
non-UTF-8 byte for base64 fidelity), incremental SSE relay, cancel reaches the
simulator + ends the caller's stream, a request issued mid-handling is not
dropped, and daemon-vanished mid-stream makes the caller fail fast (not hang).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
tlgimenes added a commit that referenced this pull request Jun 7, 2026
…y e2e (round-trip, SSE, cancel, no-gap, fail-fast)

Part A (landmine #8): createProxyDispatch now watches the user's link-claim
presence (the cross-pod port of ws-gateway.ts's WS-close fanout — the claim,
re-armed by the daemon's work/control/proxy polls, IS the liveness signal). Any
`null` watcher fire (claim expired/deleted) aborts the in-flight await and
throws a 502-equivalent so runner.ts rejects promptly instead of hanging on a
reply that never arrives. No new idle timeout (the /events SSE body has
legitimate long gaps). Wired via linkClaimRegistry.watch in app.ts; the
POST/GET-drop error-frame path (S1) is untouched and still backstops.

Part B: apps/mesh/e2e/tests/link-proxy.spec.ts drives a real createProxyDispatch
round-trip over real NATS + the real /links/proxy routes via a non-production
test-trigger route (mounted only when NODE_ENV!=="production"). A proxy-daemon
simulator does continuous-overlap GET /links/proxy + duplex NDJSON POST replies
+ a /links/control cancel poll. Asserts: byte-exact round-trip (incl. a
non-UTF-8 byte for base64 fidelity), incremental SSE relay, cancel reaches the
simulator + ends the caller's stream, a request issued mid-handling is not
dropped, and daemon-vanished mid-stream makes the caller fail fast (not hang).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
tlgimenes added a commit that referenced this pull request Jun 7, 2026
… (dark-shipped) (#3698)

* feat(stream-of-record): durable thread_message_parts log + fold + storage adapter

Append-only thread_message_parts table (migration 098) with version/progress columns on threads; pure foldParts read model; SqlThreadMessagePartStorage (append/window/backfill) with real-Postgres integration tests (C1, C2, C5, R14, R18).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(stream-of-record): progress-liveness + gap/reconcile primitives

Pure isRunStuck (A1, A2), detectGap/reconcileDurable (B4, R5, R6), and the throttled progress-bump helper — all unit-tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(stream-of-record): v2 read path folds parts (memory + list-messages)

Memory.loadHistory and the COLLECTION_THREAD_MESSAGES_LIST UI reader branch on message_storage_version: v2 folds thread_message_parts, v1 reads the frozen thread_messages table. Org predicate preserved (R23). Includes the e2e read-path spec.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(stream-of-record): v2 write path — PartEmitter, canary, progress reaper

PartEmitter persists only final parts (idempotent, monotonic created_at) at the dispatcher hooks; new threads pinned v2 on first message behind STREAM_OF_RECORD_V2_PERCENT (default 0); the run reaper is now progress-based via last_progress_at. Dark-shipped: with the flag off the v1 path is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(stream-of-record): NATS-restart self-heal resilience scenario

Toxiproxy scenario proving result survival across a NATS fault (B1/B2/B4, C1). Runnable wiring + the self-heal proof gated behind harness scaffolding (mock-AI + studio-v2 service), documented in the README.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(stream-of-record): register migration 098 in manifest + format

Migrations are an explicit manifest (apps/mesh/migrations/index.ts), not filesystem-discovered, so the new 098 file was never run by 'bun run migrate' — the thread_message_parts table + threads.{message_storage_version,last_progress_at} columns were never created in CI, failing storage-integration and e2e (the progress bump runs on every turn). Register 098 and re-run biome format on threads.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(stream-of-record): verify v2 read path via the list-messages MCP tool, not UI render

The e2e seeded a v2 thread and asserted the browser transcript, but rendering a real chat needs model/credential onboarding the read-path fork is orthogonal to (the sibling decopilot specs exercise the API/MCP front door for the same reason). Rewrite to call COLLECTION_THREAD_MESSAGES_LIST directly — the exact reader we forked — and assert the folded message. typecheck + fmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(harnesses): extract shared parseDispatchSSEStream for reuse by the link ingest

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(harnesses): rename parser test helper param off the bun:test `it` name

* feat(storage): add run_fence_token column, fenceMatches predicate, and accessors

Adds the Phase A fence substrate: nullable `run_fence_token` column on
`threads` (migration 099), the pure `fenceMatches` predicate with unit
tests, and `getRunFence`/`setRunFence` accessors on SqlThreadStorage.
The fence is inert until minted in a later phase (null => accept all).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(migrations): register 099-run-fence in the migration manifest

* test(storage): assert fenceMatches treats empty-string fence as set

* feat(decopilot): add consumePartStream — assemble a chunk stream into parts via an emitter

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(decopilot): consumePartStream awaits terminal emit (whenComplete) and de-dups onError

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(links): add POST /api/:org/links/runs/:runId/stream SoR ingest endpoint

Wires the cluster-side HTTP ingest for the pull-inverted local link: checks
the run fence token (409 on mismatch), parses the daemon's SSE body via
parseDispatchSSEStream, commits parts to thread_message_parts through
PartEmitter/consumePartStream, and pumps the JetStream live edge. Also
delegates getRunFence from OrgScopedThreadStorage to the inner SqlThreadStorage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(links): require auth + inert-until-fenced ingest, drop tee for pump-sole-consumer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(e2e): align link-ingest SSE chunk shapes with SDK-verified shapes

The link-ingest e2e built its dispatch SSE body with `textDelta`/non-standard
chunk fields; ai@6 text deltas use `delta` and text parts key on `id`. Mirror
the exact UIMessageChunk sequence the consume-part-stream unit test proved
assembles, so the happy-path test actually lands a text part in CI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(links): local-link pull-inversion spec + Phase A–F implementation plans

Adds the axis-b "Desktop-as-pull-worker inversion" design (companion to the
stream-of-record spec) and bite-sized TDD implementation plans for Phases A–F:
A (SoR ingest, implemented), B (WorkQueue + work long-poll + DBOS gate change +
fence minting), C (control long-poll: cancel/HITL + sandbox lifecycle), D (cut
codex/claude-code to pull), E (decopilot harness portability), F (delete the
reverse-WS NATS middle-man). Everything is gated behind a link_transport flag
(pull ⊆ v2) so the live push path is untouched; shipped-daemon tasks are flagged
for human review. Includes the report/proposals/stream-of-record design corpus.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(storage): add threads.link_transport column for pull-transport gating

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(protocol): add optional runFenceToken to HarnessStreamInputWire

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(links): add LinkWorkQueue — JetStream WorkQueue stream for pull-transport work delivery

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(links): WorkQueue consumer create-race + DeliverPolicy.All (no pre-poll work loss)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(links): add GET /api/:org/links/work long-poll with presence refresh

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(thread-gate): add pull-transport branch — publish work item then poll threads.status until terminal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(links): wire pull dispatch runtime (knip), skip gate fetch on ws, flag latent work-item gap

- Import and pass `pullDispatch` + `linkWorkQueue` into `setThreadGateRuntime` in app.ts,
  making the `pullDispatch` export reachable and fixing the knip unused-export failure.
  `linkWorkQueue` is null when NATS is absent; the gate's `pullDispatchFn != null &&
  workQueue != null` guard keeps the ws path active in that case — no behavior change.
- Gate the `threads.get()` fetch in `dispatchRunAndWaitStep` behind `isPullCapable`
  (only fetch when runtime is pull-wired), eliminating a redundant DB read on every
  ws-path dispatch when pull is not configured.
- Add prominent PHASE B INCOMPLETE comments at the work-item publish site and
  `pullDispatch` JSDoc flagging that `harnessInput` is raw DispatchRunInput, not the
  full HarnessStreamInput the daemon requires (Phase D must resolve this).
- Add minor latent-edge-case notes at `setRunFence`: DBOS-replay token mismatch and
  failed-pull-run fence token lifecycle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(links): record Phase B landing status + Phase D prerequisite bundle

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(links): pullDispatch builds + publishes the full wire HarnessStreamInput (closes work-item gap)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(links): ingest transitions run terminal after stream completes (releases the gate poll)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(e2e): assert ingest transitions thread terminal (Task 7)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(links): Phase B Tasks 1-7 complete (cluster-side pull cycle); only daemon remains

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(links): pull-transport canary predicate + setLinkTransport storage method

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(link-daemon): work-poller — long-poll GET /links/work for pull-transport work items

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(link-daemon): handleLocalDispatch — relay local /dispatch SSE to the cluster ingest with fence token

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(link-daemon): pull-loop entry (LINK_TRANSPORT_MODE=pull) wiring poller + ensureSandbox + relay [SHIPPED DAEMON]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(link-daemon): pin sandbox via acquireDispatch during pull relay + orgSlug guard + close() test

- Wraps handleLocalDispatch in acquireDispatch/finally so LRU eviction
  cannot SIGTERM the sandbox mid-dispatch (mirrors the WS control-handler path)
- Adds orgSlug guard at the top of connectToClusterPull to fail fast instead
  of silently polling /api/undefined/links/work
- Adds close()/abort unit tests: proves the loop exits cleanly on abort and
  that a missing orgSlug throws immediately

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(links): Phase D daemon pull loop landed (gated); production-readiness gaps recorded

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(e2e): pull-transport cycle — gate publishes work item, daemon-sim posts to ingest, gate releases

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(decopilot-mcp): expose update_interests as a cluster MCP tool reachable via mcp.url

Creates UPDATE_INTERESTS_MCP as a defineTool wrapping ctx.storage.interests.setForAgent,
registered in CORE_TOOLS so it appears on the management MCP server (reachable via the
{orgId}_self connection that every virtual-MCP agent includes in its connections list).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(links): pull presence claim carries daemon capabilities so resolveDispatchTarget routes pull dispatches

The work-poll route was minting a sentinel claim with capabilities:[]
causing resolveDispatchTarget to return 409 user_desktop_link_capability_missing
for any pull-transport thread with harness_id='claude-code' (or 'codex').

- link-work-routes.ts: parse x-link-capabilities (comma-separated),
  x-link-machine-id, x-link-cli-version, x-link-preview-port headers and
  populate the LinkClaim accordingly on every poll; fall back to safe defaults
  only when headers are absent.
- work-poller.ts: add capabilities, machineId, cliVersion, previewPort to
  WorkPollerDeps; forward them as presence headers on every poll request.
- cluster-connection-pull.ts: thread the same fields from
  ClusterConnectionPullInput into runWorkPollLoop so callers can advertise
  their capabilities.
- e2e/link-dispatch-pull.spec.ts: send x-link-capabilities: claude-code on
  the presence-establishing GET /links/work so the claim is capable and the
  subsequent POST /messages returns 202.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(decopilot-mcp): expose subtask/take_screenshot/generate_image/web_search as cluster MCP tools

Adds four defineTool wrappers that mirror the corresponding decopilot built-ins
but execute cluster-side so the desktop daemon can call them via the injected
mcp.url token without holding cluster credentials.

Registered in CORE_TOOLS + ALL_TOOL_NAMES + MANAGEMENT_TOOLS.
Built-ins are NOT yet removed from buildAllTools (that gating is a later task).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(e2e): long client timeout + retry on the pull work-poll (server long-polls ~30s)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(decopilot): inject mcp.modelSecret for user-desktop decopilot (dormant; security-flagged)

Adds the optional `mcp.modelSecret` wire field to `HarnessStreamInput` and
`harnessStreamInputSchema`, then populates it in `dispatch-run.ts` only when
`target.runsIn === "user-desktop" && harnessId === "decopilot"` (condition is
never true today — decopilot is absent from the daemon registry). Raw key
resolved via `ctx.storage.aiProviderKeys.resolve()` (vault-backed); main
chat-completion credential only; sub-provider keys never transit.

⚠️ SECURITY: org chat-completion API key will transit to user desktop when the
condition activates. Plaintext over HTTPS; single run scope. Hardening
follow-up: cluster model-proxy (spec §3.9).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(decopilot): desktop harness factory path (modelSecret provider + reduced tools, no processLocal)

Adds isDesktopHarnessContext() predicate and decopilotDesktopHarnessFactory
which activates the chat provider from mcp.modelSecret, connects to the cluster
MCP endpoint over HTTP, builds the tool set with isDesktopContext: true (5
cluster-coupled built-ins excluded — update_interests, subtask, generate_image,
web_search, take_screenshot), and runs runDecopilotStream. Studio-pack resolution
is skipped on the desktop path (cluster-only). Cluster path is unchanged.

decopilotHarnessFactory.create() now delegates to the desktop factory when
isDesktopHarnessContext() is true instead of throwing. Daemon entry.ts registers
decopilotHarnessFactory and wires createDaemonAiProviders() into HarnessContext.
Sandbox tsconfig gains @/* path alias to resolve apps/mesh/src imports.

⚠️ SHIPPED DAEMON — requires human review before merge (spec §3.8 WHOLE-PHASE BANNER).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(links): stop JetStream fetch on HTTP client disconnect to prevent work-item theft

Root cause: the presence-establishing GET /links/work call in the pull-transport
e2e test uses a 1.5s client timeout, but the server-side handler keeps its NATS
consumer.fetch() running for up to POLL_TIMEOUT_MS (~29s) after the client
disconnects. When the thread-gate publishes the work item within that window,
the orphaned server-side handler consumes + acks it, leaving the daemon's real
poll cycle with nothing. This produces the "gate did not publish a work item"
failure even though the gate successfully published.

Fix: register an abort listener on c.req.raw.signal that calls messages.stop()
(ConsumerMessages/QueuedIterator stop method) when the HTTP client disconnects.
The for-await loop terminates immediately on abort; the cleanup listener is
removed in a finally block when the loop ends normally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* revert(daemon): defer decopilot daemon registration (import-tree breaks the bundle); keep cluster-side portability groundwork

Registering decopilotHarnessFactory in packages/sandbox/daemon/entry.ts
pulls the full cluster import-tree (RunRegistry, StudioContext, db, storage)
into the daemon bundle, causing a TS stack overflow and breaking CI build,
typecheck, and daemon-e2e.

Daemon-side changes reverted:
- packages/sandbox/daemon/entry.ts: remove decopilotHarnessFactory import +
  registry entry + createDaemonAiProviders aiProviders shim attachment.
- packages/sandbox/daemon/provider-adapter.ts: deleted (daemon-only file,
  no longer referenced).
- packages/sandbox/tsconfig.json: remove @/* and @deco/* paths entries that
  were added solely so the daemon could resolve apps/mesh/src imports.

Cluster-side changes kept (dormant, no daemon route reaches them):
- apps/mesh/src/harnesses/decopilot/desktop-factory.ts (isDesktopHarnessContext,
  decopilotDesktopHarnessFactory, assembleDesktopPrompt).
- apps/mesh/src/harnesses/decopilot/index.ts: isDesktopHarnessContext delegation.
- apps/mesh/src/harnesses/decopilot/built-in-tools/index.ts: isDesktopContext gating.
- apps/mesh/src/links/resolve-dispatch-target.ts: DUAL-HOMED INVARIANT comment.
- apps/mesh/src/harnesses/decopilot/desktop-factory.test.ts: unit tests.

Added deferral banner to desktop-factory.ts explaining the import-isolation
work required before the daemon registration can be re-enabled.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(links): Phase E landing status — cluster groundwork done; daemon registration deferred (import isolation)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(decopilot): import-isolated decopilot-desktop tree registered in the daemon (provider-from-modelSecret, lean tools/loop)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(links): pull work item carries sandbox config + orgSlug so the daemon can provision cold

Closes the Phase D gap: `WorkItem` now carries a `sandbox` field with
`handle`, `repo` (clone URL resolved cluster-side with OAuth token),
`workload` (runtime/packageManager), and offload allowlist, plus
`orgSlug` for the ingest URL.  `pullDispatch` resolves this config via
`resolveRemoteCliSandboxHandle` + `resolvePullSandboxConfig` for CLI
harnesses targeting user-desktop, warm-caching the sandbox on the daemon
via the NATS-backed ensure call.  `cluster-connection-pull.ts` passes the
full `EnsureSandboxInput` to `ensureSandbox` so the daemon can spawn cold;
falls back to `{ handle }` for back-compat with older work items.
`effectiveOrgSlug` prefers `item.orgSlug` over the configured env var.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(links): offload large messages on the pull leg (work-item messagesRef, daemon forwards it)

When the encoded harnessInput on the pull path exceeds the NATS
MAX_PUBLISH_BYTES (768 KiB) budget, pullDispatch now offloads
input.messages to object storage (same put/presign flow as the WS
path's remoteDispatch), sets harnessInput.messages = [], and carries
the MessagesRef on the work item. handleLocalDispatch forwards the ref
verbatim in the /_sandbox/dispatch POST body; the sandbox daemon's
existing messagesRef re-inflation path handles the rest — no daemon
changes beyond the body passthrough.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(links): durable cancel + control-poll route (Phase C cancel home for pull transport)

Implements the cluster-side Phase C stop-button home for pull-transport daemons:

- Migration 101: adds `threads.cancel_requested_at` (timestamptz, nullable)
- `cancel-flag.ts`: pure `isCancelRequested(Date|null): boolean` with co-located unit tests
- `control-frames.ts`: `ControlFrame` discriminated union (cancel+keep_alive), encode/decode codec, with unit tests
- `CancelBroadcast.publishControlFrame`: added to interface; NATS impl publishes to `links.control.<userSub>`; in-memory no-op in app.ts
- Cancel route: persists `cancel_requested_at` BEFORE local registry cancel + NATS broadcast; calls `publishControlFrame` after both paths
- Link ingest backstop: cancel check (getCancelRequestedAt→isCancelRequested) PRECEDES fence check — cancelled runs rejected 409 even with valid fence
- `link-control-routes.ts`: GET /api/:org/links/control long-poll (28 s, mirrors link-work-routes); subscribes to `links.control.<userId>` core NATS; 200+frame or 204 on timeout; abort cleanup on disconnect
- Mounted in org-scoped.ts alongside /links/work; wired in app.ts when NATS available
- E2E `link-control.spec.ts`: (1) 401 no-auth, (2) cancel→durable→ingest 409 (hard invariant), (3) control-poll frame delivery (tolerant)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(link-daemon): control-poll loop aborts in-flight pull run on cancel (Phase C daemon) [⚠️ shipped daemon]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(links): cut over chat dispatch to pull-by-default + rehome e2e to pull presence

Phase F Stage 1+2: flip the thread-gate routing so the pull transport
(NATS work queue) is the default whenever rt.workQueue + rt.pullDispatchFn
are both wired, removing the old per-thread link_transport='pull' gate and
the DB fetch that read it.  Falls back to dispatchRunFn (in-cluster WS) when
NATS is absent, keeping CI/no-NATS environments working unchanged.

E2e: replace connectToCluster/WS presence in decopilot-messages and
chat-locked-thread with pull-presence (GET /api/:org/links/work +
x-link-capabilities).  Delete link-dispatch-happy and link-dispatch-eviction
(pure WS-gateway mechanics, no pull analog).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(links): rehome RequestFrame + delete dead pull canary/setLinkTransport (Phase F Stage 3, partial Stage 4)

Stage 3: move the `RequestFrame` type out of the to-be-deleted
`dispatch-frames.ts` into a new `links/link-control-types.ts`; repoint the
surviving `link-daemon/control-handler.ts` import. `dispatch-frames.ts` is NOT
deleted — the daemon-stage `cluster-connection.ts` (untouched this stage) still
imports its `encodeFrame`/`decodeFrame`/`DispatchFrame`, so deleting it now
would break `bun run check`.

Stage 4 (safe subset only): delete the dead pull-transport canary
(`pull-transport-canary.ts` + test; zero live callers after the routing cutover
in 40562b3) and the dead `setLinkTransport` writer (facade + inner in
`storage/threads.ts`; zero callers). The `link_transport` column is retained
(nullable) and marked `@deprecated` in `storage/types.ts` — no drop migration.

BLOCKED (not deleted): ws-gateway.ts, dispatcher.ts, dispatch-frames.ts,
getDispatch, gatewayWsHandlers, registerLinksGateway, buildDesktopProvider, and
the dispatch-run user-desktop block. These are still load-bearing — see the
PR/commit report for the caller audit. The daemon defaults to LINK_TRANSPORT_MODE=ws
(nothing sets pull) and the sandbox lifecycle/preview/events/proxy traffic type
(vm-events, sandbox-proxy, SANDBOX_START on user-desktop) has no pull-transport
home yet (control-frames.ts explicitly defers ensure_sandbox/delete_sandbox).

Gates: `bun run --cwd=apps/mesh check`, `bun run check`, `bun run lint`,
`bun run knip` all green; `bun run fmt` clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Revert "feat(links): cut over chat dispatch to pull-by-default + rehome e2e to pull presence"

This reverts commit 40562b3.

* docs(links): Phase C landing status (cancel done) + Phase F BLOCKED status (sandbox reverse-proxy has no pull home)

* docs(links): Phase C-bis design — sandbox reverse-proxy → pull (preview already local; one-method rehome; go/no-go pending)

* docs(links): C-bis HARDENED plan — two-subject NATS correlation, 10 landmines, 9 stages (adversarial review)

* feat(links): C-bis S0+S1 — transport-neutral dispatch types + cluster pull reverse-proxy channel (dormant)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(link-daemon): C-bis S2 — proxy poll loop (continuous-overlap, detached per-reqId, duplex reply, reqId cancel) [⚠️ shipped daemon]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(links): C-bis S3 — desktop provider proxy transport behind flag + base64-unified binary-safe DispatchChunk + probeHealth repoint

Unify DispatchChunk.data as base64 across BOTH transports (landmine #9):
- WS daemon side (cluster-connection.ts): stop the TextDecoder→UTF-8
  conversion for /_sandbox streaming + base64-encode the lifecycle res.body
  so chunk.data is base64 on the wire for both paths.
- createProxyDispatch: already base64 (pass-through, unchanged).
- runner.ts: decode base64→bytes when building the Response body (binary-safe;
  fixes the historical UTF-8 lossy-ness for vm-tools file reads) via a 4-char
  carry-buffer stream decoder that survives the WS payload-chunking splitter
  cutting a base64 string mid-group; dispatchJson concatenates-then-decodes.

Provider transport swap behind LINK_PROXY_TRANSPORT (default OFF → WS stays
default, pull dormant): buildDesktopProvider injects createProxyDispatch
(getProxyDispatch, wired off the same NATS connection in app.ts) when
LINK_PROXY_TRANSPORT=pull, else getDispatch (WS). selectDesktopTransport is the
pure, unit-tested gate.

Daemon proxy path-routing: buildReplyBody routes /api/sandboxes* (ensure /
delete / liveness) → controlHandler.handle (wrapped as headers+chunk+end) and
/_sandbox/* → handleStream, so ensure/delete/probeHealth ride the SAME proxy
channel. Deviation from the plan doc: ensure/delete go THROUGH the proxy channel
via handle() rather than the doc's separate control-poll frames (simpler,
correct).

probeHealth (landmine #4): kept on the dispatch fast path (no presence-claim
round-trip) — the proxy round-trip is near-zero-gap (continuous-overlap GETs) +
a synchronous in-memory handle() check; cap raised 1500→3000ms to absorb the
per-probe reply-POST establishment.

Tests: base64 round-trip uniformity (both transports + runner, incl. 0x80/0xff
and mid-group splits), daemon path-routing (/api/sandboxes→handle,
/_sandbox/*→handleStream), provider-flag selection. Heavy real-NATS e2e deferred
to S5 (S5 owns the e2e per the plan).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(links): C-bis S4 — sever WS warm-ensure at the 2 dispatch sites (pure computeHandle); preview-URL site keeps ensure

Add `computeDesktopSandboxHandle` (pure, sync, no I/O) that derives the
sandbox handle via composeSandboxRef → computeClaimHandle, mirroring the
formula used by ensureSandbox/provisionSandbox.

Replace the two redundant `resolveRemoteCliSandboxHandle` (warm-ensure)
calls:
- prepareRun (runsIn=user-desktop branch): pure computeDesktopSandboxHandle
- pullDispatch (sandboxConfig resolution): pure computeDesktopSandboxHandle

The daemon self-ensures from WorkItem.sandbox on dequeue
(cluster-connection-pull.ts:214 input.provider.ensureSandbox), so the
cluster-side warm-ensure was redundant on both pull paths and is now severed.

resolveRemoteCliSandboxUrl (the preview-URL path) still calls
resolveRemoteCliSandboxHandle — it has no daemon self-ensure backstop, so
pure handle derivation there would return a potentially-unspawned handle
→ 502. That site is intentionally unchanged.

Unit tests: 7 new tests for computeDesktopSandboxHandle
(determinism, shape, sensitivity to each input dimension).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(links): C-bis S5 — daemon-vanished fail-fast + pull reverse-proxy e2e (round-trip, SSE, cancel, no-gap, fail-fast)

Part A (landmine #8): createProxyDispatch now watches the user's link-claim
presence (the cross-pod port of ws-gateway.ts's WS-close fanout — the claim,
re-armed by the daemon's work/control/proxy polls, IS the liveness signal). Any
`null` watcher fire (claim expired/deleted) aborts the in-flight await and
throws a 502-equivalent so runner.ts rejects promptly instead of hanging on a
reply that never arrives. No new idle timeout (the /events SSE body has
legitimate long gaps). Wired via linkClaimRegistry.watch in app.ts; the
POST/GET-drop error-frame path (S1) is untouched and still backstops.

Part B: apps/mesh/e2e/tests/link-proxy.spec.ts drives a real createProxyDispatch
round-trip over real NATS + the real /links/proxy routes via a non-production
test-trigger route (mounted only when NODE_ENV!=="production"). A proxy-daemon
simulator does continuous-overlap GET /links/proxy + duplex NDJSON POST replies
+ a /links/control cancel poll. Asserts: byte-exact round-trip (incl. a
non-UTF-8 byte for base64 fidelity), incremental SSE relay, cancel reaches the
simulator + ends the caller's stream, a request issued mid-handling is not
dropped, and daemon-vanished mid-stream makes the caller fail fast (not hang).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(links): C-bis S6 — target-gated chat cutover (runsIn==='user-desktop' + v2); rehome chat e2e specs to pull presence

The gate now keys off the POST-time-resolved dispatch target
(request.target.runsIn === 'user-desktop') instead of link_transport,
keeping the v2 conjunct as belt-and-suspenders. Cloud/cluster threads and
undefined targets fall back to the in-cluster ws path — the fix for the
reverted NATS-gated cutover (40562b3) that routed every chat to a pull
work-queue nobody drains.

decidePullDispatch extracted as a pure helper + unit-tested. decopilot-
messages and chat-locked-thread e2e specs swapped from connectToCluster WS
links to claimPullPresence (shared fixture). link-dispatch-pull drops the
link_transport seed, keeps message_storage_version=2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(links): C-bis — first-frame timeout in proxy dispatch (no-subscriber fail-fast) + hang-proof link-proxy e2e + playwright globalTimeout backstop

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(links): C-bis S7 — flip daemon + desktop-provider defaults to pull (hard break: re-run bunx decocms@latest link)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(links): C-bis S8 — delete the reverse-WS middle-man (ws-gateway, dispatcher, dispatch-frames, cluster-connection, reconnect-backoff); pure-pull [⚠️ shipped daemon]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(links): re-home GET /api/links/me after S8 ws-gateway deletion (used by CLI preflight + pull-presence e2e helpers)

* docs(links): refresh stale comments referencing the deleted reverse-WS (pure-pull is the only transport)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(links): mark Phase C-bis + F COMPLETE (reverse-WS deleted, pure-pull, CI-green)

* fix(link): resolve org slug in deco link CLI (pull transport is org-scoped — daemon hard-required it but the CLI never passed one)

The reverse-WS endpoint /api/links/connect was unscoped, so deco link never
needed an org. The pull transport polls /api/:org/links/* and the daemon
exits with 'requires an org slug' without one. Resolve it: --org flag →
DECO_ORG_SLUG → auto-resolve the user's single org from the cluster. Found by
running 'bun run dev --local-sandbox-provider' (daemon looped on the error).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(link-daemon): advertise capabilities + machineId/cliVersion/previewPort on the pull path

The pull connectToClusterPull call never passed the daemon's identity/capabilities,
so the cluster minted a presence claim with empty capabilities + placeholder
machineId/cliVersion/previewPort=0. Capability-gated dispatch then 409'd with
user_desktop_link_capability_missing even though presence showed 'linked'. The WS
hello frame carried these before S8 deleted it; the pull path must too. Wire
detectCapabilities() + loadOrCreateMachineId() + ingress.port into the call.
Verified live: claim now advertises [decopilot-sandbox, body-offload, claude-code, codex].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(docs): remove root-level working/planning notes (pull-inversion plans + spec, brainstorm docs)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
pedrofrxncx added a commit that referenced this pull request Jul 11, 2026
* docs: add CONTRIBUTING.md and daemon sync-ops gotcha

CONTRIBUTING.md with base guidelines; golden rule #1 is: no synchronous/
blocking work in the sandbox daemon — it runs on a single-threaded Bun
event loop, so sync fs/crypto/CPU work blocks the loop, the health probe
stops answering, and Studio marks the sandbox dead and tears it down.
Also adds the same as gotcha #8 in AGENTS.md (CLAUDE.md).

* docs: rebalance CONTRIBUTING — lead with common rules, add issue/PR/branch basics
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant