diff --git a/VENDORED.md b/VENDORED.md index 5dcad7aac..214de92c4 100644 --- a/VENDORED.md +++ b/VENDORED.md @@ -22,21 +22,23 @@ never a convenience. ## Ledger -| Vendored path | What was copied | Upstream repo @ commit | Why not a published package | Owner | Kill date | Kill-date test | -| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ---------- | ----------------- | -| `apps/sidecar` | Derived from upstream's own `apps/sidecar`: of 38 tracked `src/` modules, 5 are byte-identical to upstream (`default-harness.ts`, `source-asset-delivery.ts`, `workflow-closure-apply.ts`, `workflow-probe-handler.ts`, `workflow-run-pack-restore.ts`), 10 are substantially rewritten under the same name (`atomic-write.ts`, `config.ts`, `conversation-state.ts`, `index.ts`, `run-grants.ts`, `signing-keypair.ts`, `step-agent-tools.ts`, `tool-materialization.ts`, `workflow-closure-materialization.ts`, `workflow-run-pack-client.ts`), and the remaining 23 are workbench-only, including the `workflow-host-wiring/` and `workflow-substrate-factory/` module splits of upstream's single-file `workflow-host-wiring.ts` and `workflow-substrate-factory.ts`. A living fork, not a frozen copy, so this row carries no tree hash. | [faremeter/interchange](https://github.com/faremeter/interchange) @ `b5580a02` (v0.3.0) | An app is never npm-published, so no publish can cover the execution host; retired by consuming an upstream-published host, or by renewing this row deliberately | sawyer | 2026-09-19 | `check:killdates` | -| `vendor/intx/agent` | `@intx/agent` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 predates the operator-configurable doom-loop threshold (`afd0c82b`, `c421c092`) the re-vendored `workflow-host` configures; no local delta; retired by the next `@intx/agent` publish | sawyer | 2026-10-26 | `check:killdates` | -| `vendor/intx/db` | `@intx/db` source (`src/`, `migrations/`, drizzle config, manifest, tsconfigs) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 covers the base package but not the `wire_projection` column/loader delta (CL-6324) or the `workflow_definition.origin` column separating a definition from the per-run record of one folded run's deploy (CL-6452), shipped as migrations `0086`/`0087` behind upstream's `0085_add_approval_run_idx`, plus `0088` rewriting the retired `onBodyFailure: "continue"` literal to upstream's `"tolerate"` in stored wire projections; retired when upstream absorbs the deltas | sawyer | 2026-10-26 | `check:killdates` | -| `vendor/intx/hub-api` | `@intx/hub-api` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 covers the base package but not the null-principal `resolveApproval` for policy-resolved decisions (CL-6345) or the bearer-authenticated workflow-deploy mirror (`middleware/workflow-run-deploy-auth.ts`, CL-workflow-deploy-bearer); retired when upstream absorbs the deltas | sawyer | 2026-10-26 | `check:killdates` | -| `vendor/intx/hub-sessions` | `@intx/hub-sessions` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 covers the base package but not the pack-acceptance fixes (`ownsWorkflowRunRepo`, `anchorAddressForPackSource`, `decideTerminalRunFlip`), the adopted deploy front + `sourceRef` (CL-6324), the wire-projection writer (CL-6324), malformed tool-call-name sanitization (CL-6478) or the sealed-run terminal-status backfill (CL-6595); retired when upstream absorbs the deltas | sawyer | 2026-10-26 | `check:killdates` | -| `vendor/intx/inference` | `@intx/inference` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 predates doom-loop detection (`8da4c827`, `afd0c82b`, `c421c092`); one local delta: `providers/google-genai-files.ts` builds its upload body as `new Uint8Array(bytes)` because TS 6's lib.dom `BodyInit` rejects `Uint8Array` (upstream compiles ESNext-only under TS 5.9); retired by the next publish | sawyer | 2026-10-26 | `check:killdates` | -| `vendor/intx/mail-memory` | `@intx/mail-memory` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 predates the `@intx/mailbox` extraction (`af03bb90`), on-demand body reads (`54f7c239`) and `expunge` returning the swept uids (`bcabb1f8`) that the re-vendored `workflow-host` binds against; no local delta; retired by the next publish | sawyer | 2026-10-26 | `check:killdates` | -| `vendor/intx/mailbox` | `@intx/mailbox` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | Never published: a new package at the target pin (`af03bb90`) that `workflow-host`'s substrate mailbox store and supervisor-backed transport import; no local delta; retired by its first publish | sawyer | 2026-10-26 | `check:killdates` | -| `vendor/intx/mime` | `@intx/mime` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 predates the non-RFC message-id guard `isMessageId` (`d97e1832`), the full `References` chain (`65c6fe70`) and the lossless `decodeMail` decoder (`3b6d06b2`) that `mailbox`/`mail-memory` at the same pin import; no local delta; retired by the next publish | sawyer | 2026-10-26 | `check:killdates` | -| `vendor/intx/types` | `@intx/types` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 predates the type surface the re-vendored trees compile against: `expunge` returning `expungedUids` (`bcabb1f8`), plain-string `PackRejectReason` (`7b42f405`), the run authorization/approvals REST types (`71ad6c08`), the decoded-mail `Mail`/`MailPartReader` model (`3b6d06b2`) and the `interchange.actions`/`loops` package-json refs (`3bd5b837`, `1ea2f39b`); no local delta; retired by the next publish | sawyer | 2026-10-26 | `check:killdates` | -| `vendor/intx/workflow` | `@intx/workflow` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | No local delta: npm 0.3.0 predates the `onBodyFailure: "tolerate"` section policy (`b977ade6`) that `@corbits/agent-runtime` authors and the action/loop primitives (`3bd5b837`, `1ea2f39b`) the re-vendored `workflow-host` runs; retired by the next `@intx/workflow` publish | sawyer | 2026-10-26 | `check:killdates` | -| `vendor/intx/workflow-deploy` | `@intx/workflow-deploy` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | No local delta: npm 0.3.0 predates `inertLoopBody` and the loop-body source pin (`1ea2f39b`) that the re-vendored `hub-sessions` imports; retired by the next `@intx/workflow-deploy` publish | sawyer | 2026-10-26 | `check:killdates` | -| `vendor/intx/workflow-host` | `@intx/workflow-host` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `b5580a02` (v0.3.0) | npm 0.3.0 covers the base package but not the empty-mail drop (CL-6164), the action/loop runtime bind (CL-6325; its adapters live in `packages/workflow-host-actions` since CL-6435), or the body-spawn authorize/credential threading (CL-6448); retired when upstream absorbs the deltas | sawyer | 2026-09-19 | `check:killdates` | +| Vendored path | What was copied | Upstream repo @ commit | Why not a published package | Owner | Kill date | Kill-date test | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ---------- | ----------------- | +| `apps/sidecar` | Derived from upstream's own `apps/sidecar`: five modules byte-identical (`default-harness.ts`, `source-asset-delivery.ts`, `workflow-closure-apply.ts`, `workflow-probe-handler.ts`, `workflow-run-pack-restore.ts`), four near-verbatim, the rest (`index.ts`, `config.ts`, `tool-materialization.ts`, `step-agent-tools.ts`, `workflow-host-wiring/`, `workflow-substrate-factory/`, …) substantially rewritten, plus workbench-only modules. A living fork, not a frozen copy, so this row carries no tree hash. | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | An app is never npm-published, so no publish can cover the execution host; retired by consuming an upstream-published host, or by renewing this row deliberately | sawyer | 2026-10-26 | `check:killdates` | +| `vendor/intx/agent` | `@intx/agent` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 predates the operator-configurable doom-loop threshold (`afd0c82b`, `c421c092`) the re-vendored `workflow-host` configures; no local delta; retired by the next `@intx/agent` publish | sawyer | 2026-10-26 | `check:killdates` | +| `vendor/intx/db` | `@intx/db` source (`src/`, `migrations/`, drizzle config, manifest, tsconfigs) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 covers the base package but not the `wire_projection` column/loader delta (CL-6324) or the `workflow_definition.origin` column separating a definition from the per-run record of one folded run's deploy (CL-6452), shipped as migrations `0086`/`0087` behind upstream's `0085_add_approval_run_idx`, plus `0088` rewriting the retired `onBodyFailure: "continue"` literal to upstream's `"tolerate"` in stored wire projections; retired when upstream absorbs the deltas | sawyer | 2026-10-26 | `check:killdates` | +| `vendor/intx/harness` | `@intx/harness` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 predates the connector reply drain (`driveConnectorReplies`, `ConnectorReplyDrain`, `AgentEventStream`; `11590e66`) the sidecar's warm mail loop drives; no local delta; retired by the next `@intx/harness` publish | sawyer | 2026-10-26 | `check:killdates` | +| `vendor/intx/hub-agent` | `@intx/hub-agent` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 predates the `agentDir` path export (`927556de`) the sidecar's deploy-tree lookup uses, and its own `@intx/mail-memory`/`@intx/harness` pins must resolve the vendored copies; no local delta; retired by the next publish | sawyer | 2026-10-26 | `check:killdates` | +| `vendor/intx/hub-api` | `@intx/hub-api` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 covers the base package but not the null-principal `resolveApproval` for policy-resolved decisions (CL-6345) or the bearer-authenticated workflow-deploy mirror (`middleware/workflow-run-deploy-auth.ts`, CL-workflow-deploy-bearer); retired when upstream absorbs the deltas | sawyer | 2026-10-26 | `check:killdates` | +| `vendor/intx/hub-sessions` | `@intx/hub-sessions` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 covers the base package but not the pack-acceptance fixes (`ownsWorkflowRunRepo`, `anchorAddressForPackSource`, `decideTerminalRunFlip`), the adopted deploy front + `sourceRef` (CL-6324), the wire-projection writer (CL-6324), malformed tool-call-name sanitization (CL-6478) or the sealed-run terminal-status backfill (CL-6595); retired when upstream absorbs the deltas | sawyer | 2026-10-26 | `check:killdates` | +| `vendor/intx/inference` | `@intx/inference` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 predates doom-loop detection (`8da4c827`, `afd0c82b`, `c421c092`); one local delta: `providers/google-genai-files.ts` builds its upload body as `new Uint8Array(bytes)` because TS 6's lib.dom `BodyInit` rejects `Uint8Array` (upstream compiles ESNext-only under TS 5.9); retired by the next publish | sawyer | 2026-10-26 | `check:killdates` | +| `vendor/intx/mail-memory` | `@intx/mail-memory` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 predates the `@intx/mailbox` extraction (`af03bb90`), on-demand body reads (`54f7c239`) and `expunge` returning the swept uids (`bcabb1f8`) that the re-vendored `workflow-host` binds against; no local delta; retired by the next publish | sawyer | 2026-10-26 | `check:killdates` | +| `vendor/intx/mailbox` | `@intx/mailbox` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | Never published: a new package at the target pin (`af03bb90`) that `workflow-host`'s substrate mailbox store and supervisor-backed transport import; no local delta; retired by its first publish | sawyer | 2026-10-26 | `check:killdates` | +| `vendor/intx/mime` | `@intx/mime` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 predates the non-RFC message-id guard `isMessageId` (`d97e1832`), the full `References` chain (`65c6fe70`) and the lossless `decodeMail` decoder (`3b6d06b2`) that `mailbox`/`mail-memory` at the same pin import; no local delta; retired by the next publish | sawyer | 2026-10-26 | `check:killdates` | +| `vendor/intx/types` | `@intx/types` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 predates the type surface the re-vendored trees compile against: `expunge` returning `expungedUids` (`bcabb1f8`), plain-string `PackRejectReason` (`7b42f405`), the run authorization/approvals REST types (`71ad6c08`), the decoded-mail `Mail`/`MailPartReader` model (`3b6d06b2`) and the `interchange.actions`/`loops` package-json refs (`3bd5b837`, `1ea2f39b`); no local delta; retired by the next publish | sawyer | 2026-10-26 | `check:killdates` | +| `vendor/intx/workflow` | `@intx/workflow` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | No local delta: npm 0.3.0 predates the `onBodyFailure: "tolerate"` section policy (`b977ade6`) that `@corbits/agent-runtime` authors and the action/loop primitives (`3bd5b837`, `1ea2f39b`) the re-vendored `workflow-host` runs; retired by the next `@intx/workflow` publish | sawyer | 2026-10-26 | `check:killdates` | +| `vendor/intx/workflow-deploy` | `@intx/workflow-deploy` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | No local delta: npm 0.3.0 predates `inertLoopBody` and the loop-body source pin (`1ea2f39b`) that the re-vendored `hub-sessions` imports; retired by the next `@intx/workflow-deploy` publish | sawyer | 2026-10-26 | `check:killdates` | +| `vendor/intx/workflow-host` | `@intx/workflow-host` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 covers the base package but not the body-spawn authorize/credential/mail-part-reader threading and grants head-collapse (CL-6448) that let the fork run tool-bearing onTrigger bodies; retired when upstream absorbs the delta | sawyer | 2026-10-26 | `check:killdates` | The re-pin to `a8bc06ae` (upstream `origin/main`, 2026-08-27, 72 commits past `v0.3.0`) is landing row by row; npm is still `0.3.0`, so every tree an @@ -46,12 +48,6 @@ same commit — a vendored tree never mixes pins. The root `package.json` published `@intx/harness`, `@intx/hub-agent`, `@intx/tool-packaging`, `@intx/authz`, … resolve their own `@intx/*` dependencies onto the vendored copies instead of a second npm copy) and keep the unchanged names on `0.3.0`. -`vendor/intx/workflow-host` (still at `b5580a02` until its own re-pin) -carries two bridging edits against the re-pinned `@intx/types` and -`@intx/hub-sessions`: its supervisor-backed transport's `expunge` stub -returns `Promise<{ expungedUids: number[] }>` (upstream `bcabb1f8`), and its -boot replay reads `ownedMessageIds` from `scanRunsForBoot` (upstream -`f89bb51b`); both disappear with that tree's re-pin. The pinned commit `b5580a02` is upstream's `v0.3.0` release tag, 16 commits past the previous pin `4ed8baf4`: a workflow-host supervisor @@ -120,42 +116,18 @@ own event-log commits (e.g. a step named `write`) under their step-suffixed address, so every such pack was rejected `path_violation` with "source address has no deployment anchor it owns," the sidecar withheld the ack, and the hub redelivered forever — the same infinite-retry shape as the terminal-run -case above, one layer up the address hierarchy. `vendor/intx/workflow-host` -(CL-6164) drops -inbound mail carrying no conversation text on the parked-resume path rather -than delivering an empty string that throws inside `agent.send` and fails the -step with `retriesExhausted`; the gate is the new pure helper -`hasConversationText`. `vendor/intx/workflow-host` (CL-6325, slimmed by -CL-6435) additionally carries the run-child bind for the action/loop -runtime seam upstream defines but never populates. The action-primitive -adapters themselves (action invoker, effect ledger, run-blobs helpers, -plus their tests) no longer live in the vendor tree: CL-6435 extracted -them to the first-class package `packages/workflow-host-actions` -(`@corbits/workflow-host-actions`), which `run-child.ts` imports — the -vendored delta is now only the bindings fields, the once-per-child -registry resolution, and `buildRuntimeEnv`'s call into the package (the -vendored `package.json` gains the matching `workspace:*` dependency). -The bind itself: `RunWorkflowChildBindings` gains -`resolveActionHandler` — awaited once per child, after the definition -re-verify, with the resolved `WorkflowDefinition` and the live -`CredentialWiring`, so the app-owned registry -(`apps/sidecar/src/action-tool-handler.ts`) eagerly materializes every -action step's tool closure at establish and scopes credentials through -the same per-step grant wiring agent steps use — and `loopFns`, both -defaulting to the fail-closed empty registries; `buildRuntimeEnv` wires -`effects`, `invokeAction`, `loopFns`, and `runLoopIteration` into every -run's env and is exported so a host's runtime-env-level probe -(`apps/sidecar/test/action-runtime-env.test.ts`) can exercise the bind -without the full control-channel harness. `vendor/intx/workflow-host` -(CL-6448) also threads the parent child's credentials-backed authorize and -live `CredentialWiring` through the suspendable-child (onTrigger body) spawn -seam: `RunSuspendableChild`'s input and -`createInMemorySpawnSuspendableChild`'s opts gain optional -`authorize`/`credentialWiring` fields, and `run-child.ts` passes both when +case above, one layer up the address hierarchy. `vendor/intx/workflow-host` (CL-6448) threads the parent child's +credentials-backed authorize, live `CredentialWiring` and `MailPartReader` +through the suspendable-child (onTrigger body) spawn seam: +`RunSuspendableChild`'s input and `createInMemorySpawnSuspendableChild`'s +opts gain the three optional fields, and `run-child.ts` passes them when building the body resolver, so a body agent's tool calls gate through the -same per-step grant snapshot a top-level step's do instead of the host's -throwing authorize stub. Upstream never runs tool-bearing body agents, so -the seam has no upstream analog yet. `vendor/intx/workflow` carries no local delta: upstream `b977ade6` ships the +same per-step grant snapshot a top-level step's do, its tool bundles resolve +credentials, and an attachments-only inbound mail resolves its parts instead +of throwing. `findStepGrantsEntry` collapses a single-step deployment's sole +grants entry onto a body step whose own id the parent snapshot never lists. +Upstream never runs tool-bearing body agents, so the seam has no upstream +analog. `vendor/intx/workflow` carries no local delta: upstream `b977ade6` ships the `onTrigger` body-failure policy workbench had vendored as `onBodyFailure: "continue"` (CL-6326, CL-6324) under the literal `"tolerate"`, so the authoring site (`@corbits/agent-runtime`) says `"tolerate"` and `@intx/db` @@ -221,15 +193,15 @@ gained a same-push defense-in-depth backfill via the new slips past the primary detection. Each package's `VENDORED-FROM` file restates its own delta. -`apps/sidecar` records `b5580a02` (v0.3.0): the fork tracks the -closure-sourced lineage. Four modules are near-verbatim copies of upstream's -own — `workflow-probe-handler.ts`, -`workflow-closure-materialization.ts`, `workflow-closure-apply.ts`, -`source-asset-delivery.ts` — plus `bin/workflow-probe-child`; each is adapted -only where the fork's module layout differs (the host-platform resolution -lives in this fork's `tool-materialization.ts`, and the probe child's shebang -drops upstream's `intx-src` condition, which workbench forbids). The -remaining shared modules stay substantially rewritten, as the row records. +`apps/sidecar` records `a8bc06ae`: the fork tracks the closure-sourced +lineage and, at this pin, adopts upstream's warm-agent mailbox (the +supervisor-backed transport's inbound half, the mailbox watch registry and +mutation bridge, the connector-thread seed and reply drain), the +self-terminated-supervisor address reclaim, the live-child grants refresh +after an approval, the `agentDir` deploy-tree lookup and the disposer +`AggregateError`. Its defining delta stays: onTrigger bodies run WITH tools +(`bodyInvokeStep`), and every step env -- body or top-level -- gets the +mailbox read surface. ### Un-vendoring `vendor/intx` diff --git a/apps/sidecar/package.json b/apps/sidecar/package.json index 0e841dc02..73ad0f766 100644 --- a/apps/sidecar/package.json +++ b/apps/sidecar/package.json @@ -19,12 +19,11 @@ "@corbits/credential-providers": "workspace:*", "@corbits/error-sink": "workspace:*", "@corbits/ollama-adapter": "workspace:*", - "@corbits/workflow-host-actions": "workspace:*", "@intx/agent": "workspace:*", "@intx/authz": "0.3.0", "@intx/crypto": "0.3.0", - "@intx/harness": "0.3.0", - "@intx/hub-agent": "0.3.0", + "@intx/harness": "workspace:*", + "@intx/hub-agent": "workspace:*", "@intx/hub-sessions": "workspace:*", "@intx/inference": "workspace:*", "@intx/log": "0.3.0", diff --git a/apps/sidecar/src/action-tool-handler.test.ts b/apps/sidecar/src/action-tool-handler.test.ts deleted file mode 100644 index 137bbb00f..000000000 --- a/apps/sidecar/src/action-tool-handler.test.ts +++ /dev/null @@ -1,302 +0,0 @@ -// Unit tests for the workbench-native action-handler registry -// (`createActionToolHandlerRegistry`). Exercises the seam directly: a -// fake `materialize` stands in for `materializeStepTools` (which reads a -// real deploy tree off disk -- out of scope for a unit test), and -// `createEffectContext` (the same helper `@intx/workflow-host`'s -// `createWorkflowActionInvoker` uses in production) builds the -// capability- and ledger-checked context each bound handler runs -// against. -import { describe, expect, test } from "bun:test"; - -import { createEffectContext } from "@intx/workflow"; -import type { EffectLedger, WorkflowAuthorizeFn } from "@intx/workflow"; -import type { WorkflowDefinition } from "@intx/workflow/definition"; -import { defineTool, type BaseEnv, type ToolBundle } from "@intx/agent"; -import { toolConsumer, type GrantRule } from "@intx/authz"; -import { - createCredentialProviderRegistry, - createHttpCredentialProvider, -} from "@intx/harness"; -import type { CredentialDelivery } from "@intx/types/sidecar"; -import type { CredentialWiring } from "@intx/workflow-host"; - -import { - createActionToolHandlerRegistry, - type ActionStepMaterializationArgs, - type MaterializeStepTools, -} from "./action-tool-handler"; -import type { StepToolCacheConfig } from "./step-agent-tools"; - -const CACHE: StepToolCacheConfig = { - cacheMaxBytes: 1024, - registryMaxTarballBytes: 1024, -}; - -function materializationArgs( - stepId: string, - credentials?: ActionStepMaterializationArgs["credentials"], -): ActionStepMaterializationArgs { - return { - dataDir: "/tmp/action-tool-handler-test", - mailboxAddress: `${stepId}@run.test`, - stepId, - stepCount: 1, - storeDir: "/tmp/action-tool-handler-test/store", - cache: CACHE, - registries: new Map(), - ...(credentials !== undefined ? { credentials } : {}), - }; -} - -function definitionWithOneAction(handler: string): WorkflowDefinition { - return { - id: "wf-test", - triggers: [], - steps: { - s1: { id: "s1", kind: "action", handler }, - }, - stepOrder: ["s1"], - }; -} - -const allowAll: WorkflowAuthorizeFn = async () => ({ - effect: "allow", - matchingGrants: [], - resolvedBy: null, -}); - -function inMemoryLedger(): EffectLedger { - const store = new Map(); - return { - async lookup(effectKey) { - return store.get(effectKey); - }, - async record(effectKey, output) { - store.set(effectKey, { output }); - }, - }; -} - -function runViaEffectContext( - handler: ( - input: unknown, - ctx: never, - signal: AbortSignal, - ) => Promise, - input: unknown, - requires: readonly string[], -): Promise { - const ctx = createEffectContext({ - authorize: allowAll, - effects: inMemoryLedger(), - requires, - authzContext: { runId: "r1", stepId: "s1" }, - input, - }); - return handler(input, ctx as never, new AbortController().signal); -} - -/** A one-tool factory that echoes its call arguments back as JSON, with no - * credential requirement. */ -function echoFactory(toolName: string, id = "@test/echo-tools/echo") { - return defineTool({ - id, - definitions: [{ name: toolName }], - factory: (): ToolBundle => ({ - definitions: [{ name: toolName, description: "echo", inputSchema: {} }], - run: async (call) => ({ - callId: call.id, - content: JSON.stringify(call.arguments), - }), - }), - }); -} - -describe("createActionToolHandlerRegistry", () => { - test("dispatches a materialized tool through the resolved handler", async () => { - const materialize: MaterializeStepTools = async () => ({ - factories: [echoFactory("echo_tool")], - pluginFactories: [], - }); - const resolve = await createActionToolHandlerRegistry({ - definition: definitionWithOneAction("echo_tool"), - materializationByStepId: new Map([["s1", materializationArgs("s1")]]), - providers: createCredentialProviderRegistry([]), - materialize, - }); - - const handler = resolve("echo_tool"); - const output = await runViaEffectContext(handler, { n: 3 }, ["echo_tool"]); - expect(output).toEqual(JSON.stringify({ n: 3 })); - }); - - test("unknown ref throws", async () => { - const materialize: MaterializeStepTools = async () => ({ - factories: [echoFactory("echo_tool")], - pluginFactories: [], - }); - const resolve = await createActionToolHandlerRegistry({ - definition: definitionWithOneAction("echo_tool"), - materializationByStepId: new Map([["s1", materializationArgs("s1")]]), - providers: createCredentialProviderRegistry([]), - materialize, - }); - - expect(() => resolve("missing_tool")).toThrow( - /no action step in the workflow definition declares this handler ref/, - ); - }); - - test("missing materialization args for a declared action step fails closed at construction", async () => { - const materialize: MaterializeStepTools = async () => ({ - factories: [], - pluginFactories: [], - }); - await expect( - createActionToolHandlerRegistry({ - definition: definitionWithOneAction("echo_tool"), - materializationByStepId: new Map(), - providers: createCredentialProviderRegistry([]), - materialize, - }), - ).rejects.toThrow(/no materialization args were supplied/); - }); - - test("a handler ref with no matching materialized tool definition fails closed at construction", async () => { - const materialize: MaterializeStepTools = async () => ({ - factories: [echoFactory("some_other_tool")], - pluginFactories: [], - }); - await expect( - createActionToolHandlerRegistry({ - definition: definitionWithOneAction("echo_tool"), - materializationByStepId: new Map([["s1", materializationArgs("s1")]]), - providers: createCredentialProviderRegistry([]), - materialize, - }), - ).rejects.toThrow(/no materialized tool package/); - }); - - test("ctx.perform: undeclared capability fails closed", async () => { - const materialize: MaterializeStepTools = async () => ({ - factories: [echoFactory("echo_tool")], - pluginFactories: [], - }); - const resolve = await createActionToolHandlerRegistry({ - definition: definitionWithOneAction("echo_tool"), - materializationByStepId: new Map([["s1", materializationArgs("s1")]]), - providers: createCredentialProviderRegistry([]), - materialize, - }); - const handler = resolve("echo_tool"); - await expect( - runViaEffectContext(handler, { n: 1 }, ["other.cap"]), - ).rejects.toThrow(/not in its declared requires set/); - }); - - test("a tool result with isError throws", async () => { - const toolName = "failing_tool"; - const materialize: MaterializeStepTools = async () => ({ - factories: [ - defineTool({ - id: "@test/echo-tools/failing", - definitions: [{ name: toolName }], - factory: (): ToolBundle => ({ - definitions: [ - { name: toolName, description: "fails", inputSchema: {} }, - ], - run: async (call) => ({ - callId: call.id, - content: "boom", - isError: true, - }), - }), - }), - ], - pluginFactories: [], - }); - const resolve = await createActionToolHandlerRegistry({ - definition: definitionWithOneAction(toolName), - materializationByStepId: new Map([["s1", materializationArgs("s1")]]), - providers: createCredentialProviderRegistry([]), - materialize, - }); - const handler = resolve(toolName); - await expect(runViaEffectContext(handler, {}, [toolName])).rejects.toThrow( - /returned an error result/, - ); - }); - - test("a factory requiring credentials receives the consumer-scoped capability", async () => { - const toolName = "credentialed_tool"; - const consumer = toolConsumer("@test/cred-tools"); - let sawCredentials: unknown; - const materialize: MaterializeStepTools = async () => ({ - factories: [ - defineTool({ - id: "@test/cred-tools/credentialed", - requires: ["credentials"], - definitions: [{ name: toolName }], - factory: (env: BaseEnv & { credentials?: unknown }): ToolBundle => { - sawCredentials = env.credentials; - return { - definitions: [ - { - name: toolName, - description: "needs creds", - inputSchema: {}, - }, - ], - run: async (call) => ({ callId: call.id, content: "ok" }), - }; - }, - }), - ], - pluginFactories: [], - }); - - const delivery: CredentialDelivery = { - bindings: [{ handle: "svc", credentialId: "cred_1", consumer }], - materials: [ - { - credentialId: "cred_1", - providerKey: "http", - origin: "https://api.test", - secret: "s3cr3t", - }, - ], - }; - const grant: GrantRule = { - id: "grant_1", - resource: "credential:cred_1", - action: "use", - effect: "allow", - origin: "system", - conditions: { tool: consumer }, - expiresAt: null, - roleId: null, - principalId: null, - }; - const wiring: CredentialWiring = { - materialRef: { current: delivery }, - resolveStepGrants: () => [grant], - }; - - const resolve = await createActionToolHandlerRegistry({ - definition: definitionWithOneAction(toolName), - materializationByStepId: new Map([ - ["s1", materializationArgs("s1", { wiring })], - ]), - providers: createCredentialProviderRegistry([ - createHttpCredentialProvider({ - fetch: async () => new Response("{}", { status: 200 }), - }), - ]), - materialize, - }); - - const handler = resolve(toolName); - await runViaEffectContext(handler, {}, [toolName]); - expect(sawCredentials).toBeDefined(); - }); -}); diff --git a/apps/sidecar/src/action-tool-handler.ts b/apps/sidecar/src/action-tool-handler.ts deleted file mode 100644 index 80318aad1..000000000 --- a/apps/sidecar/src/action-tool-handler.ts +++ /dev/null @@ -1,316 +0,0 @@ -// Host-side action-handler registry for the native `action` primitive -// (WORKBENCH-OWNED, no upstream counterpart -- the sidecar-local pairing -// to `@intx/workflow-host`'s fail-closed `createActionHandlerRegistry`, -// ported from gtm-workbench's `apps/sidecar/src/action-tool-handler.ts`; -// see docs/revendor-inventory.md for what carried over unchanged and -// what is new). -// -// An `action` step carries no `AgentDefinition` -- only a `handler` -// string ref, an optional `input` selector, and `effect.requires` -// capability names. The workflow runtime never resolves `handler` -// itself (mirroring how it never reads `agent.toolFactories`); -// resolving it to a concrete TypeScript function is entirely the -// host's job. -// -// `handler` is FLAT: it is a materialized tool's canonical dispatch -// name (`ToolDefinition.name`, the same string `bundle.run({name, ...})` -// dispatches on and the same vocabulary `effect.requires` uses), nothing -// more. -// -// Departure from gtm's port: gtm's registry enumerates action steps by -// re-reading the deployed `workflow.json` off disk -// (`loadActionHandlerStepIds`), duplicating the read `packages/ -// workflow-host`'s `run-child.ts` does moments later. Workbench retired -// `workflow.json` as a host-read format in favor of the resolved -// in-memory `WorkflowDefinition` the closure loader already hands the -// sidecar (`loadVerifiedWorkflowDefinition` / -// `loadWorkflowDefinitionFromClosure`, `@intx/workflow-host`) -- so this -// registry takes that resolved `WorkflowDefinition` object directly as -// an argument instead of re-parsing anything off disk. -// -// Second departure: gtm's registry resolves each ref's `StepToolContext` -// through `step-tool-harness.ts` (`resolveStepToolContext` / -// `runDeterministicToolStep` / `assertStepToolResolvable`), a -// deterministic-tool-dispatch harness workbench has no analog of. -// Workbench dispatches tools only inside an agent reactor -// (`createToolBearingAgentFactory`, `step-agent-tools.ts`). This module -// instead materializes a step's tool-package closure directly via -// `materializeStepTools`, shapes the same consumer-scoped `credentials` -// capability `createToolBearingAgentFactory` shapes for a deterministic -// step (reusing its exported `getStepCredentialContext` / -// `packageFromToolId` / `consumerBindings` helpers), and dispatches the -// named tool through the materialized `ToolBundle.run` directly -- no -// agent, no reactor, no LSP/plugin chain (an action step has no -// `AgentDefinition`, so none of that machinery applies). -// -// Eager beats lazy here on purpose, same as gtm: every action step's -// tool closure is resolved once, when this registry is constructed, so -// a missing tool package or an unresolvable credential fails the moment -// the deployment is established rather than mid-run. - -import crypto from "node:crypto"; - -import { type } from "arktype"; -import type { ActionHandler } from "@corbits/workflow-host-actions"; -import type { WorkflowDefinition } from "@intx/workflow/definition"; -import type { AnnotatedToolFactory, BaseEnv, ToolBundle } from "@intx/agent"; -import { toolConsumer, type GrantRule } from "@intx/authz"; -import { - createCredentialCapability, - type CredentialProviderRegistry, - type HostCredentialCapability, -} from "@intx/harness"; -import type { LoadedToolFactory, RegistryConfig } from "@intx/tool-packaging"; - -import { - attachStepCredentials, - consumerBindings, - getStepCredentialContext, - materializeStepTools, - packageFromToolId, - type StepCredentialContext, - type StepToolCacheConfig, - type StepToolMaterialization, -} from "./step-agent-tools"; - -/** Injectable seam over `materializeStepTools` so a unit test can supply a - * fake tool-package closure instead of reading a real deploy tree off - * disk. Production callers omit it and get the real materializer. */ -export type MaterializeStepTools = ( - args: ActionStepMaterializationArgs, -) => Promise; - -/** Boundary validator for an action-step's input before it reaches a tool's - * `arguments` map -- a tool call's `arguments` is a flat record, never an - * arbitrary JSON value, so a non-record `input` selector result fails - * closed here instead of surfacing as a confusing tool-runner error. */ -const ToolCallArguments = type("Record"); - -/** - * Everything one `kind: "action"` step needs materialized before its - * `handler` ref(s) can dispatch: the resolved tool-package closure plus - * (optionally) the credential wiring `getStepCredentialContext` reads - * back. Mirrors the inputs `materializeStepTools` + - * `attachStepCredentials` already take for a deterministic/inference - * step -- this module is the action-primitive analog of that same seam. - */ -export interface ActionStepMaterializationArgs { - readonly dataDir: string; - readonly mailboxAddress: string; - readonly stepId: string; - readonly stepCount: number; - readonly storeDir: string; - readonly cache: StepToolCacheConfig; - readonly registries: ReadonlyMap; - readonly credentials?: Omit; -} - -export interface CreateActionToolHandlerRegistryArgs { - /** The resolved, in-memory workflow definition -- read at the seam the - * sidecar already holds it (the closure loader's output), never - * re-parsed off a `workflow.json` on disk. */ - definition: WorkflowDefinition; - /** Per-action-step materialization inputs, keyed by stepId. A `kind: - * "action"` step present in `definition.steps` with no entry here - * throws at registry construction -- fail-closed at establish, not - * mid-run. */ - materializationByStepId: ReadonlyMap; - providers: CredentialProviderRegistry; - /** Test seam; production callers omit this and get `materializeStepTools`. */ - materialize?: MaterializeStepTools; -} - -/** - * Every `handler` ref an action step in `definition.steps` declares, - * paired with the FIRST stepId that declares it. Two action steps - * sharing one handler ref resolve identically -- the deployment pins one - * uniform tool-package set per step. - */ -function actionHandlerRefs( - definition: WorkflowDefinition, -): Map { - const refs = new Map(); - for (const [stepId, primitive] of Object.entries(definition.steps)) { - if (primitive.kind !== "action") continue; - if (!refs.has(primitive.handler)) { - refs.set(primitive.handler, stepId); - } - } - return refs; -} - -/** - * Shape the consumer-scoped `credentials` capability for one materialized - * tool factory, mirroring `createToolBearingAgentFactory`'s - * `credentialCapabilityFor` -- duplicated rather than shared because that - * function lives inside the agent-factory closure and this dispatch path - * has no agent to attach it to. - */ -function credentialCapabilityForFactory(args: { - factory: LoadedToolFactory; - credentialContext: StepCredentialContext | undefined; - providers: CredentialProviderRegistry; -}): HostCredentialCapability | undefined { - if (!args.factory.requires.includes("credentials")) return undefined; - const consumer = toolConsumer(packageFromToolId(args.factory.id)); - const bindings = consumerBindings(args.credentialContext, consumer); - return createCredentialCapability({ - consumer, - bindings, - providers: args.providers, - grants: - bindings.size === 0 || args.credentialContext === undefined - ? [] - : [ - ...(args.credentialContext.wiring.resolveStepGrants( - args.credentialContext.stepId, - ) as readonly GrantRule[]), - ], - }); -} - -/** - * Build the scoped `ToolBundle` for one materialized tool factory, - * injecting a `credentials` capability only when the factory declares it - * needs one -- same gate `createToolBearingAgentFactory` applies. - */ -function buildScopedBundle(args: { - factory: LoadedToolFactory; - env: Omit; - credentialContext: StepCredentialContext | undefined; - providers: CredentialProviderRegistry; -}): ToolBundle { - const credentials = credentialCapabilityForFactory({ - factory: args.factory, - credentialContext: args.credentialContext, - providers: args.providers, - }); - const scopedEnv: BaseEnv = ( - credentials !== undefined ? { ...args.env, credentials } : args.env - ) as BaseEnv; - return (args.factory as unknown as AnnotatedToolFactory)(scopedEnv); -} - -/** - * Bind one `handler` ref to its already-resolved tool closure. Eagerly - * locates the owning factory (fail-closed at establish if no - * materialized factory declares a tool by this name), then returns the - * pure `ActionHandler`: dispatch the tool through the action's - * capability- and ledger-checked `EffectContext`, return its content. - */ -async function bindActionHandler(args: { - toolName: string; - materialization: ActionStepMaterializationArgs; - providers: CredentialProviderRegistry; - materialize: MaterializeStepTools; -}): Promise { - const { factories } = await args.materialize(args.materialization); - const factory = factories.find((f) => - f.definitions.some((d) => d.name === args.toolName), - ); - if (factory === undefined) { - throw new Error( - `action-tool-handler: no materialized tool package for step ${JSON.stringify(args.materialization.stepId)} declares a tool named ${JSON.stringify(args.toolName)}`, - ); - } - - // Placeholder `BaseEnv` slots a tool bundle's `factory(env)` structurally - // requires but never reads for an action dispatch (no reactor, no - // `createAgent`) -- mirrors gtm's `buildScratchEnv`. `workdir` is real: - // tools that touch disk need it. - const env = { - sources: [], - defaultSource: "", - storage: undefined, - audit: undefined, - directors: {}, - workdir: args.materialization.storeDir, - } as unknown as Omit; - if (args.materialization.credentials !== undefined) { - attachStepCredentials(env, { - ...args.materialization.credentials, - stepId: args.materialization.stepId, - }); - } - const credentialContext = getStepCredentialContext(env); - - return async (input, ctx, signal): Promise => { - const parsedArgs = ToolCallArguments(input); - if (parsedArgs instanceof type.errors) { - throw new Error( - `action-tool-handler: input for handler ${JSON.stringify(args.toolName)} is not a tool-arguments record: ${parsedArgs.summary}`, - ); - } - return ctx.perform({ - effectId: "tool-call", - capability: args.toolName, - run: async () => { - const bundle = buildScopedBundle({ - factory, - env, - credentialContext, - providers: args.providers, - }); - try { - const result = await bundle.run( - { - id: crypto.randomUUID(), - name: args.toolName, - arguments: parsedArgs, - }, - signal, - ); - if (result.isError === true) { - throw new Error( - `action-tool-handler: tool ${JSON.stringify(args.toolName)} returned an error result: ${typeof result.content === "string" ? result.content : JSON.stringify(result.content)}`, - ); - } - return result.content; - } finally { - await bundle.dispose?.(); - } - }, - }); - }; -} - -/** - * Build the `(ref) => ActionHandler` registry the sidecar threads into - * the run-child bindings' `resolveActionHandler` field. Every action - * step's tool closure is resolved EAGERLY, before this function returns. - */ -export async function createActionToolHandlerRegistry( - args: CreateActionToolHandlerRegistryArgs, -): Promise<(ref: string) => ActionHandler> { - const refs = actionHandlerRefs(args.definition); - const materialize = args.materialize ?? materializeStepTools; - - const handlers = new Map(); - for (const [toolName, stepId] of refs) { - const materialization = args.materializationByStepId.get(stepId); - if (materialization === undefined) { - throw new Error( - `action-tool-handler: step ${JSON.stringify(stepId)} declares handler ${JSON.stringify(toolName)} but no materialization args were supplied for it`, - ); - } - handlers.set( - toolName, - await bindActionHandler({ - toolName, - materialization, - providers: args.providers, - materialize, - }), - ); - } - - return (ref: string): ActionHandler => { - const handler = handlers.get(ref); - if (handler === undefined) { - throw new Error( - `action handler for ${JSON.stringify(ref)}: no action step in the workflow definition declares this handler ref`, - ); - } - return handler; - }; -} diff --git a/apps/sidecar/src/conversation-state.ts b/apps/sidecar/src/conversation-state.ts index c09248a5b..4674d8baa 100644 --- a/apps/sidecar/src/conversation-state.ts +++ b/apps/sidecar/src/conversation-state.ts @@ -108,12 +108,13 @@ // // Commit granularity. The design calls for connector-state-change-driven // commits via the router's -// `onStateChanged` hook. In the unified-host warm-agent path the agent -// receives synthesized step inputs and never drives the connector router -// (the supervisor owns mail), so `onStateChanged` stays dormant and never -// fires. The commit therefore falls back to the run boundary (per -// message). The `onStateChanged` wiring is retained so a future path that -// does drive the router gets change-driven mirrors for free. +// `onStateChanged` hook. The warm-agent path drives the connector router +// through `seedInbound`: each mail-derived inbound message routes and +// commits its thread state before the agent's send, so `onStateChanged` +// fires and enqueues a change-driven mirror. The run-boundary mirror (per +// message) still runs unconditionally, so the two triggers are +// complementary -- the seed persists the connector state promptly, the +// boundary persists the turn delta. // // Defensive: a restore that finds a checkpoint or WAL but cannot // parse/replay it THROWS (a lost or corrupt conversation on respawn is a @@ -128,6 +129,7 @@ import { type } from "arktype"; import { getLogger } from "@intx/log"; import { createConnectorRouter } from "@intx/harness"; +import type { ConnectorReplyParts, RouteDecision } from "@intx/harness"; import { createIsogitStorage, createNodeIsogitRuntime, @@ -144,7 +146,9 @@ import { type AuditStore, type ContextStore, type ConversationTurn, + type InboundMessage, type PendingOperation, + type SendReceipt, } from "@intx/types/runtime"; const logger = getLogger(["sidecar", "workflow-child", "conversation-state"]); @@ -308,6 +312,38 @@ export interface DurableConversationStore { * the agent's send settles). A write failure surfaces. */ mirrorToSubstrate(): Promise; + /** + * Advance the connector router from a received inbound message so the + * warm agent's reply path has thread state. Runs the router's pure + * `route()` then `commit()`: a `start` seeds threadRoot / lastMessageId / + * replyTo from the message; a `continue` advances lastMessageId / replyTo + * and carries prior speakers into `cc`. The advanced connector state is + * flushed into the local store's metadata so the run-boundary mirror + * persists it and a respawn restore re-seeds the router. A `passthrough` + * decision -- no active-thread match, or an unparseable sender -- advances + * nothing. Called before the warm agent's send so `composeReply()` can + * compose a threaded reply. A metadata write failure surfaces. + */ + seedInbound(message: InboundMessage): Promise; + /** + * Produce the threading headers for a reply on the active connector + * thread (the router's `composeReply`). Throws + * `NoActiveConnectorThreadError` when no thread has been seeded. The warm + * mail loop's reply drain reads this to address its outbound reply. + */ + composeReply(): ConnectorReplyParts; + /** + * Advance the connector thread after a reply was sent. Forwards to the + * router's `onReplySent` (which moves `lastMessageId` to the sent reply's + * Message-ID so the next inbound continuation matches) and flushes the + * advanced connector state into the local store's metadata the same way + * `seedInbound` does, so `lastMessageId` persists across turns and across a + * child respawn. Called by the warm mail loop's reply drain after its + * outbound send settles. Throws `NoActiveConnectorThreadError` when no + * thread is active -- advancing outbound state has no meaning without a + * seeded thread. A metadata write failure surfaces. + */ + onReplySent(receipt: SendReceipt): Promise; } export async function createDurableConversationStore( @@ -321,11 +357,11 @@ export async function createDurableConversationStore( // Reuse the connector router + the harness storage-override seam. The // router's `onStateChanged` is the change-driven commit hook the design - // names; in the synthesized-step-input warm path it stays dormant (the - // supervisor owns mail, so the agent never routes a connector message), - // so the run-boundary mirror is the operative commit trigger. The wiring - // is retained verbatim so a future path that drives the router gets - // change-driven mirrors with no further work. + // names. `seedInbound` drives the router (route + commit) on each inbound + // mail, so `onStateChanged` fires and enqueues a change-driven mirror + // behind the seed on the shared serialization tail; the run-boundary + // mirror still commits every boundary. Both triggers persist state, so a + // dropped change-driven mirror is recoverable at the next boundary. const connectorRouter = createConnectorRouter({ onStateChanged: () => { void mirrorToSubstrate().catch((cause) => { @@ -387,6 +423,18 @@ export async function createDurableConversationStore( return serializeStateOp(runMirror); } + function seedInbound(message: InboundMessage): Promise { + return serializeStateOp(() => runSeed(message)); + } + + function composeReply(): ConnectorReplyParts { + return connectorRouter.composeReply(); + } + + function onReplySent(receipt: SendReceipt): Promise { + return serializeStateOp(() => runReplySent(receipt)); + } + function substrateAgentStateFsDir(): string { const repoDir = opts.substrate.getRepoDir(opts.workflowRunRepoId); return path.join( @@ -614,10 +662,78 @@ export async function createDurableConversationStore( } } + // Classify an inbound message, treating a `route()` throw as passthrough. + // The router throws when `message.headers.from` is not a parseable bare + // addr-spec; per the router contract that is a passthrough (deliver the + // message to the agent -- the caller's send is separate -- but do not + // advance the thread), not a programmer error, so it must not fail the + // seed. A synthesized passthrough decision commits as a no-op. + function routeOrPassthrough(message: InboundMessage): RouteDecision { + try { + return connectorRouter.route(message); + } catch (cause) { + logger.warn`connector route for ${opts.agentKey} could not parse the inbound sender; leaving the thread unadvanced: ${cause instanceof Error ? cause.message : String(cause)}`; + return { kind: "passthrough" }; + } + } + + // Advance the connector router from a received inbound message and flush + // the resulting connector state into the local store's metadata. `commit` + // fires the router's `onStateChanged`, which enqueues a change-driven + // mirror behind this op on the shared serialization tail; because that + // mirror reads the connector state from the local store's metadata (not + // from the router), the metadata write below is what makes the seeded + // state reach the substrate. The write preserves the reactor's staged + // pendingOperations / tokenUsage -- a seed advances only connectorState. + // A passthrough decision advances nothing and writes nothing. + async function runSeed(message: InboundMessage): Promise { + const decision = routeOrPassthrough(message); + connectorRouter.commit(decision); + if (decision.kind === "passthrough") return; + + const metadata = await baseStorage.loadMetadata(); + baseStorage.setConnectorState(connectorRouter.snapshot()); + await baseStorage.writeMetadata({ + pendingOperations: metadata.pendingOperations, + tokenUsage: metadata.tokenUsage, + }); + await baseStorage.commit({ + message: `seed connector thread for ${opts.agentKey}`, + }); + } + + // Advance the connector thread after a reply was sent and flush the + // resulting connector state into the local store's metadata. `onReplySent` + // moves `lastMessageId` to the reply's Message-ID and fires the router's + // `onStateChanged`, which enqueues a change-driven mirror behind this op on + // the shared serialization tail; because that mirror reads the connector + // state from the local store's metadata (not from the router), the metadata + // write below is what makes the advanced state reach the substrate. The + // write preserves the reactor's staged pendingOperations / tokenUsage -- an + // outbound advance touches only connectorState. `onReplySent` throws when no + // thread is active, which surfaces to the reply drain's failure callback + // rather than persisting a phantom advance. + async function runReplySent(receipt: SendReceipt): Promise { + connectorRouter.onReplySent(receipt); + + const metadata = await baseStorage.loadMetadata(); + baseStorage.setConnectorState(connectorRouter.snapshot()); + await baseStorage.writeMetadata({ + pendingOperations: metadata.pendingOperations, + tokenUsage: metadata.tokenUsage, + }); + await baseStorage.commit({ + message: `advance connector thread after reply for ${opts.agentKey}`, + }); + } + return { storage: baseStorage, restoreFromSubstrate, mirrorToSubstrate, + seedInbound, + composeReply, + onReplySent, }; } diff --git a/apps/sidecar/src/step-agent-tools.ts b/apps/sidecar/src/step-agent-tools.ts index 75282f859..5d9674e1c 100644 --- a/apps/sidecar/src/step-agent-tools.ts +++ b/apps/sidecar/src/step-agent-tools.ts @@ -44,7 +44,7 @@ import { type HostCredentialCapability, type ResolvedCredentialBinding, } from "@intx/harness"; -import { readDeployTree, sanitizeAddress } from "@intx/hub-agent/paths"; +import { readDeployTree, agentDir } from "@intx/hub-agent/paths"; import { getLogger } from "@intx/log"; import type { LoadedToolFactory, RegistryConfig } from "@intx/tool-packaging"; import { resolveStepAddress } from "@intx/workflow-deploy"; @@ -168,13 +168,7 @@ export function attachStepCredentials( /** * Read back the credential context `attachStepCredentials` set on the - * per-step env. Exported (alongside `packageFromToolId` below) so - * `action-tool-handler.ts` can shape the same consumer-scoped - * `credentials` capability for an action's tool dispatch that - * `createToolBearingAgentFactory` shapes for a deterministic/inference - * step's tools -- action dispatch has no agent reactor to route - * through, so it reads this slot directly rather than through the - * `agentFactory` seam. + * per-step env. */ export function getStepCredentialContext( env: object, @@ -235,7 +229,7 @@ export function packageFromToolId(id: string): string { * `deploy/asset-mounts.json`) is shipped to the sidecar per step by the * hub's `launchSession` deploy-pack push, which lands it in the LEGACY * per-agent directory keyed by the step's sanitized mail address (see - * `@intx/hub-agent` `agentDir` / `sanitizeAddress`). It is NOT in the + * `@intx/hub-agent` `agentDir`). It is NOT in the * substrate's `agent-state/` layout -- the multi-step deploy path * never pushes step `agent-state` packs to the child's substrate. * @@ -267,7 +261,7 @@ export function stepDeployTreeDir(args: { domain: parsed.domain, stepCount: args.stepCount, }); - return path.join(args.dataDir, sanitizeAddress(stepAddress)); + return agentDir(args.dataDir, stepAddress); } /** @@ -570,15 +564,27 @@ export function createToolBearingAgentFactory(deps: { // dispose is idempotent. Running both guarantees the LSP // subprocess is torn down even for a plugin no tool bundle // consumed. + // Every disposer runs and failures are collected rather than thrown + // mid-loop, so one failing disposer never strands the rest. A leaked + // or failing LSP subprocess must surface, not be swallowed: any + // collected failure fails the close, so the caller sees it. + const failures: unknown[] = []; for (const dispose of capturedDisposers) { try { await dispose(); } catch (cause) { - logger.warn`step tool bundle dispose failed: ${cause instanceof Error ? cause.message : String(cause)}`; + logger.error`step tool bundle dispose failed: ${cause instanceof Error ? cause.message : String(cause)}`; + failures.push(cause); } } - await disposeAll(pluginInstances, "step teardown"); + failures.push(...(await disposeAll(pluginInstances, "step teardown"))); await disposeCredentialCapabilities(credentialCapabilities); + if (failures.length > 0) { + throw new AggregateError( + failures, + `step agent close: ${String(failures.length)} disposer(s) failed during teardown; an LSP subprocess may be leaked`, + ); + } }); }; } @@ -686,7 +692,8 @@ function wrapAgentClose(agent: Agent, teardown: () => Promise): Agent { async function disposeAll( instances: readonly unknown[], context: string, -): Promise { +): Promise { + const failures: unknown[] = []; for (const instance of instances) { const dispose = pluginDispose(instance); if (dispose === undefined) continue; @@ -695,9 +702,11 @@ async function disposeAll( // whether the disposer is sync or async. await dispose(); } catch (cause) { - logger.warn`step plugin dispose failed during ${context}: ${cause instanceof Error ? cause.message : String(cause)}`; + logger.error`step plugin dispose failed during ${context}: ${cause instanceof Error ? cause.message : String(cause)}`; + failures.push(cause); } } + return failures; } /** diff --git a/apps/sidecar/src/workflow-host-wiring/index.ts b/apps/sidecar/src/workflow-host-wiring/index.ts index 3af00120e..9d4319fa2 100644 --- a/apps/sidecar/src/workflow-host-wiring/index.ts +++ b/apps/sidecar/src/workflow-host-wiring/index.ts @@ -645,6 +645,34 @@ export function createSidecarDeployRouter(deps: { if (existing === agentAddress) slugClaims.delete(deploymentId); } + // Reclaim a deployment address whose supervisor drove ITSELF to a terminal + // phase (crash-loop latch, channel crash, recycle failure) without an + // operator undeploy, so a redeploy of the same address succeeds without a + // manual undeploy first. Runs re-entrantly as the supervisor's own + // `onSelfTerminate` sink, so it never calls back into `wired.supervisor.*`. + // It drops only in-memory routing state: the durable deployment record and + // on-disk step state stay (the hub still believes the address is deployed, + // and a boot restore re-spawns from the record), and the deployment + // address registry is RETAINED because the supervisor's own terminal + // `RunFailed` commit -- written after this sink fires -- resolves its run + // address through it. Fully synchronous, so it cannot interleave with a + // concurrent operator undeploy of the same address. + function reclaimSelfTerminatedSupervisor(args: { + deploymentId: string; + agentAddress: string; + }): void { + if (!activeSupervisors.has(args.agentAddress)) return; + deps.multistepMailRouter?.unregister(args.agentAddress); + deps.multistepSignalRouter?.unregister(args.agentAddress); + deps.multistepDrainRouter?.unregister(args.agentAddress); + deps.multistepGrantsRouter?.unregister(args.agentAddress); + deps.multistepSourcesRouter?.unregister(args.agentAddress); + deps.multistepCredentialsRouter?.unregister(args.agentAddress); + activeSupervisors.delete(args.agentAddress); + deps.transport.unregister(args.agentAddress); + releaseSlug(args.deploymentId, args.agentAddress); + } + /** * The per-deployment inputs the shared spawn core needs to stand up a * workflow deployment, independent of the live deploy frame. The live @@ -858,6 +886,11 @@ export function createSidecarDeployRouter(deps: { ...(deps.registerSuspension !== undefined ? { onSuspensionRegister: deps.registerSuspension } : {}), + onSelfTerminate: () => + reclaimSelfTerminatedSupervisor({ + deploymentId, + agentAddress: spec.agentAddress, + }), ...(spec.credentials !== undefined ? { credentialDelivery: spec.credentials } : {}), @@ -1042,6 +1075,12 @@ export function createSidecarDeployRouter(deps: { grants: args.stepGrants, runId: args.runId, }); + // The per-run file now carries the (possibly overlaid) floor. Refresh + // a live child so a standing ("always") approval resolved while the + // run is running but not parked lowers its floor immediately; the + // same durable file governs the next barrier/respawn, so a skipped + // push never leaves the run under a stale floor for good. + await wired.supervisor.deliverGrants(args.runId); }); // Register the sources-rotation handler ONLY for a single-step warm // deployment: it has one long-lived agent whose sources can be diff --git a/apps/sidecar/src/workflow-host-wiring/supervisor.ts b/apps/sidecar/src/workflow-host-wiring/supervisor.ts index 5e11bbad3..75971daec 100644 --- a/apps/sidecar/src/workflow-host-wiring/supervisor.ts +++ b/apps/sidecar/src/workflow-host-wiring/supervisor.ts @@ -236,6 +236,16 @@ export type CreateSidecarWorkflowSupervisorOpts = { * never registers an approval and the run parks invisibly forever. */ onSuspensionRegister?: (registration: SuspensionRegistration) => void; + /** + * Self-termination sink forwarded to the supervisor's `onSelfTerminate` + * binding: invoked when the supervisor drives itself to a terminal phase + * (crash-loop latch, channel crash, recycle failure). Production wiring + * routes it to the deploy router's address reclaim. + */ + onSelfTerminate?: (info: { + phase: "stopped" | "crash-looping"; + reason: string; + }) => void; /** * Decrypted credential material from the deploy frame's * `workflow.credentials`, forwarded verbatim to the supervisor's @@ -390,6 +400,9 @@ export function createSidecarWorkflowSupervisor( ...(opts.onSuspensionRegister !== undefined ? { onSuspensionRegister: opts.onSuspensionRegister } : {}), + ...(opts.onSelfTerminate !== undefined + ? { onSelfTerminate: opts.onSelfTerminate } + : {}), ...(opts.credentialDelivery !== undefined ? { credentialDelivery: opts.credentialDelivery } : {}), diff --git a/apps/sidecar/src/workflow-substrate-factory/child-runtime.ts b/apps/sidecar/src/workflow-substrate-factory/child-runtime.ts index 6eb10138d..556ddeaea 100644 --- a/apps/sidecar/src/workflow-substrate-factory/child-runtime.ts +++ b/apps/sidecar/src/workflow-substrate-factory/child-runtime.ts @@ -42,6 +42,7 @@ import { type RunSuspendableChild, type SourcesSnapshotRef, } from "@intx/workflow-host"; +import type { MailPartReader } from "@intx/types/runtime"; import { parseStepInferenceSources, @@ -62,8 +63,9 @@ import { * workflow-typed `authorize` the spawn seam threaded from the parent child * (CL-6448: the credentials-backed authorize, so a body agent's tool calls * gate through the same per-step grant snapshot a top-level step's do), - * and the parent's live `CredentialWiring` for tool bundles that declare a - * `credentials` capability. + * the parent's live `CredentialWiring` for tool bundles that declare a + * `credentials` capability, and the parent's `MailPartReader` so a body's + * attachments-only inbound mail resolves its parts instead of throwing. */ export type SidecarBodyStepInvoker = ( req: StepInvokeRequest, @@ -71,6 +73,7 @@ export type SidecarBodyStepInvoker = ( sourcesRef: SourcesSnapshotRef, onEvent: (event: InferenceEvent) => void, credentialWiring?: CredentialWiring, + mailPartReader?: MailPartReader, ) => Promise; /** @@ -332,6 +335,7 @@ export function createSidecarSpawnSuspendableChild( signal, authorize: threadedAuthorize, credentialWiring, + mailPartReader, }, onEvent, ) => { @@ -385,6 +389,7 @@ export function createSidecarSpawnSuspendableChild( bodySourcesRef, onEvent, credentialWiring, + mailPartReader, ); } diff --git a/apps/sidecar/src/workflow-substrate-factory/index.ts b/apps/sidecar/src/workflow-substrate-factory/index.ts index 737320c0a..bc1c2d4c8 100644 --- a/apps/sidecar/src/workflow-substrate-factory/index.ts +++ b/apps/sidecar/src/workflow-substrate-factory/index.ts @@ -31,6 +31,9 @@ import type { GrantRule } from "@intx/authz"; import { builtinCredentialProviders, createCredentialProviderRegistry, + driveConnectorReplies, + type AgentEventStream, + type ConnectorReplyDrain, } from "@intx/harness"; import { createHttpRawAuthorizationCredentialProvider, @@ -54,8 +57,12 @@ import { type RunWorkflowChildBindings, type SubstrateFactory, type SubstrateFactoryEnv, + createChildMailboxReader, + createMailboxWatchRegistry, + type SupervisorBackedTransportInbound, } from "@intx/workflow-host"; import { type ReadParkedApprovalOps, type StepInvoker } from "@intx/workflow"; +import type { InboundMessage } from "@intx/types/runtime"; import { createToolBearingAgentFactory, @@ -74,11 +81,7 @@ import { SIDECAR_SUBSTRATE_CONFIG_KEYS, SubstrateConfig, } from "./config"; -import { actionStepStorageRoot, runStepStorageRoot } from "./storage-paths"; -import { - createActionToolHandlerRegistry, - type ActionStepMaterializationArgs, -} from "../action-tool-handler"; +import { runStepStorageRoot } from "./storage-paths"; import { readColdParkedApprovalSnapshot, readColdParkedPendingOperations, @@ -350,6 +353,32 @@ export function createSidecarSubstrateFactory( }) : undefined; + // INBOUND half of mailbox ownership. One watch registry per child, + // shared by the step agent's supervisor-backed transport (its `watch` + // registers callbacks here, backing `mail_wait`) and the child's control + // loop (which fires each `mailbox.notify` into it); it rides out on the + // bindings so `runWorkflowChild` routes notifications to this instance. + // The reader opens a fresh committed snapshot of the deployment's + // substrate `INBOX` per read, so a read after a `mailbox.notify` sees the + // message the supervisor just committed. Every step env -- top-level or + // onTrigger body -- gets the surface, since the fork's bodies are + // tool-bearing agents. + const mailboxWatchRegistry = createMailboxWatchRegistry(); + const transportInbound: SupervisorBackedTransportInbound = { + reader: createChildMailboxReader({ + substrate, + repoId: workflowRunRepoId, + principal, + ref: validated.WORKFLOW_RUN_REF, + }), + watchRegistry: mailboxWatchRegistry, + // The child holds no sender-key registry, so `fetchFull` reports every + // message's signature status as "unknown". + getCrypto: () => undefined, + // Flag/expunge writes ride to the supervisor, the sole mailbox writer. + mutationBridge: env.mailboxMutationBridge, + }; + const buildStepEnvBaseOpts = { dataDir: validated.SIDECAR_DATA_DIR, workflowRunRepoId, @@ -358,6 +387,7 @@ export function createSidecarSubstrateFactory( mailboxAddress: env.spawn.mailboxAddress, stepCount: env.spawn.stepCount, outboundMailBridge: env.outboundMailBridge, + inbound: transportInbound, cache: stepToolCache, adapters: childAdapterRegistry, hubArtifactsUrl: deriveHubHttpUrl(validated.HUB_WS_URL), @@ -451,6 +481,7 @@ export function createSidecarSubstrateFactory( sourcesRef, onEvent, credentialWiring, + mailPartReader, ) => { try { return await createWorkflowStepInvoker({ @@ -460,6 +491,7 @@ export function createSidecarSubstrateFactory( agentFactory: stepAgentFactory, sourcesRef, onEvent, + ...(mailPartReader !== undefined ? { mailPartReader } : {}), })(req); } finally { const bodyStepId = req.authzContext.stepId; @@ -520,6 +552,49 @@ export function createSidecarSubstrateFactory( } : undefined; + // Connector-thread seed: when the deployment is warm-kept, route each + // mail-derived inbound message onto the warm agent's connector thread + // before its send, so the reply path has thread state. Keyed by the step + // identity the durable store is filed under. + const seedInbound = + durableConversation !== undefined + ? async (key: string, message: InboundMessage) => { + await durableConversation.get(key).seedInbound(message); + } + : undefined; + + // Connector reply drain: on each `connector.reply` the warm agent emits, + // compose a threaded reply from the durable store's connector thread, + // send it through the outbound bridge (the same signed-send path the + // agent's own transport uses) and advance the thread from the receipt. + // The References chain comes from the committed mailbox: the parent's + // own References plus its Message-Id; a parent miss (first reply on a + // fresh thread) leaves the transport to derive [inReplyTo]. + const driveReplies = + durableConversation !== undefined + ? (key: string, stream: AgentEventStream): ConnectorReplyDrain => + driveConnectorReplies({ + stream, + composeReply: () => durableConversation.get(key).composeReply(), + send: (message) => + env.outboundMailBridge.submit( + env.spawn.mailboxAddress, + message, + ), + resolveReferences: async (inReplyTo) => { + const store = await transportInbound.reader.open(); + const parent = store.messages.find( + (m) => m.envelope.messageId === inReplyTo, + ); + return parent === undefined + ? undefined + : [...parent.envelope.references, parent.envelope.messageId]; + }, + onReplySent: (receipt) => + durableConversation.get(key).onReplySent(receipt), + }) + : undefined; + const invokeStep: RunWorkflowChildBindings["invokeStep"] = async ( req, onEvent, @@ -527,25 +602,21 @@ export function createSidecarSubstrateFactory( warmCache, sourcesRef, credentialWiring, - ) => { - const stepInvokerBaseOpts = { + mailPartReader, + ) => + createWorkflowStepInvoker({ workflowAuthorize: authorize, buildEnv: (buildReq: Parameters[0]) => buildStepEnv(buildReq, sourcesRef, credentialWiring), agentFactory: stepAgentFactory, onEvent, sourcesRef, - }; - const stepInvokerOptsWithWarmCache = - warmCache !== undefined - ? { ...stepInvokerBaseOpts, warmCache } - : stepInvokerBaseOpts; - const stepInvokerOpts = - onRunBoundary !== undefined - ? { ...stepInvokerOptsWithWarmCache, onRunBoundary } - : stepInvokerOptsWithWarmCache; - return createWorkflowStepInvoker(stepInvokerOpts)(req); - }; + mailPartReader, + ...(warmCache !== undefined ? { warmCache } : {}), + ...(onRunBoundary !== undefined ? { onRunBoundary } : {}), + ...(seedInbound !== undefined ? { seedInbound } : {}), + ...(driveReplies !== undefined ? { driveReplies } : {}), + })(req); const evaluateGrantsAdapter: GrantEvaluator = async ({ resource, @@ -680,47 +751,6 @@ export function createSidecarSubstrateFactory( }), ); - // Native `action` steps carry no `agent`, so they never reach - // `invokeStep` above -- the runtime dispatches them through the - // separate `ActionInvoker` seam, whose `handler` refs this binding - // resolves. The child awaits it once, right after the definition - // re-verify, with the resolved in-memory `WorkflowDefinition` and the - // live `CredentialWiring` -- so every action step's tool closure is - // materialized eagerly at establish (a missing tool package or an - // unresolvable credential fails now, not mid-run) and each handler's - // `credentials` capability is scoped through the same per-step grant - // wiring agent steps use. Wired unconditionally: an action-free - // deployment enumerates zero action steps and resolves nothing. - const resolveActionHandler: RunWorkflowChildBindings["resolveActionHandler"] = - ({ definition, credentialWiring }) => { - const materializationByStepId = new Map< - string, - ActionStepMaterializationArgs - >(); - for (const [stepId, primitive] of Object.entries(definition.steps)) { - if (primitive.kind !== "action") continue; - materializationByStepId.set(stepId, { - dataDir: validated.SIDECAR_DATA_DIR, - mailboxAddress: env.spawn.mailboxAddress, - stepId, - stepCount: env.spawn.stepCount, - storeDir: actionStepStorageRoot({ - dataDir: validated.SIDECAR_DATA_DIR, - workflowRunRepoId, - stepId, - }), - cache: stepToolCache, - registries: parseToolRegistries(validated.SIDECAR_TOOL_REGISTRIES), - credentials: { wiring: credentialWiring }, - }); - } - return createActionToolHandlerRegistry({ - definition, - materializationByStepId, - providers: credentialProviders, - }); - }; - const bindings: RunWorkflowChildBindings = { substrate, workflowRunRepoId, @@ -728,13 +758,13 @@ export function createSidecarSubstrateFactory( principal, invokeStep, initialSources: stepInferenceSources, - resolveActionHandler, runChild, runSuspendableChild, scheduler, evaluateGrants: evaluateGrantsAdapter, loadParkedApproval, readParkedApprovalOps, + mailboxWatchRegistry, ...(cleanupRunStorage !== undefined ? { cleanupRunStorage } : {}), }; return bindings; diff --git a/apps/sidecar/src/workflow-substrate-factory/step-env.ts b/apps/sidecar/src/workflow-substrate-factory/step-env.ts index 7dbe1fdcc..abeb25139 100644 --- a/apps/sidecar/src/workflow-substrate-factory/step-env.ts +++ b/apps/sidecar/src/workflow-substrate-factory/step-env.ts @@ -26,6 +26,7 @@ import { type CredentialWiring, type SourcesSnapshotRef, type StepEnvBase, + type SupervisorBackedTransportInbound, } from "@intx/workflow-host"; import type { DurableConversationRegistry } from "../conversation-state"; @@ -101,6 +102,17 @@ export interface SidecarStepBuildEnvDeps { * key. */ outboundMailBridge: ChildOutboundMailBridge; + /** + * Inbound local IMAP surface for the step agent's supervisor-backed + * transport (INBOUND half of mailbox ownership): the child mailbox reader + * (a fresh committed snapshot of the deployment's substrate `INBOX` per + * read), the shared watch registry the child's control loop fires + * `mailbox.notify` into, and the mutation bridge flag/expunge writes ride + * to the supervisor on. Present, `mail_read` / `mail_search` / `mail_wait` + * resolve against the committed mailbox; absent, the inbound methods stay + * inert. + */ + inbound?: SupervisorBackedTransportInbound; /** Per-step tool-loader caps (cache + registry tarball size). */ cache: StepToolCacheConfig; /** @@ -319,20 +331,22 @@ export function createSidecarStepBuildEnv( }, ); - // Supervisor-backed transport for the step agent's mail tools - // (OUTBOUND half of mailbox ownership). Inbound is inert -- the - // supervisor delivers the agent's input as the step input, not - // through the agent's own mailbox -- and outbound (`send`) routes - // over the control IPC to the supervisor, which performs the actual - // signed send through the host transport as `address`. `address` is - // the deployment mailbox address: the same identity the host - // registered the agent's `CryptoProvider` against, so the outbound - // mail carries the agent's signature with parity to the in-process - // path. Both `transport` and `address` are the env keys - // `@intx/tools-mail`'s sidecar bundle declares in its `requires`. + // Supervisor-backed transport for the step agent's mail tools (both + // halves of mailbox ownership). Outbound (`send`) routes over the + // control IPC to the supervisor, which performs the actual signed send + // through the host transport as `address`: the deployment mailbox + // address, the same identity the host registered the agent's + // `CryptoProvider` against, so the outbound mail carries the agent's + // signature with parity to the in-process path. Inbound + // (`deps.inbound`) makes `mail_read` / `mail_search` / `mail_wait` + // resolve locally against a fresh committed snapshot of the + // deployment's substrate `INBOX`. Both `transport` and `address` are + // the env keys `@intx/tools-mail`'s sidecar bundle declares in its + // `requires`. const transport = createSupervisorBackedTransport( deps.outboundMailBridge, deps.mailboxAddress, + deps.inbound, ); // The step env carries `transport` + `address` beyond `BaseEnv` so diff --git a/apps/sidecar/test/action-runtime-env.test.ts b/apps/sidecar/test/action-runtime-env.test.ts deleted file mode 100644 index 1312be70c..000000000 --- a/apps/sidecar/test/action-runtime-env.test.ts +++ /dev/null @@ -1,375 +0,0 @@ -// Runtime-env-level probe for the CL-6325 `invokeAction`/`loopFns` bind. -// -// Exercises the PRODUCTION seam end to end: the sidecar's -// `resolveActionHandler` binding (the workbench-native -// `createActionToolHandlerRegistry` over a fake tool materializer) is -// resolved exactly the way `runWorkflowChild` resolves it, threaded into -// the vendored `buildRuntimeEnv`, and a probe workflow with a native -// `action` step is driven through `runtimeRun` against a real on-disk -// workflow-run substrate. Proven here: -// - a declared action dispatches its materialized tool and the run -// completes; -// - the effect ledger dedups the effect exactly-once (a replayed -// invocation returns the recorded output without re-running the tool); -// - an effect whose capability the step's `effect.requires` does NOT -// declare is refused loudly and fails the run; -// - a handler ref no action step declares is refused loudly; -// - `loopFns` and `runLoopIteration` are bound, and an unregistered -// loop-fn ref is refused loudly (the fail-closed default registry). - -import { describe, test, expect, afterAll, beforeAll } from "bun:test"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { generateKeyPair } from "@intx/crypto"; -import type { KeyPair } from "@intx/types/runtime"; -import { - createDefaultDirectorRegistry, - defineTool, - type ToolBundle, -} from "@intx/agent"; -import { - createRepoStore, - workflowRunKindHandler, - WORKFLOW_RUN_GITIGNORE_PATH, -} from "@intx/hub-sessions"; -import type { AuthorizeFn } from "@intx/hub-sessions"; -import type { - RepoId, - WorkflowRunWorkflowProcessPrincipal, -} from "@intx/hub-sessions/substrate"; -import { - action, - defineWorkflow, - type WorkflowAuthorizeFn, - type WorkflowDefinition, -} from "@intx/workflow"; -import { - buildRuntimeEnv, - createControlChannelSender, - createWorkflowHostDrainController, - createWorkflowRunRepoStore, - generateChannelId, - type CredentialWiring, - type RunWorkflowChildBindings, -} from "@intx/workflow-host"; -import { createCredentialProviderRegistry } from "@intx/harness"; - -import { - createActionToolHandlerRegistry, - type ActionStepMaterializationArgs, - type MaterializeStepTools, -} from "../src/action-tool-handler"; -import { runtimeRun } from "@intx/workflow"; - -const REF = "refs/heads/main"; -const DEPLOYMENT_ID = "deployment-action-env"; -const WORKFLOW_RUN_REPO_ID: RepoId = { - kind: "workflow-run", - id: DEPLOYMENT_ID, -}; -const allowAll: AuthorizeFn = () => ({ allowed: true }); -const PRINCIPAL: WorkflowRunWorkflowProcessPrincipal = { - kind: "workflow-process", - anchorRunId: DEPLOYMENT_ID, -}; -const workflowAllowAll: WorkflowAuthorizeFn = async () => ({ - effect: "allow", - matchingGrants: [], - resolvedBy: null, -}); - -const tempDirs: string[] = []; -let signingKey: KeyPair; - -beforeAll(async () => { - signingKey = await generateKeyPair(); -}); - -afterAll(async () => { - for (const d of tempDirs.splice(0)) { - await fs.promises.rm(d, { recursive: true, force: true }).catch(() => { - /* best effort */ - }); - } -}); - -async function makeSubstrate( - prefix: string, -): Promise> { - const dataDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), prefix)); - tempDirs.push(dataDir); - const substrate = createRepoStore({ - dataDir, - signingKey, - handlers: { "workflow-run": workflowRunKindHandler }, - authorize: allowAll, - }); - await substrate.writeTree({ kind: "hub" }, WORKFLOW_RUN_REPO_ID, REF, { - files: { [WORKFLOW_RUN_GITIGNORE_PATH]: "" }, - message: "genesis", - }); - return substrate; -} - -/** One-action probe: dispatches `handler` with a literal input and the - * given declared effect capabilities. */ -function probeDefinition( - handler: string, - requires: readonly string[], -): WorkflowDefinition { - return defineWorkflow({ - id: "wf-action-probe", - trigger: { type: "manual" }, - steps: { - act: action({ - handler, - input: { literal: { text: "ping" } }, - effect: { requires: [...requires] }, - }), - }, - }); -} - -const stubCredentialWiring: CredentialWiring = { - materialRef: { current: null }, - resolveStepGrants: () => [], -}; - -/** Mirrors the sidecar substrate factory's `resolveActionHandler` - * binding, with the registry's `materialize` test seam standing in for - * the on-disk deploy-tree read. */ -function makeResolveActionHandler( - materialize: MaterializeStepTools, -): NonNullable { - return ({ definition, credentialWiring }) => { - const materializationByStepId = new Map< - string, - ActionStepMaterializationArgs - >(); - for (const [stepId, primitive] of Object.entries(definition.steps)) { - if (primitive.kind !== "action") continue; - materializationByStepId.set(stepId, { - dataDir: "/tmp/action-runtime-env-test", - mailboxAddress: `${DEPLOYMENT_ID}@run.test`, - stepId, - stepCount: 1, - storeDir: "/tmp/action-runtime-env-test/store", - cache: { cacheMaxBytes: 1024, registryMaxTarballBytes: 1024 }, - registries: new Map(), - credentials: { wiring: credentialWiring }, - }); - } - return createActionToolHandlerRegistry({ - definition, - materializationByStepId, - providers: createCredentialProviderRegistry([]), - materialize, - }); - }; -} - -async function makeEnvForRun(opts: { - runId: string; - definition: WorkflowDefinition; - materialize: MaterializeStepTools; -}) { - const substrate = await makeSubstrate("action-runtime-env-"); - const bindings: RunWorkflowChildBindings = { - substrate, - workflowRunRepoId: WORKFLOW_RUN_REPO_ID, - workflowRunRef: REF, - principal: PRINCIPAL, - invokeStep: async () => ({ output: null }), - scheduler: { scheduleIn: () => () => undefined }, - evaluateGrants: async () => ({ - effect: "allow" as const, - matchingGrants: [], - resolvedBy: null, - }), - resolveActionHandler: makeResolveActionHandler(opts.materialize), - }; - const runtimeRepoStore = createWorkflowRunRepoStore({ - substrate, - repoId: WORKFLOW_RUN_REPO_ID, - principal: PRINCIPAL, - ref: REF, - }); - // Resolved exactly as `runWorkflowChild` resolves it: awaited once, - // against the definition and the live credential wiring. - const resolveActionHandler = - bindings.resolveActionHandler !== undefined - ? await bindings.resolveActionHandler({ - definition: opts.definition, - credentialWiring: stubCredentialWiring, - }) - : (): never => { - throw new Error("unreachable: binding wired above"); - }; - const upstreamSender = createControlChannelSender({ - privateKeySeed: signingKey.privateKey, - channelId: generateChannelId(), - writer: { write: () => undefined }, - }); - return buildRuntimeEnv({ - runId: opts.runId, - bindings, - runtimeRepoStore, - authorize: workflowAllowAll, - directors: createDefaultDirectorRegistry(), - suspendableChildHost: undefined, - spawnChild: async () => { - throw new Error("probe workflow spawns no children"); - }, - clock: () => new Date(), - newId: (prefix: string) => `${prefix}-${String(nextId())}`, - drainController: createWorkflowHostDrainController({ - definition: opts.definition, - }), - warmCache: undefined, - sourcesRef: { current: {} }, - credentialWiring: stubCredentialWiring, - onEvent: () => undefined, - upstreamSender, - resolveActionHandler, - loopFns: (ref: string) => { - throw new Error(`unknown loop fn ${JSON.stringify(ref)}`); - }, - }); -} - -let idCounter = 0; -function nextId(): number { - idCounter += 1; - return idCounter; -} - -/** A one-tool factory that echoes its call arguments back as JSON, - * counting invocations so exactly-once is observable. */ -function countingEchoFactory(toolName: string, counter: { runs: number }) { - return defineTool({ - id: "@test/echo-tools/echo", - definitions: [{ name: toolName }], - factory: (): ToolBundle => ({ - definitions: [{ name: toolName, description: "echo", inputSchema: {} }], - run: async (call) => { - counter.runs += 1; - return { - callId: call.id, - content: JSON.stringify(call.arguments), - }; - }, - }), - }); -} - -describe("sidecar runtime-env action bind (CL-6325)", () => { - test("a declared action dispatches its tool, completes the run, and ledgers exactly once", async () => { - const counter = { runs: 0 }; - const materialize: MaterializeStepTools = async () => ({ - factories: [countingEchoFactory("probe_echo", counter)], - pluginFactories: [], - }); - const definition = probeDefinition("probe_echo", ["probe_echo"]); - const env = await makeEnvForRun({ - runId: "run-ok", - definition, - materialize, - }); - - const handle = runtimeRun(definition, env, { runId: "run-ok" }); - const result = await handle.complete; - expect(result.terminalStatus).toBe("completed"); - expect(result.outputs["act"]).toBe(JSON.stringify({ text: "ping" })); - expect(counter.runs).toBe(1); - - // Exactly-once: replaying the same effect against the same run's - // ledger returns the recorded output without re-running the tool. - const invokeAction = env.invokeAction; - if (invokeAction === undefined) { - throw new Error("runtime env did not bind invokeAction"); - } - const replayed = await invokeAction({ - handler: "probe_echo", - input: { text: "ping" }, - requires: ["probe_echo"], - authzContext: { runId: "run-ok", stepId: "act", attempt: 1 }, - signal: new AbortController().signal, - }); - expect(replayed.output).toBe(JSON.stringify({ text: "ping" })); - expect(counter.runs).toBe(1); - }); - - test("an effect capability the step did not declare is refused and fails the run", async () => { - const counter = { runs: 0 }; - const materialize: MaterializeStepTools = async () => ({ - factories: [countingEchoFactory("probe_echo", counter)], - pluginFactories: [], - }); - // The handler dispatches `probe_echo`, but the step declares no - // effect capabilities -- the EffectContext must refuse the perform. - const definition = probeDefinition("probe_echo", []); - const env = await makeEnvForRun({ - runId: "run-undeclared", - definition, - materialize, - }); - - const handle = runtimeRun(definition, env, { runId: "run-undeclared" }); - const result = await handle.complete; - expect(result.terminalStatus).toBe("failed"); - expect(counter.runs).toBe(0); - const failure = result.events.find((e) => e.kind === "StepFailed"); - expect(JSON.stringify(failure)).toContain( - "not in its declared requires set", - ); - }); - - test("a handler ref no action step declares is refused loudly and fails the run", async () => { - const counter = { runs: 0 }; - const materialize: MaterializeStepTools = async () => ({ - factories: [countingEchoFactory("probe_echo", counter)], - pluginFactories: [], - }); - const definition = probeDefinition("probe_echo", ["probe_echo"]); - const env = await makeEnvForRun({ - runId: "run-unknown-ref", - definition, - materialize, - }); - const invokeAction = env.invokeAction; - if (invokeAction === undefined) { - throw new Error("runtime env did not bind invokeAction"); - } - await expect( - invokeAction({ - handler: "never_registered", - input: {}, - requires: [], - authzContext: { runId: "run-unknown-ref", stepId: "act", attempt: 1 }, - signal: new AbortController().signal, - }), - ).rejects.toThrow(/never_registered/); - expect(counter.runs).toBe(0); - }); - - test("loopFns and runLoopIteration are bound; an unregistered loop-fn ref is refused", async () => { - const materialize: MaterializeStepTools = async () => ({ - factories: [countingEchoFactory("probe_echo", { runs: 0 })], - pluginFactories: [], - }); - const definition = probeDefinition("probe_echo", ["probe_echo"]); - const env = await makeEnvForRun({ - runId: "run-loopfns", - definition, - materialize, - }); - expect(env.runLoopIteration).toBeDefined(); - const loopFns = env.loopFns; - if (loopFns === undefined) { - throw new Error("runtime env did not bind loopFns"); - } - expect(() => loopFns("never_registered")).toThrow(/never_registered/); - }); -}); diff --git a/apps/sidecar/test/attachments-only-mail-step.test.ts b/apps/sidecar/test/attachments-only-mail-step.test.ts new file mode 100644 index 000000000..663120474 --- /dev/null +++ b/apps/sidecar/test/attachments-only-mail-step.test.ts @@ -0,0 +1,117 @@ +// CL-6164 pristine repro at the step-invoker seam. An event-only mail +// (`@corbits/chat`'s `workbench.agent-joined`: an empty text/plain part plus +// a JSON attachment) used to reach `agent.send` as `""` and kill the run +// with `retriesExhausted`. Upstream now decodes the mail and omits empty +// content, and the fork threads the run's `MailPartReader` to onTrigger +// bodies, so a body step receives the attachment and no `content`. +import { expect, test } from "bun:test"; + +import type { Agent, SendResult } from "@intx/agent"; +import type { AuthzCallResult } from "@intx/inference"; +import type { StepInvokeRequest } from "@intx/workflow"; +import { + createWorkflowStepInvoker, + type StepEnvBase, +} from "@intx/workflow-host"; +import type { InboundMessage, Mail } from "@intx/types/runtime"; + +const ALLOW: AuthzCallResult = { + effect: "allow", + matchingGrants: [], + resolvedBy: null, +}; + +const AGENT_JOINED = new TextEncoder().encode( + JSON.stringify({ type: "workbench.agent-joined", agentId: "agt_1" }), +); + +function eventOnlyMail(): Mail { + return { + headers: { + from: "prn_member@bench.example.test", + to: ["run_anchor@runs.example.test"], + messageId: "", + } as Mail["headers"], + rawHeaders: {}, + parts: [ + { contentType: "text/plain", ref: "part:text", text: "" }, + { + contentType: "application/json", + filename: "workbench.agent-joined.json", + disposition: "attachment", + ref: "part:event", + }, + ], + }; +} + +function stubAgent(sent: (string | InboundMessage)[]): Agent { + const result: SendResult = { + type: "reply", + reply: "ok", + turn: { role: "assistant", content: [] } as never, + }; + return { + send: async (content: string | InboundMessage) => { + sent.push(content); + return result; + }, + stream: async function* () {}, + deliver: () => undefined, + close: async () => undefined, + } as unknown as Agent; +} + +test("an attachments-only mail reaches a body step as attachments with no empty content", async () => { + const sent: (string | InboundMessage)[] = []; + const read: string[] = []; + const invoke = createWorkflowStepInvoker({ + workflowAuthorize: () => Promise.resolve(ALLOW), + buildEnv: () => Promise.resolve({} as unknown as StepEnvBase), + agentFactory: () => Promise.resolve(stubAgent(sent)), + sourcesRef: { current: {} }, + mailPartReader: { + read: (ref) => { + read.push(ref); + return Promise.resolve(AGENT_JOINED); + }, + }, + }); + + const req = { + agent: { id: "reply", instructions: "reply", tools: [] }, + input: eventOnlyMail(), + authzContext: { stepId: "reply", runId: "run_1" }, + signal: new AbortController().signal, + } as unknown as StepInvokeRequest; + + await expect(invoke(req)).resolves.toMatchObject({ + output: { reply: "ok" }, + }); + expect(read).toEqual(["part:event"]); + const message = sent[0]; + expect(typeof message).toBe("object"); + const inbound = message as InboundMessage; + expect(inbound.content).toBeUndefined(); + expect(inbound.attachments).toHaveLength(1); + expect(inbound.attachments?.[0]?.contentType).toBe("application/json"); +}); + +test("without a mail-part reader the same mail is refused loudly, never flattened to an empty string", async () => { + const sent: (string | InboundMessage)[] = []; + const invoke = createWorkflowStepInvoker({ + workflowAuthorize: () => Promise.resolve(ALLOW), + buildEnv: () => Promise.resolve({} as unknown as StepEnvBase), + agentFactory: () => Promise.resolve(stubAgent(sent)), + sourcesRef: { current: {} }, + }); + const req = { + agent: { id: "reply", instructions: "reply", tools: [] }, + input: eventOnlyMail(), + authzContext: { stepId: "reply", runId: "run_1" }, + signal: new AbortController().signal, + } as unknown as StepInvokeRequest; + + await expect(invoke(req)).rejects.toThrow(/no mail-part reader wired/); + expect(sent).toHaveLength(0); +}); diff --git a/apps/sidecar/test/workflow-substrate-factory-suspendable-child.test.ts b/apps/sidecar/test/workflow-substrate-factory-suspendable-child.test.ts index da2b0c108..1a0a5b359 100644 --- a/apps/sidecar/test/workflow-substrate-factory-suspendable-child.test.ts +++ b/apps/sidecar/test/workflow-substrate-factory-suspendable-child.test.ts @@ -278,11 +278,12 @@ describe("createSidecarSpawnSuspendableChild", () => { }); // CL-6448: the body-turn tool seam. The spawn input threads the parent - // child's credentials-backed authorize and live credential wiring; the - // body invoker must receive exactly those, so a body agent's tool calls - // gate through the parent's per-step grant snapshot instead of the - // throwing stub. - test("the spawn input's authorize and credentialWiring reach the body invoker", async () => { + // child's credentials-backed authorize, live credential wiring and mail + // part reader; the body invoker must receive exactly those, so a body + // agent's tool calls gate through the parent's per-step grant snapshot + // instead of the throwing stub, and an attachments-only inbound mail + // resolves its parts instead of throwing for want of a reader. + test("the spawn input's authorize, credentialWiring and mailPartReader reach the body invoker", async () => { const substrate = await makeSubstrate("suspendable-body-authorize-"); const dataDir = await makeTempDir("suspendable-body-authz-datadir-"); const sourcesDir = path.join( @@ -313,16 +314,23 @@ describe("createSidecarSpawnSuspendableChild", () => { materialRef: { current: null }, resolveStepGrants: () => [], }; - const seen: { authorize?: unknown; credentialWiring?: unknown } = {}; + const threadedReader = { read: () => Promise.resolve(new Uint8Array()) }; + const seen: { + authorize?: unknown; + credentialWiring?: unknown; + mailPartReader?: unknown; + } = {}; const bodyInvokeStep: SidecarBodyStepInvoker = async ( _req, authorize, _sourcesRef, _onEvent, credentialWiring, + mailPartReader, ) => { seen.authorize = authorize; seen.credentialWiring = credentialWiring; + seen.mailPartReader = mailPartReader; return { output: { done: true } }; }; const spawn = createSidecarSpawnSuspendableChild({ @@ -352,6 +360,7 @@ describe("createSidecarSpawnSuspendableChild", () => { signal: new AbortController().signal, authorize: threadedAuthorize as never, credentialWiring: threadedWiring as never, + mailPartReader: threadedReader, }, () => undefined, ); @@ -360,6 +369,7 @@ describe("createSidecarSpawnSuspendableChild", () => { expect(terminal.kind).toBe("terminal"); expect(seen.authorize).toBe(threadedAuthorize); expect(seen.credentialWiring).toBe(threadedWiring); + expect(seen.mailPartReader).toBe(threadedReader); }); test("a parent abort while parked cancels the child and surfaces a terminal", async () => { diff --git a/bun.lock b/bun.lock index c506ba175..4db1d162e 100644 --- a/bun.lock +++ b/bun.lock @@ -111,12 +111,11 @@ "@corbits/credential-providers": "workspace:*", "@corbits/error-sink": "workspace:*", "@corbits/ollama-adapter": "workspace:*", - "@corbits/workflow-host-actions": "workspace:*", "@intx/agent": "workspace:*", "@intx/authz": "0.3.0", "@intx/crypto": "0.3.0", - "@intx/harness": "0.3.0", - "@intx/hub-agent": "0.3.0", + "@intx/harness": "workspace:*", + "@intx/hub-agent": "workspace:*", "@intx/hub-sessions": "workspace:*", "@intx/inference": "workspace:*", "@intx/log": "0.3.0", @@ -620,7 +619,7 @@ "name": "@corbits/credential-providers", "version": "0.0.1", "dependencies": { - "@intx/harness": "0.3.0", + "@intx/harness": "workspace:*", "@intx/types": "workspace:*", }, "devDependencies": { @@ -798,7 +797,7 @@ "@intx/authz": "0.3.0", "@intx/crypto": "0.3.0", "@intx/db": "workspace:*", - "@intx/harness": "0.3.0", + "@intx/harness": "workspace:*", "@types/bun": "catalog:", "typescript": "catalog:", }, @@ -948,7 +947,7 @@ "@intx/authz": "0.3.0", "@intx/crypto": "0.3.0", "@intx/db": "workspace:*", - "@intx/harness": "0.3.0", + "@intx/harness": "workspace:*", "@types/bun": "catalog:", "typescript": "catalog:", }, @@ -1508,21 +1507,6 @@ "typescript": "catalog:", }, }, - "packages/workflow-host-actions": { - "name": "@corbits/workflow-host-actions", - "version": "0.0.1", - "dependencies": { - "@intx/hub-sessions": "workspace:*", - "@intx/types": "workspace:*", - "@intx/workflow": "workspace:*", - "arktype": "catalog:", - }, - "devDependencies": { - "@intx/crypto": "0.3.0", - "@types/bun": "catalog:", - "typescript": "catalog:", - }, - }, "packages/workflow-source": { "name": "@corbits/workflow-source", "version": "0.0.1", @@ -1564,21 +1548,56 @@ "typescript": "catalog:", }, }, + "vendor/intx/harness": { + "name": "@intx/harness", + "version": "0.3.0", + "dependencies": { + "@intx/agent": "workspace:*", + "@intx/authz": "0.3.0", + "@intx/log": "0.3.0", + "@intx/types": "workspace:*", + }, + "devDependencies": { + "@intx/mime": "workspace:*", + "@types/bun": "catalog:", + "arktype": "catalog:", + "typescript": "catalog:", + }, + }, + "vendor/intx/hub-agent": { + "name": "@intx/hub-agent", + "version": "0.3.0", + "dependencies": { + "@intx/harness": "workspace:*", + "@intx/log": "0.3.0", + "@intx/mail-memory": "workspace:*", + "@intx/pack-transport": "0.3.0", + "@intx/storage-isogit": "0.3.0", + "@intx/types": "workspace:*", + "arktype": "catalog:", + "isomorphic-git": "catalog:", + }, + "devDependencies": { + "@types/bun": "catalog:", + "hono": "catalog:", + "typescript": "catalog:", + }, + }, "vendor/intx/hub-api": { "name": "@intx/hub-api", "version": "0.3.0", "dependencies": { "@hono/standard-validator": "^0.2.2", - "@intx/agent": "0.3.0", + "@intx/agent": "workspace:*", "@intx/authz": "0.3.0", "@intx/crypto": "0.3.0", "@intx/db": "workspace:*", "@intx/hub-common": "0.3.0", "@intx/hub-sessions": "workspace:*", "@intx/log": "0.3.0", - "@intx/mime": "0.3.0", + "@intx/mime": "workspace:*", "@intx/storage-isogit": "0.3.0", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "@intx/workflow-deploy": "workspace:*", "arktype": "catalog:", "better-auth": "catalog:", @@ -1591,7 +1610,7 @@ "devDependencies": { "@intx/workflow": "workspace:*", "@types/bun": "catalog:", - "@types/ssri": "^7.1.5", + "@types/ssri": "catalog:", "openapi-types": "^12.1.3", "tar": "catalog:", "typescript": "catalog:", @@ -1726,16 +1745,16 @@ "name": "@intx/workflow-host", "version": "0.3.0", "dependencies": { - "@corbits/workflow-host-actions": "workspace:*", - "@intx/agent": "0.3.0", + "@intx/agent": "workspace:*", "@intx/crypto": "0.3.0", "@intx/hub-sessions": "workspace:*", - "@intx/inference": "0.3.0", + "@intx/inference": "workspace:*", "@intx/log": "0.3.0", - "@intx/mail-memory": "0.3.0", - "@intx/mime": "0.3.0", + "@intx/mail-memory": "workspace:*", + "@intx/mailbox": "workspace:*", + "@intx/mime": "workspace:*", "@intx/storage-isogit": "0.3.0", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", "arktype": "catalog:", }, @@ -1961,8 +1980,8 @@ "@intx/authz": "0.3.0", "@intx/crypto": "0.3.0", "@intx/db": "workspace:*", - "@intx/harness": "0.3.0", - "@intx/hub-agent": "0.3.0", + "@intx/harness": "workspace:*", + "@intx/hub-agent": "workspace:*", "@intx/hub-api": "workspace:*", "@intx/hub-common": "0.3.0", "@intx/hub-sessions": "workspace:*", @@ -2261,8 +2280,6 @@ "@corbits/workflow-freeze": ["@corbits/workflow-freeze@workspace:packages/workflow-freeze"], - "@corbits/workflow-host-actions": ["@corbits/workflow-host-actions@workspace:packages/workflow-host-actions"], - "@corbits/workflow-source": ["@corbits/workflow-source@workspace:packages/workflow-source"], "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], @@ -2373,9 +2390,9 @@ "@intx/db": ["@intx/db@workspace:vendor/intx/db"], - "@intx/harness": ["@intx/harness@0.3.0", "", { "dependencies": { "@intx/agent": "0.3.0", "@intx/authz": "0.3.0", "@intx/log": "0.3.0", "@intx/types": "0.3.0" } }, "sha512-YXXHhM+Gr4FPd0UjJ9iEA5rh8DQCV7oJoL+I9X4g8KBNQtq7ZLUa3xVvQ/YeDZhz7g8OtN7023b3OkkjHuSHUw=="], + "@intx/harness": ["@intx/harness@workspace:vendor/intx/harness"], - "@intx/hub-agent": ["@intx/hub-agent@0.3.0", "", { "dependencies": { "@intx/harness": "0.3.0", "@intx/log": "0.3.0", "@intx/mail-memory": "0.3.0", "@intx/pack-transport": "0.3.0", "@intx/storage-isogit": "0.3.0", "@intx/types": "0.3.0", "arktype": "^2.1.29", "isomorphic-git": "^1.27.2" } }, "sha512-X0Y9Hh1nWIQrEfAqGF39ff9lN3Qh17AmW8kUtr+LxM0xqqE4OdRnxZauyImVMAKpuLcIQDUppLrZ7O4lj81hEQ=="], + "@intx/hub-agent": ["@intx/hub-agent@workspace:vendor/intx/hub-agent"], "@intx/hub-api": ["@intx/hub-api@workspace:vendor/intx/hub-api"], diff --git a/package.json b/package.json index c54afb0ed..8af8aa2cc 100644 --- a/package.json +++ b/package.json @@ -93,8 +93,8 @@ "@intx/authz": "0.3.0", "@intx/crypto": "0.3.0", "@intx/db": "workspace:*", - "@intx/harness": "0.3.0", - "@intx/hub-agent": "0.3.0", + "@intx/harness": "workspace:*", + "@intx/hub-agent": "workspace:*", "@intx/hub-api": "workspace:*", "@intx/hub-common": "0.3.0", "@intx/hub-sessions": "workspace:*", diff --git a/packages/credential-providers/package.json b/packages/credential-providers/package.json index cdffd792b..a00a8f6f1 100644 --- a/packages/credential-providers/package.json +++ b/packages/credential-providers/package.json @@ -13,7 +13,7 @@ "test": "bun test" }, "dependencies": { - "@intx/harness": "0.3.0", + "@intx/harness": "workspace:*", "@intx/types": "workspace:*" }, "devDependencies": { diff --git a/packages/granola-tools/package.json b/packages/granola-tools/package.json index e3ef0a44d..d68c844bf 100644 --- a/packages/granola-tools/package.json +++ b/packages/granola-tools/package.json @@ -29,7 +29,7 @@ "@intx/authz": "0.3.0", "@intx/crypto": "0.3.0", "@intx/db": "workspace:*", - "@intx/harness": "0.3.0", + "@intx/harness": "workspace:*", "@types/bun": "catalog:", "typescript": "catalog:" } diff --git a/packages/linear-tools/package.json b/packages/linear-tools/package.json index 13798c765..001c1a65b 100644 --- a/packages/linear-tools/package.json +++ b/packages/linear-tools/package.json @@ -29,7 +29,7 @@ "@intx/authz": "0.3.0", "@intx/crypto": "0.3.0", "@intx/db": "workspace:*", - "@intx/harness": "0.3.0", + "@intx/harness": "workspace:*", "@types/bun": "catalog:", "typescript": "catalog:" } diff --git a/packages/workflow-host-actions/LICENSE b/packages/workflow-host-actions/LICENSE deleted file mode 100644 index c6487f4fd..000000000 --- a/packages/workflow-host-actions/LICENSE +++ /dev/null @@ -1,176 +0,0 @@ -GNU LESSER GENERAL PUBLIC LICENSE - -Version 2.1, February 1999 - -Copyright (C) 1991, 1999 Free Software Foundation, Inc. -51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - -Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. - -[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] - -Preamble - -The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. - -This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. - -When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. - -To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. - -For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. - -We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. - -To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. - -Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. - -Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. - -When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. - -We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. - -For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. - -In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. - -Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. - -The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. - -GNU LESSER GENERAL PUBLIC LICENSE -TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - -0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". - -A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. - -The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) - -"Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. - -Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. - -1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. - -You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. - -2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: - - a) The modified work must itself be a software library. - - b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. - - c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. - - d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. - -(For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) - -These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. - -In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. - -3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. - -Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. - -This option is useful when you wish to copy part of the code of the Library into a program that is not a library. - -4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. - -If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. - -5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. - -However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. - -When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. - -If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) - -Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. - -6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. - -You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: - - a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) - - b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. - - c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. - - d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. - - e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. - -For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. - -It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. - -7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: - - a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. - - b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. - -8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. - -9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. - -10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. - -11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. - -If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. - -It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. - -This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. - -12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. - -13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. - -Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. - -14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. - -NO WARRANTY - -15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - -16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - -END OF TERMS AND CONDITIONS - -How to Apply These Terms to Your New Libraries - -If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). - -To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - - one line to give the library's name and an idea of what it does. - Copyright (C) year name of author - - This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. - -You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: - -Yoyodyne, Inc., hereby disclaims all copyright interest in -the library `Frob' (a library for tweaking knobs) written -by James Random Hacker. - -signature of Ty Coon, 1 April 1990 -Ty Coon, President of Vice -That's all there is to it! diff --git a/packages/workflow-host-actions/README.md b/packages/workflow-host-actions/README.md deleted file mode 100644 index 098ed3408..000000000 --- a/packages/workflow-host-actions/README.md +++ /dev/null @@ -1,32 +0,0 @@ -# @corbits/workflow-host-actions - -Host bindings for the action/loop seam `@intx/workflow`'s -`WorkflowRuntimeEnv` leaves to the host -(`invokeAction` / `effects` / `loopFns` / `runLoopIteration`). Upstream -defines the slots but never populates them in the production child host; -this package supplies the production implementations, droppable on any -Interchange instance. - -## Surfaces - -- `createActionHandlerRegistry(handlers)` / - `createLoopFnRegistry(fns)` — typed, fail-closed registries. An - unregistered ref throws with the ref name; `{}` yields a default that - refuses every ref, so an action-free deployment wires nothing and a - stray `action`/`loop` step fails its run loudly instead of silently. -- `createWorkflowActionInvoker({ authorize, effects, resolveHandler })` — - the runtime-env `invokeAction` surface. Resolves the step's `handler` - ref, builds a capability- and ledger-checked `EffectContext`, and runs - the handler. -- `createWorkflowRunEffectLedger(opts)` — durable, exactly-once - `EffectLedger` for one run, committed through the workflow-run - substrate under `runs//blobs/` (append-only kind-handler - layout). Envelope bytes read back off disk are parsed with arktype, - never asserted. -- `writeRunBlob` / `readRunBlob` / `blobsPrefixFor` / `isErrnoNotFound` — - the shared `runs//blobs/` read/write helpers the ledger rides. - -## Dependencies - -Only `@intx/workflow` (runtime contracts), `@intx/hub-sessions` -(substrate), `@intx/types`, and arktype — no workbench coupling. diff --git a/packages/workflow-host-actions/package.json b/packages/workflow-host-actions/package.json deleted file mode 100644 index 12266c7db..000000000 --- a/packages/workflow-host-actions/package.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "@corbits/workflow-host-actions", - "private": true, - "description": "Host bindings for @intx/workflow's action/loop runtime seam: fail-closed action-handler and loop-fn registries, a capability-checked action invoker, and a durable per-run effect ledger over the workflow-run substrate.", - "version": "0.0.1", - "license": "LGPL-2.1-or-later", - "type": "module", - "exports": { - ".": "./src/index.ts" - }, - "scripts": { - "typecheck": "tsc --noEmit", - "test": "bun test" - }, - "dependencies": { - "@intx/hub-sessions": "workspace:*", - "@intx/types": "workspace:*", - "@intx/workflow": "workspace:*", - "arktype": "catalog:" - }, - "devDependencies": { - "@intx/crypto": "0.3.0", - "@types/bun": "catalog:", - "typescript": "catalog:" - } -} diff --git a/packages/workflow-host-actions/src/action-invoker.test.ts b/packages/workflow-host-actions/src/action-invoker.test.ts deleted file mode 100644 index 203636b51..000000000 --- a/packages/workflow-host-actions/src/action-invoker.test.ts +++ /dev/null @@ -1,260 +0,0 @@ -// Covers the fail-closed registry surfaces and the capability- and -// ledger-checked action invoker, including durable dedupe through the -// workflow-run effect ledger. -import { describe, test, expect, afterAll, beforeAll } from "bun:test"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { generateKeyPair } from "@intx/crypto"; -import type { KeyPair } from "@intx/types/runtime"; -import { - createRepoStore, - type AuthorizeFn, - type KindHandler, - type Principal, - type RepoId, - type ValidatePushResult, -} from "@intx/hub-sessions"; -import type { EffectLedger, WorkflowAuthorizeFn } from "@intx/workflow"; - -import { - createActionHandlerRegistry, - createLoopFnRegistry, - createWorkflowActionInvoker, - type ActionHandler, -} from "./action-invoker"; -import { createWorkflowRunEffectLedger } from "./effect-ledger"; - -function inMemoryLedger(): EffectLedger { - const store = new Map(); - return { - async lookup(effectKey) { - return store.get(effectKey); - }, - async record(effectKey, output) { - store.set(effectKey, { output }); - }, - }; -} - -const allowAll: WorkflowAuthorizeFn = async () => ({ - effect: "allow", - matchingGrants: [], - resolvedBy: null, -}); - -const denyAll: WorkflowAuthorizeFn = async () => ({ - effect: "deny", - matchingGrants: [], - resolvedBy: null, -}); - -describe("createWorkflowActionInvoker", () => { - test("runs the resolved handler and returns its output", async () => { - const handler: ActionHandler = async (input) => { - return { echoed: input }; - }; - const invoker = createWorkflowActionInvoker({ - authorize: allowAll, - effects: inMemoryLedger(), - resolveHandler: createActionHandlerRegistry({ "echo.handler": handler }), - }); - const result = await invoker({ - handler: "echo.handler", - input: { n: 3 }, - requires: [], - authzContext: { runId: "r1", stepId: "s1", attempt: 1 }, - signal: new AbortController().signal, - }); - expect(result).toEqual({ output: { echoed: { n: 3 } } }); - }); - - test("unknown handler ref fails closed", async () => { - const invoker = createWorkflowActionInvoker({ - authorize: allowAll, - effects: inMemoryLedger(), - resolveHandler: createActionHandlerRegistry({}), - }); - await expect( - invoker({ - handler: "missing.handler", - input: null, - requires: [], - authzContext: { runId: "r1", stepId: "s1", attempt: 1 }, - signal: new AbortController().signal, - }), - ).rejects.toThrow(/unknown action handler/); - }); - - test("perform: undeclared capability fails closed", async () => { - const handler: ActionHandler = async (_input, ctx) => - ctx.perform({ - effectId: "x", - capability: "not.declared", - run: async () => "should-not-run", - }); - const invoker = createWorkflowActionInvoker({ - authorize: allowAll, - effects: inMemoryLedger(), - resolveHandler: createActionHandlerRegistry({ "cap.handler": handler }), - }); - await expect( - invoker({ - handler: "cap.handler", - input: null, - requires: ["other.cap"], - authzContext: { runId: "r1", stepId: "s1", attempt: 1 }, - signal: new AbortController().signal, - }), - ).rejects.toThrow(/not in its declared requires set/); - }); - - test("perform: authorize deny fails closed", async () => { - const handler: ActionHandler = async (_input, ctx) => - ctx.perform({ - effectId: "x", - capability: "side.effect", - run: async () => "should-not-run", - }); - const invoker = createWorkflowActionInvoker({ - authorize: denyAll, - effects: inMemoryLedger(), - resolveHandler: createActionHandlerRegistry({ "authz.handler": handler }), - }); - await expect( - invoker({ - handler: "authz.handler", - input: null, - requires: ["side.effect"], - authzContext: { runId: "r1", stepId: "s1", attempt: 1 }, - signal: new AbortController().signal, - }), - ).rejects.toThrow(/was not authorized/); - }); -}); - -describe("createWorkflowActionInvoker + durable ledger via perform", () => { - const tempDirs: string[] = []; - let signingKey: KeyPair; - - const permissiveHandler: KindHandler = { - kind: "agent-state", - directoryPrefix: "action-invoker-ledger-test", - validatePush(): ValidatePushResult { - return { ok: true }; - }, - onRefUpdated() { - /* no-op */ - }, - }; - - const substrateAllow: AuthorizeFn = () => ({ allowed: true }); - const principal: Principal = { kind: "test" }; - const REF = "refs/heads/main"; - - beforeAll(async () => { - signingKey = await generateKeyPair(); - }); - - afterAll(async () => { - for (const d of tempDirs.splice(0)) { - await fs.promises.rm(d, { recursive: true, force: true }).catch(() => { - /* best effort */ - }); - } - }); - - async function makeDurableLedger(runId: string) { - const dataDir = await fs.promises.mkdtemp( - path.join(os.tmpdir(), "action-invoker-ledger-"), - ); - tempDirs.push(dataDir); - const repoId: RepoId = { kind: "agent-state", id: `dep-${runId}` }; - const substrate = createRepoStore({ - dataDir, - signingKey, - handlers: { "agent-state": permissiveHandler }, - authorize: substrateAllow, - }); - const effects = createWorkflowRunEffectLedger({ - substrate, - repoId, - principal, - runId, - ref: REF, - }); - return { effects, substrate, repoId }; - } - - test("ctx.perform records once; second invoker call returns ledger hit without re-running", async () => { - let runs = 0; - const handler: ActionHandler = async (_input, ctx) => - ctx.perform({ - effectId: "side-effect", - capability: "test.effect", - run: async () => { - runs += 1; - return { n: runs }; - }, - }); - - const { effects, substrate, repoId } = - await makeDurableLedger("run-dedupe"); - const resolveHandler = createActionHandlerRegistry({ - "dedupe.handler": handler, - }); - const invoker = createWorkflowActionInvoker({ - authorize: allowAll, - effects, - resolveHandler, - }); - const call = { - handler: "dedupe.handler", - input: { x: 1 }, - requires: ["test.effect"] as const, - authzContext: { runId: "run-dedupe", stepId: "s1", attempt: 1 }, - signal: new AbortController().signal, - }; - - const first = await invoker(call); - expect(first).toEqual({ output: { n: 1 } }); - expect(runs).toBe(1); - - // Fresh invoker + ledger against the same substrate — simulates a child - // restart that rebuilds adapters after the effect was already recorded. - const restarted = createWorkflowActionInvoker({ - authorize: allowAll, - effects: createWorkflowRunEffectLedger({ - substrate, - repoId, - principal, - runId: "run-dedupe", - ref: REF, - }), - resolveHandler, - }); - const second = await restarted(call); - expect(second).toEqual({ output: { n: 1 } }); - expect(runs).toBe(1); - }); -}); - -describe("createLoopFnRegistry", () => { - test("resolves a registered pure fn", () => { - const registry = createLoopFnRegistry({ - "while.under": (out) => (out as { n: number }).n < 3, - "carry.inc": (out) => { - const n = (out as { n: number }).n; - return { n: n + 1 }; - }, - }); - expect(registry("while.under")({ n: 1 }, null)).toBe(true); - expect(registry("carry.inc")({ n: 1 }, null)).toEqual({ n: 2 }); - }); - - test("unknown loop fn fails closed", () => { - const registry = createLoopFnRegistry({}); - expect(() => registry("missing.fn")).toThrow(/unknown loop fn/); - }); -}); diff --git a/packages/workflow-host-actions/src/action-invoker.ts b/packages/workflow-host-actions/src/action-invoker.ts deleted file mode 100644 index 39ca3a8a5..000000000 --- a/packages/workflow-host-actions/src/action-invoker.ts +++ /dev/null @@ -1,90 +0,0 @@ -// Production `WorkflowRuntimeEnv.ActionInvoker` binding. -// -// Resolves an action's `handler` string ref to a host TypeScript function, -// builds a capability- and ledger-checked `EffectContext`, and runs the -// handler. Mirrors runLocal's default action invoker so the production -// host and the in-process test host share one contract. -// -// Fail-closed: an unknown handler ref throws rather than silently returning -// a stub output. A silent stub would let action workflows pass while their -// effects never ran. - -import { - createEffectContext, - type ActionHandler, - type ActionInvoker, - type EffectLedger, - type LoopFn, - type LoopFnRegistry, - type WorkflowAuthorizeFn, -} from "@intx/workflow"; - -export type { ActionHandler }; - -export type WorkflowActionInvokerOpts = { - authorize: WorkflowAuthorizeFn; - effects: EffectLedger; - /** - * Resolve a handler ref to a deterministic effect handler. Production - * wires a registry of host-owned handlers; tests inject a closed map. - */ - resolveHandler: (ref: string) => ActionHandler; -}; - -/** - * Construct the production action invoker. The returned callable is the - * runtime-env `invokeAction` surface. - */ -export function createWorkflowActionInvoker( - opts: WorkflowActionInvokerOpts, -): ActionInvoker { - return async ({ handler, input, requires, authzContext, signal }) => { - const fn = opts.resolveHandler(handler); - const ctx = createEffectContext({ - authorize: opts.authorize, - effects: opts.effects, - requires, - authzContext, - input, - }); - const output = await fn(input, ctx, signal); - return { output }; - }; -} - -/** - * Build a fail-closed action-handler registry from a static map. - * Unknown refs throw with the ref name so a missing registration is - * obvious in the run failure. Pass `{}` for a default that rejects every ref. - */ -export function createActionHandlerRegistry( - handlers: Readonly>, -): (ref: string) => ActionHandler { - return failClosedRegistry(handlers, "action handler"); -} - -/** - * Build a fail-closed loop pure-function registry from a static map. - * Unknown `while`/`carry` refs throw with the ref name. Pass `{}` for a - * default that rejects every ref. - */ -export function createLoopFnRegistry( - fns: Readonly>, -): LoopFnRegistry { - return failClosedRegistry(fns, "loop fn"); -} - -function failClosedRegistry( - items: Readonly>, - label: string, -): (ref: string) => T { - return (ref) => { - const item = items[ref]; - if (item === undefined) { - throw new Error( - `unknown ${label} ${JSON.stringify(ref)}; register it via the host ${label === "action handler" ? "action-handler" : "loop-fn"} registry`, - ); - } - return item; - }; -} diff --git a/packages/workflow-host-actions/src/effect-ledger.test.ts b/packages/workflow-host-actions/src/effect-ledger.test.ts deleted file mode 100644 index 7834ff04d..000000000 --- a/packages/workflow-host-actions/src/effect-ledger.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -// Covers the durable per-run effect ledger over the workflow-run -// substrate: miss/hit, durability across adapter rebuilds, envelope -// fail-closed paths, and kind-handler append-only semantics. -import { describe, test, expect, afterAll, beforeAll } from "bun:test"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { generateKeyPair } from "@intx/crypto"; -import { hexEncode } from "@intx/types"; -import type { KeyPair } from "@intx/types/runtime"; -import { - createRepoStore, - workflowRunKindHandler, - WORKFLOW_RUN_GITIGNORE_PATH, -} from "@intx/hub-sessions"; -import type { - AuthorizeFn, - KindHandler, - Principal, - RepoId, - ValidatePushResult, -} from "@intx/hub-sessions"; - -import { createWorkflowRunEffectLedger } from "./effect-ledger"; - -const tempDirs: string[] = []; - -async function makeTempDir(prefix: string): Promise { - const d = await fs.promises.mkdtemp(path.join(os.tmpdir(), prefix)); - tempDirs.push(d); - return d; -} - -let signingKey: KeyPair; - -beforeAll(async () => { - signingKey = await generateKeyPair(); -}); - -afterAll(async () => { - for (const d of tempDirs.splice(0)) { - await fs.promises.rm(d, { recursive: true, force: true }).catch(() => { - /* best effort */ - }); - } -}); - -const REF = "refs/heads/main"; -const allowAll: AuthorizeFn = () => ({ allowed: true }); - -// Adapter smoke tests target ledger behavior, not the kind handler's -// schema. A permissive agent-state handler stands in — same pattern as -// the blob substrate adapter tests. -const permissiveHandler: KindHandler = { - kind: "agent-state", - directoryPrefix: "effect-ledger-test", - validatePush(): ValidatePushResult { - return { ok: true }; - }, - onRefUpdated() { - /* no-op */ - }, -}; - -const TEST_PRINCIPAL: Principal = { kind: "test" }; - -async function makeLedger(runId: string, deploymentId: string) { - const dataDir = await makeTempDir("effect-ledger-adapter-"); - const repoId: RepoId = { kind: "agent-state", id: deploymentId }; - const substrate = createRepoStore({ - dataDir, - signingKey, - handlers: { "agent-state": permissiveHandler }, - authorize: allowAll, - }); - const ledger = createWorkflowRunEffectLedger({ - substrate, - repoId, - principal: TEST_PRINCIPAL, - runId, - ref: REF, - }); - return { ledger, substrate, repoId, dataDir }; -} - -async function identityKeyHex(effectKey: string): Promise { - const digest = await crypto.subtle.digest( - "SHA-256", - // ArrayBuffer-backed; Web Crypto BufferSource rejects ArrayBufferLike - // under TS 5.9, hence the assertion. - new TextEncoder().encode(effectKey) as Uint8Array, - ); - return hexEncode(new Uint8Array(digest)); -} - -describe("createWorkflowRunEffectLedger", () => { - test("lookup returns undefined on a miss", async () => { - const { ledger } = await makeLedger("run-miss", "dep-miss"); - expect(await ledger.lookup("never-recorded")).toBeUndefined(); - }); - - test("record then lookup returns the output", async () => { - const { ledger } = await makeLedger("run-hit", "dep-hit"); - await ledger.record("effect-key-1", { ok: true, n: 7 }); - expect(await ledger.lookup("effect-key-1")).toEqual({ - output: { ok: true, n: 7 }, - }); - }); - - test("record is durable across a fresh adapter on the same substrate", async () => { - const runId = "run-durable"; - const { substrate, repoId } = await makeLedger(runId, "dep-durable"); - const first = createWorkflowRunEffectLedger({ - substrate, - repoId, - principal: TEST_PRINCIPAL, - runId, - ref: REF, - }); - await first.record("effect-key-durable", "persisted-value"); - - // Fresh adapter — simulates a child process restart that rebuilds the - // ledger against the same workflow-run repo. - const second = createWorkflowRunEffectLedger({ - substrate, - repoId, - principal: TEST_PRINCIPAL, - runId, - ref: REF, - }); - expect(await second.lookup("effect-key-durable")).toEqual({ - output: "persisted-value", - }); - }); - - test("distinct effect keys do not collide", async () => { - const { ledger } = await makeLedger("run-multi", "dep-multi"); - await ledger.record("key-a", "A"); - await ledger.record("key-b", "B"); - expect(await ledger.lookup("key-a")).toEqual({ output: "A" }); - expect(await ledger.lookup("key-b")).toEqual({ output: "B" }); - }); - - test("record fails closed when output cannot form a {output} envelope", async () => { - const { ledger } = await makeLedger( - "run-unserializable", - "dep-unserializable", - ); - await expect(ledger.record("undef-key", undefined)).rejects.toThrow( - /cannot serialize output/, - ); - // Miss stays a miss — no bare `{}` was written. - expect(await ledger.lookup("undef-key")).toBeUndefined(); - }); - - test("lookup throws when the on-disk entry is not a {output} envelope", async () => { - const runId = "run-malformed"; - const { ledger, substrate, repoId } = await makeLedger( - runId, - "dep-malformed", - ); - const key = await identityKeyHex("bad-envelope"); - await substrate.writeTreePreservingPrefix(TEST_PRINCIPAL, repoId, REF, { - preservePrefix: `runs/${runId}/blobs/`, - merge: async (existing) => { - const files: Record = {}; - for (const [k, v] of existing) files[k] = v; - files[`runs/${runId}/blobs/${key}`] = JSON.stringify({ - notOutput: true, - }); - return files; - }, - message: "seed malformed effect entry", - }); - await expect(ledger.lookup("bad-envelope")).rejects.toThrow( - /is not a \{output\} envelope/, - ); - }); - - test("kind-handler path: identical re-record is idempotent; divergent fails closed", async () => { - const dataDir = await makeTempDir("effect-ledger-immutable-"); - const repoId: RepoId = { kind: "workflow-run", id: "dep-immutable" }; - const principal: Principal = { kind: "supervisor" }; - const runId = "run-immutable"; - const substrate = createRepoStore({ - dataDir, - signingKey, - handlers: { "workflow-run": workflowRunKindHandler }, - authorize: allowAll, - }); - // Kind handler requires every run dir to carry events/; seed a - // RunStarted so the first effect write has a valid prior layout. - await substrate.writeTree({ kind: "hub" }, repoId, REF, { - files: { - [WORKFLOW_RUN_GITIGNORE_PATH]: "", - [`runs/${runId}/events/0.json`]: JSON.stringify({ - type: "RunStarted", - seq: 0, - at: "2026-01-01T00:00:00.000Z", - runId, - definitionHash: "test-hash", - trigger: { type: "manual", payload: null }, - }), - }, - message: "genesis with run events", - }); - const ledger = createWorkflowRunEffectLedger({ - substrate, - repoId, - principal, - runId, - ref: REF, - }); - - await ledger.record("once-key", { first: true }); - // Identical re-record: kind handler's append-only compare accepts it. - await ledger.record("once-key", { first: true }); - expect(await ledger.lookup("once-key")).toEqual({ - output: { first: true }, - }); - - await expect(ledger.record("once-key", { first: false })).rejects.toThrow( - /immutable|diverge|append-only|path_violation/, - ); - // Original value is preserved — the divergent write must not land. - expect(await ledger.lookup("once-key")).toEqual({ - output: { first: true }, - }); - }); -}); diff --git a/packages/workflow-host-actions/src/effect-ledger.ts b/packages/workflow-host-actions/src/effect-ledger.ts deleted file mode 100644 index c2c4b18f3..000000000 --- a/packages/workflow-host-actions/src/effect-ledger.ts +++ /dev/null @@ -1,118 +0,0 @@ -// Production `WorkflowRuntimeEnv.EffectLedger` binding. -// -// Crash-safe exactly-once substrate for action effects. Distinct from the -// run event log: each `record` commits through `writeTreePreservingPrefix` -// under `runs//blobs/`, so a dropped run-log buffer never takes the -// ledger with it. The workflow-run kind handler already permits the blobs/ -// subtree and enforces append-only immutability for those paths — reusing -// that layout satisfies the EffectLedger durability contract with no -// kind-handler change. -// -// Layout: `runs//blobs/` holds JSON -// `{ "output": }`. The on-disk name is the SHA-256 of the identity -// effect key (not of the envelope bytes). That cohabits with content- -// addressed spill blobs under the same `blobs/` prefix: the kind handler -// only enforces 64-hex filenames + byte immutability, not -// `filename == sha256(bytes)`. Callers must not assume every entry under -// `blobs/` is pure content-addressed; GC or verify tooling that does will -// mis-handle ledger entries. A dedicated `effects/` subtree would need a -// kind-handler change. -// -// Outputs must JSON-serialize into a real `{output}` envelope. Values that -// `JSON.stringify` drops (`undefined`, functions, symbols) fail closed on -// `record` rather than writing a bare `{}` that later `lookup` would reject. - -import { type } from "arktype"; -import { hexEncode } from "@intx/types"; -import type { - Principal, - RepoId, - RepoStore as SubstrateRepoStore, -} from "@intx/hub-sessions/substrate"; -import type { EffectLedger } from "@intx/workflow"; - -import { - isErrnoNotFound, - readRunBlob, - writeRunBlob, - type RunBlobStoreOpts, -} from "./run-blobs"; - -// Ledger bytes come back off disk — a trust boundary — so the envelope is -// parsed, never asserted. -const EffectEnvelope = type({ output: "unknown" }); - -export type WorkflowRunEffectLedgerOpts = { - substrate: SubstrateRepoStore; - repoId: RepoId; - principal: Principal; - /** Run id whose effect ledger this adapter owns. Fresh adapter per run. */ - runId: string; - /** Workflow-run repo ref (typically `refs/heads/main`). */ - ref: string; -}; - -/** - * Construct a durable `EffectLedger` for one run. Lookup/record go through - * the workflow-run substrate under `runs//blobs/`. - */ -export function createWorkflowRunEffectLedger( - opts: WorkflowRunEffectLedgerOpts, -): EffectLedger { - const store: RunBlobStoreOpts = opts; - return { - async lookup(effectKey) { - const key = await identityKeyHex(effectKey); - try { - const bytes = await readRunBlob(store, key); - const envelope = EffectEnvelope( - JSON.parse(new TextDecoder().decode(bytes)), - ); - if (envelope instanceof type.errors) { - throw new Error( - `workflow-host-actions: effect ledger entry ${key} for run ${opts.runId} is not a {output} envelope`, - ); - } - return { output: envelope.output }; - } catch (cause) { - if (isErrnoNotFound(cause)) return undefined; - throw cause; - } - }, - async record(effectKey, output) { - const key = await identityKeyHex(effectKey); - // JSON.stringify omits keys whose value is undefined and returns - // undefined for bare undefined/function/symbol top-level values. Either - // form would leave a non-{output} envelope on disk; fail closed before - // the substrate write. - const encoded = JSON.stringify({ output }); - if ( - encoded === undefined || - EffectEnvelope(JSON.parse(encoded)) instanceof type.errors - ) { - throw new Error( - `workflow-host-actions: effect ledger cannot serialize output for key ${key} on run ${opts.runId} (typeof ${typeof output})`, - ); - } - const bytes = new TextEncoder().encode(encoded); - // Same-key re-record with identical bytes is accepted by the kind - // handler's append-only compare; a divergent re-record fails closed. - await writeRunBlob( - store, - key, - bytes, - `record effect ${key} for run ${opts.runId}`, - ); - }, - }; -} - -async function identityKeyHex(effectKey: string): Promise { - const digest = await crypto.subtle.digest( - "SHA-256", - // ArrayBuffer-backed at the call site; Web Crypto's BufferSource type - // rejects Uint8Array under TS 5.9, hence the assertion. - new TextEncoder().encode(effectKey) as Uint8Array, - ); - return hexEncode(new Uint8Array(digest)); -} diff --git a/packages/workflow-host-actions/src/index.ts b/packages/workflow-host-actions/src/index.ts deleted file mode 100644 index 5e5a65daa..000000000 --- a/packages/workflow-host-actions/src/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -// @corbits/workflow-host-actions — host bindings for the action/loop seam -// `@intx/workflow`'s `WorkflowRuntimeEnv` leaves to the host -// (`invokeAction`/`effects`/`loopFns`/`runLoopIteration`). Droppable on any -// Interchange instance: depends only on `@intx/workflow` for the runtime -// contracts and `@intx/hub-sessions`' substrate for durable storage. - -export { - createActionHandlerRegistry, - createLoopFnRegistry, - createWorkflowActionInvoker, - type ActionHandler, - type WorkflowActionInvokerOpts, -} from "./action-invoker"; -export { - createWorkflowRunEffectLedger, - type WorkflowRunEffectLedgerOpts, -} from "./effect-ledger"; -export { - blobsPrefixFor, - isErrnoNotFound, - readRunBlob, - writeRunBlob, - type RunBlobStoreOpts, -} from "./run-blobs"; diff --git a/packages/workflow-host-actions/src/run-blobs.ts b/packages/workflow-host-actions/src/run-blobs.ts deleted file mode 100644 index 00cae316c..000000000 --- a/packages/workflow-host-actions/src/run-blobs.ts +++ /dev/null @@ -1,82 +0,0 @@ -// Shared read/write under `runs//blobs/` for the effect ledger. -// Writes go through `writeTreePreservingPrefix` (kind-handler append-only + -// git commit); reads hit the working-tree materialization the substrate -// leaves at the same path — the established production pattern for the -// workflow-run adapters, not a durability gap. The substrate materializes -// the tree on every successful write, so a post-commit lookup always sees -// the bytes. -// -// Note: this prefix holds both content-addressed spill blobs (filename = -// sha256 of bytes, written by `@intx/workflow-host`'s blob substrate) and -// identity-keyed effect-ledger entries (filename = sha256 of the effect -// key). Do not treat the whole directory as pure content-addressed storage. - -import fs from "node:fs/promises"; -import path from "node:path"; - -import type { - Principal, - RepoId, - RepoStore as SubstrateRepoStore, -} from "@intx/hub-sessions/substrate"; - -const RUNS_PREFIX = "runs"; -const BLOBS_DIR = "blobs"; - -export type RunBlobStoreOpts = { - substrate: SubstrateRepoStore; - repoId: RepoId; - principal: Principal; - runId: string; - ref: string; -}; - -export function blobsPrefixFor(runId: string): string { - return `${RUNS_PREFIX}/${runId}/${BLOBS_DIR}/`; -} - -export async function writeRunBlob( - opts: RunBlobStoreOpts, - key: string, - bytes: Uint8Array, - message: string, -): Promise { - const prefix = blobsPrefixFor(opts.runId); - try { - await opts.substrate.writeTreePreservingPrefix( - opts.principal, - opts.repoId, - opts.ref, - { - preservePrefix: prefix, - merge: async (existing) => { - const files: Record = {}; - for (const [k, v] of existing) files[k] = v; - files[`${prefix}${key}`] = bytes; - return files; - }, - message, - }, - ); - } catch (cause) { - const msg = cause instanceof Error ? cause.message : String(cause); - if (msg.startsWith("path_violation: ")) { - throw new Error(msg.slice("path_violation: ".length), { cause }); - } - throw cause; - } -} - -export async function readRunBlob( - opts: Pick, - key: string, -): Promise { - const dir = opts.substrate.getRepoDir(opts.repoId); - const blobPath = path.join(dir, RUNS_PREFIX, opts.runId, BLOBS_DIR, key); - return await fs.readFile(blobPath); -} - -export function isErrnoNotFound(cause: unknown): boolean { - if (cause === null || typeof cause !== "object") return false; - return (cause as { code?: unknown }).code === "ENOENT"; -} diff --git a/packages/workflow-host-actions/tsconfig.json b/packages/workflow-host-actions/tsconfig.json deleted file mode 100644 index 12ec9a862..000000000 --- a/packages/workflow-host-actions/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "types": ["bun"] - }, - "include": ["src"] -} diff --git a/scripts/checks/kill-dates.txt b/scripts/checks/kill-dates.txt index e0a9baf55..195834f83 100644 --- a/scripts/checks/kill-dates.txt +++ b/scripts/checks/kill-dates.txt @@ -13,9 +13,11 @@ # node_modules excluded). check:killdates recomputes it and fails on # drift: editing a vendored tree means updating this hash and the # package's VENDORED-FROM delta line in the same change. -apps/sidecar | sawyer | 2026-09-19 +apps/sidecar | sawyer | 2026-10-26 vendor/intx/agent | sawyer | 2026-10-26 | d0d56d9f452b78f4b541ad8f4e89f975e8069446bb98f2c8097b90de4b020243 vendor/intx/db | sawyer | 2026-09-19 | 0a4cdb9a8a6ff19d5d4713cbc4f5cc9257aad839b1fa393b2026e6d5afd828b9 +vendor/intx/harness | sawyer | 2026-10-26 | 867f2b0eb4a360c68bf552d5b95a9530411e2c1a2d046d1f5ce718d4a9be36a8 +vendor/intx/hub-agent | sawyer | 2026-10-26 | 30d5050511f22bc73b3c5d34728dfdf5b791de203452b83d4333a1dc762afceb vendor/intx/hub-api | sawyer | 2026-09-19 | 42ee33e027559b236065382cb94f393bbcfee69625615894f82be778f34f7aa1 vendor/intx/hub-sessions | sawyer | 2026-09-19 | 53addc3090ad9f54bc4bac8fb50ad8d567ccf46f30bb5403d447351cb16b4fb6 vendor/intx/inference | sawyer | 2026-10-26 | 77fec29b078e8d03e686747c70e6b62ac1fd1434db0fb2c1e12e84b6dc71465f @@ -25,7 +27,7 @@ vendor/intx/mime | sawyer | 2026-10-26 | d02e5f8f1429eac7c27d3a37eec31111f8a1053 vendor/intx/types | sawyer | 2026-10-26 | ec1de14b859007b4db137da1533d4ce79d11024ad69c8b36938017319d6d8e86 vendor/intx/workflow | sawyer | 2026-09-19 | 4b51b9bd6a124cfaa0c916e2b26c04ac9170618bb092f0c8e1c312263fc84fdf vendor/intx/workflow-deploy | sawyer | 2026-10-26 | 960a2ae408223649fe8be0e3b9d63f2b0cca25259bc0ae06761bca521bb738e5 -vendor/intx/workflow-host | sawyer | 2026-09-19 | 001bea028bf1484f150fa00c9331b897a2db888f6aacd0a109f4e2739854cf74 +vendor/intx/workflow-host | sawyer | 2026-09-19 | aff0342a526387ea9ccb52827fd4f13d9dd52645b37795362ce9b2fd912a5da5 packages/folded-runs | sawyer | 2026-11-01 diff --git a/vendor/intx/harness/README.md b/vendor/intx/harness/README.md new file mode 100644 index 000000000..4db017a6f --- /dev/null +++ b/vendor/intx/harness/README.md @@ -0,0 +1,71 @@ +# @intx/harness + +Composition layer over `@intx/agent` that adds the mail-transport +surface: INBOX watch, connector router, connector-reply outbound +forwarding, and connector-state persistence layered on top of the +agent's context store. The reactor is wrapped exactly once -- inside +`@intx/agent`'s `createAgent` -- and the harness composes around that. + +Consumed by `apps/sidecar` for sidecar-hosted agents and by demo +examples that need a transport-bearing agent. + +```ts +import { + createDefaultDirectorRegistry, + defineAgent, + defineTool, +} from "@intx/agent"; +import { noopAuditStore, permissiveAuthorize } from "@intx/agent/testing"; +import { createHarness, defineMailTools } from "@intx/harness"; +import { createIsogitStore } from "@intx/storage-isogit/node"; + +const mailFactory = defineMailTools( + () => ({ + definitions: myMailTools.definitions, + run: (call, signal) => myMailTools.run(call, signal), + }), + myMailTools.definitions.map((def) => ({ name: def.name })), +); + +const posixFactory = defineTool({ + id: "@my-org/agent/posix", + definitions: myPosixTools.definitions.map((def) => ({ name: def.name })), + factory: () => ({ + definitions: myPosixTools.definitions, + run: (call, signal) => myPosixTools.run(call, signal), + }), +}); + +const def = defineAgent({ + id: "agent@tenant.interchange.network", + systemPrompt, + tools: [mailFactory, posixFactory], + capabilities: [], + inference: { sources: [{ provider: source.provider, model: source.model }] }, +}); + +const harness = await createHarness(def, { + source, + storage: await createIsogitStore(workdir), + workdir, + audit: noopAuditStore(), + authorize: permissiveAuthorize(), + directors: createDefaultDirectorRegistry(), + transport, + address: "agent@tenant.interchange.network", +}); + +// Subscribe to events via harness.stream(); compose with your own +// downstream observability sink. +for await (const event of harness.stream()) { + // ... +} + +await harness.close(); +``` + +The narrowed `Harness` surface is `close()`, `deliver(message)`, +`setSource(source)`, `stream()`, and `blobReader`. Tool composition +flows through `defineMailTools` and `defineTool` from `@intx/agent`; +the agent's own `resolveTools` aggregates definitions and dispatches +calls. diff --git a/vendor/intx/harness/VENDORED-FROM b/vendor/intx/harness/VENDORED-FROM new file mode 100644 index 000000000..19fe6e4cf --- /dev/null +++ b/vendor/intx/harness/VENDORED-FROM @@ -0,0 +1,4 @@ +Source: https://github.com/faremeter/interchange (packages/harness) +Commit: a8bc06ae38661c5e0ed91ded8559bf09f502213d (origin/main, 2026-08-27) +License: LGPL-2.1-only (see vendor/intx/LICENSE) +Local modifications: exports map repointed from the upstream intx-src condition to direct TypeScript source resolution (types/default -> ./src/...); dist references removed. No source delta. diff --git a/vendor/intx/harness/package.json b/vendor/intx/harness/package.json new file mode 100644 index 000000000..0c3bed1a9 --- /dev/null +++ b/vendor/intx/harness/package.json @@ -0,0 +1,37 @@ +{ + "name": "@intx/harness", + "description": "Mail-transport composition layer over @intx/agent adding INBOX watch and connector routing", + "version": "0.3.0", + "license": "LGPL-2.1-only", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@intx/agent": "workspace:*", + "@intx/authz": "0.3.0", + "@intx/log": "0.3.0", + "@intx/types": "workspace:*" + }, + "devDependencies": { + "@intx/mime": "workspace:*", + "@types/bun": "catalog:", + "arktype": "catalog:", + "typescript": "catalog:" + }, + "files": [ + "src", + "README.md", + "LICENSE" + ], + "sideEffects": false, + "publishConfig": { + "access": "public" + } +} diff --git a/vendor/intx/harness/src/connector-router.ts b/vendor/intx/harness/src/connector-router.ts new file mode 100644 index 000000000..b7f8009a2 --- /dev/null +++ b/vendor/intx/harness/src/connector-router.ts @@ -0,0 +1,304 @@ +// Connector-thread routing for the agent harness. +// +// The connector is one durable thread per agent. Participants accumulate +// as they speak; no one is displaced. `replyTo` tracks the most recent +// speaker (the primary recipient on the next outbound reply) and `cc` +// tracks every other participant who has spoken (carried on outbound so +// everyone stays in the loop). +// +// Two-phase decision: route() is pure and returns a discriminated kind +// plus an opaque carrier of the next state; commit() advances router +// state from that carrier. Separating the decision from the mutation +// lets the harness sequence the side effects (deliver, INBOX expunge) +// around the state change however it needs to. + +import { getLogger } from "@intx/log"; +import { extractAddrSpec } from "@intx/mime"; +import type { + ConnectorThreadState, + InboundMessage, + SendReceipt, +} from "@intx/types/runtime"; + +const logger = getLogger(["interchange", "harness", "connector-router"]); + +export type RouteDecision = + | { kind: "start" } + | { kind: "continue" } + | { kind: "passthrough" }; + +export type ConnectorReplyParts = { + to: string; + cc: string[]; + inReplyTo: string; + subject?: string; +}; + +export class NoActiveConnectorThreadError extends Error { + constructor() { + super("no active connector thread"); + this.name = "NoActiveConnectorThreadError"; + } +} + +export type ConnectorRouterOptions = { + /** + * Called synchronously after the router's internal state mutates and the + * new state is committed to internal storage. Fires only when the new + * state differs from the prior state — restore() into the same state, + * passthrough commits, and other no-ops do not fire. Single subscriber: + * the harness wiring that lifts state changes onto the hub-bound event + * channel. + * + * The router catches and logs any error this callback throws. The cache + * the callback feeds is a best-effort projection of router state, and + * the authoritative state remains in the router and the persisted + * context store. Dropping one notification means the projection stays + * stale until the next state change rebuilds it; that is the right + * trade-off versus aborting the call chain that invoked the + * commit/onReplySent that produced the notification. + */ + onStateChanged?(state: ConnectorThreadState | null): void; +}; + +export interface ConnectorRouter { + /** + * Classify an inbound message against the current connector state. Pure: + * does not mutate router state. The returned decision must be passed to + * `commit()` to take effect. + * + * Throws when `message.headers.from` is not a parseable bare addr-spec + * (per `extractAddrSpec` from `@intx/mime`). The production fetch path + * copies the wire `From:` header verbatim, so a malformed sender is a + * normal-shape runtime concern, not a programmer error. Callers should + * treat the throw as passthrough — deliver the message to the reactor + * but do not advance router state or consume the message from the + * INBOX. + */ + route(message: InboundMessage): RouteDecision; + + /** + * Advance router state per a decision produced by `route()`. No-op for + * `passthrough`. For `start` and `continue`, throws if the decision was + * not produced by this router instance. + */ + commit(decision: RouteDecision): void; + + /** + * Produce the threading headers needed to send a reply on the active + * connector thread. `to` is the most recent speaker; `cc` is everyone + * else who has spoken on the thread (deduplicated). The caller composes + * the full outbound message by adding its own `content` and `type` + * fields. Throws `NoActiveConnectorThreadError` when no thread is + * active. + */ + composeReply(): ConnectorReplyParts; + + /** + * Update `lastMessageId` after a successful outbound reply send. + * Throws when called with no active thread — outbound state advance + * has no meaning without a thread. + */ + onReplySent(receipt: SendReceipt): void; + + /** + * Return the current connector state as a serializable snapshot, or + * `null` when no thread is active. Matches the + * `ConnectorThreadState | null` shape used by the storage layer. + */ + snapshot(): ConnectorThreadState | null; + + /** + * Install a snapshot as the router's current state. Used at startup + * to restore from the persisted context store, and in tests to set + * up scenarios. Passing `null` clears the active thread. + */ + restore(state: ConnectorThreadState | null): void; +} + +function statesEqual( + a: ConnectorThreadState | null, + b: ConnectorThreadState | null, +): boolean { + if (a === null || b === null) return a === b; + return ( + a.threadRoot === b.threadRoot && + a.lastMessageId === b.lastMessageId && + a.replyTo === b.replyTo && + a.subject === b.subject && + a.cc.length === b.cc.length && + a.cc.every((v, i) => v === b.cc[i]) + ); +} + +export function createConnectorRouter( + options?: ConnectorRouterOptions, +): ConnectorRouter { + let state: ConnectorThreadState | null = null; + const onStateChanged = options?.onStateChanged; + + // Pending state per decision is held off the decision object via a + // WeakMap so callers see only `{ kind }` — no path to inspect or + // mutate the next state, even via type assertions. + const pendingStates = new WeakMap(); + + function applyState(next: ConnectorThreadState | null): void { + // The null → X transition is what drives bootstrap on restore() — a + // future refactor that collapses null into a sentinel "no mutation" + // case would silently break the hub-side cache's only fill path + // outside live state mutations. Keep the equality check as-is; the + // null state is a value, not a non-event. + if (statesEqual(state, next)) return; + state = next; + if (onStateChanged !== undefined) { + // The callback feeds a best-effort projection of router state. A + // throwing subscriber would otherwise propagate out of commit() or + // onReplySent() and abort the caller; catching here drops one + // notification (cache stays stale until the next change) instead + // of corrupting the call chain. The authoritative state is + // already committed to the router by this point. + try { + onStateChanged(snapshot()); + } catch (cause) { + logger.warn`onStateChanged subscriber threw: ${cause instanceof Error ? cause.message : String(cause)}`; + } + } + } + + function isContinuation(message: InboundMessage): boolean { + if (state === null) return false; + + const { inReplyTo, references } = message.headers; + + if (references !== undefined && references.includes(state.threadRoot)) { + return true; + } + + if (inReplyTo !== undefined && inReplyTo === state.lastMessageId) { + return true; + } + + return false; + } + + // Append `value` to `existing` only when it is not already present. + // The thread's participant list is small enough that linear-scan dedup + // is the right cost. + function appendUnique(existing: readonly string[], value: string): string[] { + if (existing.includes(value)) return [...existing]; + return [...existing, value]; + } + + function route(message: InboundMessage): RouteDecision { + if (state === null) { + const nextState: ConnectorThreadState = { + threadRoot: message.headers.messageId, + lastMessageId: message.headers.messageId, + replyTo: extractAddrSpec(message.headers.from), + cc: [], + ...(message.headers.subject !== undefined + ? { subject: message.headers.subject } + : {}), + }; + const decision: RouteDecision = { kind: "start" }; + pendingStates.set(decision, nextState); + return decision; + } + + if (isContinuation(message)) { + const nextSpeaker = extractAddrSpec(message.headers.from); + // The previous most-recent speaker moves into the cc list; the + // new speaker becomes replyTo. Dedup so a sender returning after + // others have spoken doesn't appear twice. + const carriedCc = appendUnique(state.cc, state.replyTo).filter( + (addr) => addr !== nextSpeaker, + ); + const nextState: ConnectorThreadState = { + threadRoot: state.threadRoot, + lastMessageId: message.headers.messageId, + replyTo: nextSpeaker, + cc: carriedCc, + ...(state.subject !== undefined ? { subject: state.subject } : {}), + }; + const decision: RouteDecision = { kind: "continue" }; + pendingStates.set(decision, nextState); + return decision; + } + + return { kind: "passthrough" }; + } + + function commit(decision: RouteDecision): void { + if (decision.kind === "passthrough") return; + + const nextState = pendingStates.get(decision); + if (nextState === undefined) { + throw new Error( + "commit() called with a decision from a different router instance", + ); + } + + pendingStates.delete(decision); + applyState(nextState); + } + + function composeReply(): ConnectorReplyParts { + if (state === null) { + throw new NoActiveConnectorThreadError(); + } + + return { + to: state.replyTo, + cc: [...state.cc], + inReplyTo: state.lastMessageId, + ...(state.subject !== undefined ? { subject: state.subject } : {}), + }; + } + + function onReplySent(receipt: SendReceipt): void { + if (state === null) { + throw new NoActiveConnectorThreadError(); + } + applyState({ + threadRoot: state.threadRoot, + lastMessageId: receipt.messageId, + replyTo: state.replyTo, + cc: [...state.cc], + ...(state.subject !== undefined ? { subject: state.subject } : {}), + }); + } + + function snapshot(): ConnectorThreadState | null { + if (state === null) return null; + return { + threadRoot: state.threadRoot, + lastMessageId: state.lastMessageId, + replyTo: state.replyTo, + cc: [...state.cc], + ...(state.subject !== undefined ? { subject: state.subject } : {}), + }; + } + + function restore(next: ConnectorThreadState | null): void { + applyState( + next === null + ? null + : { + threadRoot: next.threadRoot, + lastMessageId: next.lastMessageId, + replyTo: next.replyTo, + cc: [...next.cc], + ...(next.subject !== undefined ? { subject: next.subject } : {}), + }, + ); + } + + return { + route, + commit, + composeReply, + onReplySent, + snapshot, + restore, + }; +} diff --git a/vendor/intx/harness/src/credential-capability.ts b/vendor/intx/harness/src/credential-capability.ts new file mode 100644 index 000000000..4705a16b5 --- /dev/null +++ b/vendor/intx/harness/src/credential-capability.ts @@ -0,0 +1,178 @@ +// The consumer-gated `credentials` capability: the sub-registry a tool queries +// by its declared handle to obtain a mediated credential. It is the runtime +// gate that enforces the `{ tool }` condition on a materialized +// `credential:{id}` / `use` grant -- the check the launch-time grant +// materialization sets up but does not itself evaluate. +// +// The gate lives here, at the point of use, and fails closed: a handle resolves +// only when the calling consumer holds `credential:{id}` / `use` with the +// grant's `{ tool }` condition matching this consumer. The shaping of the +// handle is delegated to the provider registry; the material is read fresh per +// use (rotation indirection) from the source the binding carries. + +import { + authorizeAction, + CREDENTIAL_USE_CONDITIONS, + type GrantRule, +} from "@intx/authz"; +import type { + CredentialCapability, + CredentialMaterialSource, + MediatedCredential, +} from "@intx/types"; +import type { ToolCredentialDeclaration } from "@intx/types/package-json"; + +import type { CredentialProviderRegistry } from "./credential-providers"; + +/** + * A binding resolved at launch: which credential backs a declared handle, which + * provider shapes it, the origin it authenticates to, and how to read its + * current material. The material source is an indirection over a mutable cell so + * a rotation reaches an already-shaped handle without a rebuild. + */ +export interface ResolvedCredentialBinding { + /** The credential row id the handle resolved to; the `credential:{id}` the + * use-grant check runs against. */ + credentialId: string; + /** The provider plugin key that shapes this credential's handle. */ + providerKey: string; + /** The provider origin the shaped handle authenticates to. */ + origin: string; + /** Reads the current secret material (rotation indirection). */ + readCurrentMaterial: CredentialMaterialSource; +} + +/** + * Reconcile a tool package's declared credential handles (its C5 `interchange. + * credentials`) against the handles a binding actually resolved for it. A + * declared handle with no binding is a launch-blocking misconfiguration -- the + * tool needs a credential the definition never bound -- so this fails the launch + * loudly rather than letting the gap surface as a resolve-time throw at the + * tool's first use. It is the throw-on-missing of `resolve`, pulled earlier to + * launch where the whole set is known. + */ +export function reconcileDeclaredCredentials( + consumer: string, + declared: readonly ToolCredentialDeclaration[], + boundHandles: ReadonlySet, +): void { + const missing = declared + .map((declaration) => declaration.handle) + .filter((handle) => !boundHandles.has(handle)); + if (missing.length > 0) { + throw new Error( + `consumer ${consumer} declares credential handle(s) that no binding resolves: ${missing.join(", ")}`, + ); + } +} + +export interface CredentialCapabilityDeps { + /** + * The consumer identity of the tool package this capability serves + * (`tool:`, from `toolConsumer`). Gate 2 checks each grant's + * `{ tool }` condition against this value; an empty identity fails closed. + */ + consumer: string; + /** Resolved bindings keyed by the handle the tool declared. */ + bindings: ReadonlyMap; + /** The registry that shapes a credential into a mediated handle. */ + providers: CredentialProviderRegistry; + /** The grants in effect for this deploy (the consumer's run grants). */ + grants: GrantRule[]; +} + +/** + * A `CredentialCapability` plus a host-only `dispose`. The tool sees only + * `resolve`; the host runs `dispose` on teardown to release every handle shaped + * through this capability (an http handle holds nothing; a future key-file / + * socket handle would). + */ +export interface HostCredentialCapability extends CredentialCapability { + dispose(): Promise; +} + +/** + * Build the consumer-gated `credentials` capability for one tool package. + * + * `resolve(handle)` fails closed at every step: an unbound handle throws; a + * handle the consumer is not authorized to use throws (Gate 2 -- the same + * `authorizeAction` the model-source path uses, here supplied the credential-use + * condition registry and this consumer). Only an authorized handle is shaped, + * once, and memoized so repeated resolves return the same instance and there is + * a single thing to dispose. + */ +export function createCredentialCapability( + deps: CredentialCapabilityDeps, +): HostCredentialCapability { + // Memoize the in-flight PROMISE, not the resolved handle, so two concurrent + // resolves of the same handle share one gate+shape and yield one instance + // (caching the value would let both miss the memo and shape twice, orphaning + // a handle). A deterministic failure -- unbound handle, denied gate, unknown + // provider -- caches too; it stays failed for this deploy, which is correct + // since grants do not change mid-deploy. + const shaped = new Map>(); + + function shapeHandle(handle: string): Promise { + return (async () => { + const binding = deps.bindings.get(handle); + if (binding === undefined) { + throw new Error( + `no credential is bound to handle "${handle}" for consumer ${deps.consumer}`, + ); + } + + // Gate 2: fail closed unless the consumer holds credential:{id} / use with + // the grant's { tool } condition matching this consumer. + const decision = await authorizeAction( + deps.grants, + `credential:${binding.credentialId}`, + "use", + { registry: CREDENTIAL_USE_CONDITIONS, consumer: deps.consumer }, + ); + if (!decision.ok) { + throw new Error( + `consumer ${deps.consumer} is not authorized to use credential ${binding.credentialId} (${decision.reason})`, + ); + } + + const provider = deps.providers.resolve(binding.providerKey); + return provider.shape({ + origin: binding.origin, + readCurrentMaterial: binding.readCurrentMaterial, + }); + })(); + } + + return { + resolve(handle: string): Promise { + const existing = shaped.get(handle); + if (existing !== undefined) return existing; + const pending = shapeHandle(handle); + shaped.set(handle, pending); + return pending; + }, + + async dispose(): Promise { + // Dispose EVERY successfully-shaped handle even if one throws -- a single + // bad handle must not strand the rest -- then surface any failures loudly + // rather than swallowing them. + const settled = await Promise.allSettled([...shaped.values()]); + shaped.clear(); + const errors: unknown[] = []; + for (const result of settled) { + if (result.status !== "fulfilled") continue; + try { + await result.value.dispose(); + } catch (error) { + errors.push(error); + } + } + if (errors.length > 0) { + throw new AggregateError( + errors, + "one or more credential handles failed to dispose", + ); + } + }, + }; +} diff --git a/vendor/intx/harness/src/credential-providers.ts b/vendor/intx/harness/src/credential-providers.ts new file mode 100644 index 000000000..f8ece1cea --- /dev/null +++ b/vendor/intx/harness/src/credential-providers.ts @@ -0,0 +1,179 @@ +// Credential provider plugins: the seam that shapes a resolved provider-backed +// credential into a mediated handle a consumer can use. A provider owns HOW the +// handle authenticates (an authed `fetch`, a future key-file + socket); it is +// given a material source and never acquires material or decides authorization +// -- both happen upstream, at the delivery boundary, before a provider is +// consulted. +// +// The registry mirrors @intx/inference's AdapterRegistry: a Map-backed lookup +// keyed by provider identifier, prototype-pollution-safe (a Map never consults +// Object.prototype, so an untrusted key like "toString" resolves to the loud +// unknown-provider error rather than an inherited member), throw-on-missing. + +import type { + CredentialProvider, + CredentialShapeContext, + HttpMediatedCredential, +} from "@intx/types"; + +/** Resolves a provider identifier to the plugin that shapes its handles. */ +export interface CredentialProviderRegistry { + has(key: string): boolean; + resolve(key: string): CredentialProvider; +} + +/** + * Build a registry from a list of providers. The list is copied into a private + * `Map`, so callers cannot mutate the set after construction and lookups never + * reach `Object.prototype`. A duplicate key is a wiring error and throws at + * construction rather than silently shadowing. + */ +export function createCredentialProviderRegistry( + providers: readonly CredentialProvider[], +): CredentialProviderRegistry { + const byKey = new Map(); + for (const provider of providers) { + if (byKey.has(provider.key)) { + throw new Error(`Duplicate credential provider key: ${provider.key}`); + } + byKey.set(provider.key, provider); + } + + return { + has(key: string): boolean { + return byKey.has(key); + }, + resolve(key: string): CredentialProvider { + const provider = byKey.get(key); + if (provider === undefined) { + throw new Error(`Unknown credential provider: ${key}`); + } + return provider; + }, + }; +} + +/** + * The minimal call signature the shaped handle needs from `fetch`. The global + * `fetch` satisfies it; a test stub can too, without implementing the extra + * members (`preconnect`) the full `fetch` type carries. + */ +export type FetchLike = ( + input: string | URL | Request, + init?: RequestInit, +) => Promise; + +/** Options for the built-in HTTP provider. */ +export interface HttpCredentialProviderOptions { + /** + * The `fetch` the shaped handle delegates to once the request is + * origin-checked and the auth header is injected. Defaults to the global + * `fetch`; injectable so origin-pinning can be exercised without a network. + */ + fetch?: FetchLike; +} + +/** + * The built-in HTTP credential provider. It shapes an `HttpMediatedCredential`: + * an authed `fetch` pinned to the credential's provider origin, injecting the + * current secret as a bearer token per request. The material is read fresh on + * every call, so a rotation that updates the underlying cell is picked up + * without rebuilding the handle. + * + * Origin pinning is load-bearing security: the handle authenticates only the + * initial, origin-checked request and never follows redirects. A request whose + * resolved origin is not the pinned one is refused, and a server 3xx is + * returned to the caller unfollowed (`redirect: "manual"`), so the bearer is + * never sent to any origin but the pinned one. Transparent redirect-following + * is intentionally not provided: a tool re-issues a same-origin redirect target + * through the handle (a cross-origin one is refused). This keeps token safety + * in the handle rather than resting on the injected `fetch`'s redirect + * behavior. + * + * Bearer is the only auth scheme today; providers that authenticate differently + * (a `token` scheme, an `x-api-key` header) are separate plugins, not a branch + * here. + */ +export function createHttpCredentialProvider( + opts?: HttpCredentialProviderOptions, +): CredentialProvider { + const fetchImpl: FetchLike = opts?.fetch ?? globalThis.fetch; + + return { + key: "http", + shape(context: CredentialShapeContext): HttpMediatedCredential { + const pinnedOrigin = new URL(context.origin).origin; + + return { + kind: "http", + async fetch( + input: string | URL | Request, + init?: RequestInit, + ): Promise { + const target = resolveTargetUrl(input, pinnedOrigin); + if (target.origin !== pinnedOrigin) { + throw new Error( + `http credential is pinned to ${pinnedOrigin}; refusing cross-origin request to ${target.origin}`, + ); + } + + // Read the secret fresh on every call so a rotation of the underlying + // material cell reaches this handle without a rebuild. + const { secret } = context.readCurrentMaterial(); + + // redirect:"manual" is dictated by the handle, never inherited from + // caller input. The origin check guards only the INITIAL url, so + // following a server 3xx to a foreign origin would carry the bearer + // off the pinned host. Instead the 3xx is returned to the caller + // unfollowed: a same-origin target is re-issued through the handle + // (which re-pins and re-auths); a cross-origin one is refused above. + if (input instanceof Request) { + // Re-issue the caller's request (method, body preserved) with the + // auth header added and the redirect mode forced; its url was + // origin-checked above. + const headers = new Headers(input.headers); + headers.set("authorization", `Bearer ${secret}`); + return fetchImpl( + new Request(input, { headers, redirect: "manual" }), + ); + } + + const headers = new Headers(init?.headers); + headers.set("authorization", `Bearer ${secret}`); + return fetchImpl(target, { ...init, headers, redirect: "manual" }); + }, + dispose(): void { + // An http handle allocates no resources; nothing to release. + }, + }; + }, + }; +} + +/** + * The built-in credential providers every host registers. A single `http` + * provider today; a host composes additional providers by extending the list + * passed to `createCredentialProviderRegistry`. + */ +export function builtinCredentialProviders(): CredentialProvider[] { + return [createHttpCredentialProvider()]; +} + +/** + * Resolve the URL a request targets. A relative string resolves against the + * pinned origin (so a tool can call `/repos`); an absolute string or URL keeps + * its own origin (and is refused by the caller if it differs); a `Request` + * carries an absolute URL already. + */ +function resolveTargetUrl( + input: string | URL | Request, + pinnedOrigin: string, +): URL { + if (typeof input === "string") { + return new URL(input, pinnedOrigin); + } + if (input instanceof URL) { + return input; + } + return new URL(input.url); +} diff --git a/vendor/intx/harness/src/harness.ts b/vendor/intx/harness/src/harness.ts new file mode 100644 index 000000000..773dfa3cf --- /dev/null +++ b/vendor/intx/harness/src/harness.ts @@ -0,0 +1,462 @@ +// @intx/harness composition layer. +// +// The harness imports `@intx/agent` and composes a mail-transport +// surface on top of `createAgent(def, env)`. The reactor is wrapped +// exactly once -- inside the agent harness in `@intx/agent`. This +// module owns transport subscription, the connector router and its +// state persistence, the INBOX watch loop, and the outbound side of +// `connector.reply` events. +// +// What this module does *not* own: reactor wrapping, audit accumulation +// or flushing, source-registry hot-swap. Those live in `@intx/agent` +// and are reached via `agent.deliver`, `agent.setSource`, and +// `agent.stream()` respectively. + +import { + createAgent, + defineTool, + type Agent, + type AgentDefinition, + type AnnotatedToolFactory, + type BaseEnv, + type ToolBundle, + type ToolDeclaration, +} from "@intx/agent"; +import { getLogger } from "@intx/log"; +import type { + BlobReader, + ConnectorThreadState, + ContextStore, + InboundMessage, + InferenceSource, + MessageTransport, + Unsubscribe, +} from "@intx/types/runtime"; + +import { createConnectorRouter, type RouteDecision } from "./connector-router"; +import { driveConnectorReplies } from "./reply-drain"; + +const logger = getLogger(["interchange", "harness"]); + +/** + * Env extension the composition layer requires beyond `BaseEnv`. Tools + * shipped by this package declare the matching `requires` so + * `validateEnv` can blame either at the env entry point. + * + * `onReplySendFailed` is invoked when the reply drain catches a failure + * from `connectorRouter.composeReply` or `transport.send` for an + * outbound `connector.reply`. The reply is dropped and the router + * state is not advanced; the callback is the only programmatic surface + * a caller has to observe the loss. Production deployments that need + * retry semantics layer them on top of this callback. + * + * The callback may be synchronous or async; the reply drain awaits its + * resolution so an async callback's rejection is observed (and logged) + * rather than surfacing as an unhandled promise rejection. + * + * `onReplyDrainTerminated` is invoked when the reply drain's `for await` + * loop exits abnormally -- the only documented case is a + * `StreamBackpressureError` thrown by the agent's event stream when the + * drain's per-consumer buffer overruns `streamBufferMax`. After this + * fires the harness is no longer forwarding `connector.reply` events to + * the transport: in-process `agent.send()` callers still resolve, but + * outbound replies are silently dropped until `close()`. Production + * deployments that need to alert on this failure mode subscribe via + * this callback; the harness only emits a `logger.warn` otherwise. The + * callback may be synchronous or async and is awaited the same way + * `onReplySendFailed` is, so an async rejection is observed (and + * logged) rather than escaping as an unhandled rejection. + */ +export interface MailEnv extends BaseEnv { + transport: MessageTransport; + address: string; + onConnectorStateChanged?: (state: ConnectorThreadState | null) => void; + onReplySendFailed?: (cause: unknown) => void | Promise; + onReplyDrainTerminated?: (cause: unknown) => void | Promise; +} + +/** + * Narrowed public surface returned by `createHarness`. `close` is the + * only direct surface; everything else is a pass-through to the + * underlying agent. `stream` is exposed so observability consumers can + * subscribe to the reactor's event stream without having to grab the + * agent reference. + */ +export interface Harness { + close(): Promise; + deliver(message: InboundMessage): void; + setSource(source: InferenceSource): void; + setSources(sources: InferenceSource[], defaultSource: string): void; + stream: Agent["stream"]; + readonly blobReader: BlobReader; +} + +/** + * Mail-tool factory shape. The `createMailTools` constructor in + * `@intx/tools-mail` builds a runner from a transport-bearing + * capability set; the harness wraps that into a single `defineTool` + * bundle whose `requires` names the env keys the wrapper touches. + * + * Callers (e.g. the sidecar) supply the wrapper as a tool factory on + * their `AgentDefinition`. `createHarness` does not synthesize it + * internally -- the caller is the layer that knows which mail-tool + * implementation to use. + */ +export type MailToolWrapper = ( + transport: MessageTransport, +) => Omit; + +/** + * Build the `load` / `writeMetadata` overrides the harness layers onto + * `env.storage`. Extracted from `createHarness` so the dirty-bit gating + * on `load()` is directly testable -- the production path constructs + * the overrides inline with the same arguments. + * + * The `isInMemoryStateAuthoritative` callback is read on every `load` + * invocation. The harness sets the bit from the router's + * `onStateChanged` callback so the gate flips on the same tick a + * commit produces its first state change; subsequent loads (whether + * driven by reactor recovery, mid-cycle, or anywhere else) leave the + * router's in-memory snapshot intact rather than blanking it with the + * pre-commit disk value. + * + * Exported for the regression test in this package; no external + * consumer should call it. The helper is tightly coupled to the + * dirty-bit gating semantics that live in this module, and a separate + * testing entry-point would buy bundler ceremony for a boundary + * TypeScript cannot enforce. The docstring "internal" marker is the + * contract. + */ +export function createWrappedStorageOverrides( + baseStorage: ContextStore, + connectorRouter: ReturnType, + isInMemoryStateAuthoritative: () => boolean, +): Pick { + return { + async load(signal) { + const loaded = await baseStorage.load(signal); + if (!isInMemoryStateAuthoritative()) { + connectorRouter.restore(loaded.connectorState); + } + return loaded; + }, + async writeMetadata(metadata, signal) { + baseStorage.setConnectorState(connectorRouter.snapshot()); + return baseStorage.writeMetadata(metadata, signal); + }, + }; +} + +/** + * Construct an `AnnotatedToolFactory` for a mail-tool bundle. The + * factory binds `transport` from env at construction time and produces + * a bundle whose lifetime is tied to the agent. Disposal of the + * underlying mail tools is the caller's responsibility (the env is the + * agent's dependency contract; the caller owns what it puts in env); + * the agent itself does not call bundle disposers (see the + * `ToolBundle` contract in `@intx/agent`). Callers that need to + * dispose mail tools on shutdown retain a reference to the underlying + * `MailToolWrapper`'s output and invoke its `dispose` directly -- + * routing disposal through the bundle the agent receives would still + * not fire since the agent never holds it. + * + * The `requires: ["transport", "address"]` declaration captures the + * env-key surface of the entire mail composition path -- the factory + * body reads `transport`, and `createHarness` (which the caller pairs + * this factory with) reads `env.address` to label rejected-message + * log records identifying which agent's router refused the message. + * No routing decision keys off `env.address` -- the connector router + * routes on per-message thread state, not on the agent's own + * address -- so the field is observability-only. It still belongs in + * `requires` because the harness's log record assumes the field is + * populated; declaring it here lets the agent's `validateEnv` blame a + * missing `address` at construction time rather than letting the + * watch loop discover it under operational load. Callers that hand- + * build a `defineTool` factory for a different mail-tool runner must + * remember to surface `address` on their own `requires` if their + * `createHarness` consumes it -- the agent has no way to deduce + * composition-layer env requirements from a factory body that does + * not itself read the field. + * + * The `requires` set is fixed at the two keys above by design; this + * helper is not the extension point for mail-tool runners that need + * additional env keys. A mail tool that wants to read (say) a tenant + * identifier from env should drop down to `defineTool` directly, + * declare its own `requires` with the full surface, and call the + * underlying mail-tool constructor inside that factory. Folding an + * additional `requires` parameter into `defineMailTools` would push + * the "what does the harness need vs. what does the tool runner + * need" partition onto the caller, which is exactly the partition + * this helper exists to hide. + * + * `definitions` is the static declaration `defineTool` requires: the + * tool names this factory contributes, enumerable without invoking the + * wrapper. The caller supplies it because the wrapper binds `transport` + * from env and cannot run at declaration time; the caller already holds + * the mail-tool runner whose `definitions` name the same tools. + */ +export function defineMailTools( + wrapper: MailToolWrapper, + definitions: readonly ToolDeclaration[], +): AnnotatedToolFactory { + return defineTool({ + id: "@intx/harness/mail", + requires: ["transport", "address"], + definitions, + factory: (env) => { + const bundle = wrapper(env.transport); + return { + definitions: bundle.definitions, + run: (call, signal) => bundle.run(call, signal), + }; + }, + }); +} + +/** + * Construct a composition-layer agent: the underlying agent wrapped + * with connector-state-aware storage, transport subscription, INBOX + * watch, and connector-reply forwarding. + * + * The reactor is wrapped exactly once -- inside `createAgent`. + * `createHarness` augments env.storage with connector-state load/save + * and subscribes to the agent's event stream to intercept + * `connector.reply` events for outbound transport sends. + */ +export async function createHarness( + def: AgentDefinition, + env: EnvReq, +): Promise { + const transport = env.transport; + + // The wrappedStorage's load() needs to know whether the router's + // in-memory state is "fresher" than disk. The dirty bit flips on the + // first state change emitted by the router (commit() in the watch + // loop, onReplySent() after a connector.reply) and never flips back. + // Once dirty, the wrappedStorage refuses to restore from disk -- the + // router's in-memory state is authoritative. + // + // The wrappedStorage subscribes to the router's onStateChanged so the + // dirty bit is set the same tick commit() runs, even if a + // contextStore.load() races behind it. + let inMemoryStateAuthoritative = false; + const userOnStateChanged = env.onConnectorStateChanged; + const connectorRouter = createConnectorRouter({ + onStateChanged: (state) => { + inMemoryStateAuthoritative = true; + if (userOnStateChanged !== undefined) userOnStateChanged(state); + }, + }); + + // Wrap env.storage. The first load() restores connector state from + // disk only if no router commit has happened yet -- once a commit + // makes the router's state authoritative, subsequent loads return + // the store's payload unchanged and leave the in-memory state + // intact. + // + // The router's in-memory state diverges from disk between commit() + // (in the watch callback) and the next writeMetadata (at the + // reactor's per-cycle checkpoint). A load() landing in that window + // must not clobber the in-memory state with the stale disk value -- + // doing so makes the harness's outbound connector.reply path drop + // replies with NoActiveConnectorThreadError when composeReply() runs + // after a mid-cycle reload. + // + // The wrapper is implemented as a Proxy over env.storage so adding a + // new method to ContextStore does not require touching the harness: + // any method not named in `overrides` forwards to env.storage with + // its `this` bound to env.storage. The two overrides intercept + // load (cold-boot restore) and writeMetadata (flush router snapshot + // before delegate). `setConnectorState` is left to the default + // Proxy fall-through path since the harness adds no behaviour beyond + // delegation there. + const overrides = createWrappedStorageOverrides( + env.storage, + connectorRouter, + () => inMemoryStateAuthoritative, + ); + + const wrappedStorage: ContextStore = new Proxy(env.storage, { + get(target, prop, _receiver) { + if (prop === "load") return overrides.load; + if (prop === "writeMetadata") return overrides.writeMetadata; + const value = Reflect.get(target, prop, target); + // Bind methods to the underlying store so isogit-style + // closure-captured state and prototype-bound this both resolve + // against the real store, not the proxy. + return typeof value === "function" ? value.bind(target) : value; + }, + }); + + const agentEnv = { ...env, storage: wrappedStorage }; + + const agent = await createAgent(def, agentEnv); + + // From here through the final `return`, the agent is constructed + // and the workdir lock is held. Anything that throws -- the + // `driveConnectorReplies` setup, `transport.watch()`, + // anything in the watch callback's synchronous registration -- has + // to release the lock by closing the agent before re-raising; the + // caller never sees the agent and cannot do it themselves. + // `createAgent` covers its own internal failure paths via its + // `succeeded`/`finally` shape; this is the matching coverage for + // the harness's own construction tail. + let harnessSucceeded = false; + try { + // Background drain of the agent's event stream. Intercepts + // `connector.reply` to send the reply via transport; everything + // else flows past unobserved. Other consumers can subscribe to the + // exposed `stream()` method to see the same events. + // + // The shared `driveConnectorReplies` helper owns the loop: reply + // serialization (a second reply waits for the first's receipt to + // advance the router before composing its own), per-reply failure + // surfacing to `onReplySendFailed`, and abnormal-termination + // surfacing to `onReplyDrainTerminated`. The warm workflow-host + // path drives replies through the same helper. + const replyDrain = driveConnectorReplies({ + stream: agent.stream(), + composeReply: () => connectorRouter.composeReply(), + send: (message) => transport.send(message), + onReplySent: (receipt) => connectorRouter.onReplySent(receipt), + ...(env.onReplySendFailed !== undefined + ? { onSendFailed: env.onReplySendFailed } + : {}), + ...(env.onReplyDrainTerminated !== undefined + ? { onTerminated: env.onReplyDrainTerminated } + : {}), + }); + + // Delete a message from the INBOX after it has been delivered to the + // reactor. + // + // A failure here is logged and swallowed: the router state has + // already been committed and `agent.deliver` has accepted the + // message, so re-raising would unwind a half-applied delivery. The + // message stays in the INBOX and a future startup (or watch firing) + // re-fetches it, re-routes it, and re-delivers it. The router's + // persisted state makes that benign on the routing side: the sender + // is already a thread participant, so `route()` returns either a + // `continue` (which is a no-op state mutation since the sender is + // unchanged) or a `passthrough` (no headers match). The agent's + // director sees a duplicate `message.received`; idempotent + // directors are unaffected, and the audit trail records the + // duplicate for post-hoc reconciliation. + async function consumeFromInbox(message: InboundMessage): Promise { + try { + await transport.setFlags(message.ref, ["\\Deleted"]); + await transport.expunge("INBOX"); + } catch (cause) { + logger.warn`Failed to consume message uid=${message.ref.uid} from INBOX: ${cause}`; + } + } + + // INBOX watch loop. Subscribe before the agent's reactor is fully + // settled so no message is missed in the window between subscription + // and the first watch callback. + let stopped = false; + const unsubscribe: Unsubscribe = transport.watch("INBOX", (event) => { + if (stopped) return; + if (event.type !== "exists") return; + + const ref = { uid: event.uid, mailbox: "INBOX" }; + + void (async () => { + try { + let message: InboundMessage; + try { + message = await transport.fetchFull(ref); + } catch (cause) { + logger.error`Failed to fetch message uid=${event.uid}: ${cause}`; + return; + } + + if (stopped) return; + + let decision: RouteDecision; + try { + decision = connectorRouter.route(message); + } catch (cause) { + // A router-rejected message (malformed headers, parse error + // inside the router, etc.) is still surfaced to the agent + // as an inbound `message.received`. The agent's director + // decides what the message means and how to respond; + // dropping it on the floor here would hide messages the + // operator may want to see. The router's state is *not* + // committed for the rejected message, so subsequent replies + // compose against the pre-rejection thread state. + logger.warn`Connector router rejected message uid=${message.ref.uid} for agent ${env.address}: ${cause instanceof Error ? cause.message : String(cause)}`; + if (stopped) return; + agent.deliver(message); + return; + } + + if (decision.kind === "passthrough") { + if (stopped) return; + agent.deliver(message); + return; + } + + // start or continue: commit router state synchronously before + // any await so a concurrent watch callback observes the + // updated state. + connectorRouter.commit(decision); + if (stopped) return; + agent.deliver(message); + await consumeFromInbox(message); + } catch (cause) { + // `agent.deliver` throws `AgentClosedError` synchronously when + // called after the agent has closed. The `if (stopped) return` + // guards above narrow the race window but cannot close it: a + // `close()` call landing between the guard and the synchronous + // throw still surfaces the rejection here. The fetched message + // is dropped; close() is in progress and the harness is + // tearing down, so the loss is expected. Without this catch + // the rejection would escape the void-IIFE as an unhandled + // promise rejection on the event loop. + if (cause instanceof Error && cause.name === "AgentClosedError") { + logger.warn`INBOX watch dropped uid=${event.uid} because the agent closed mid-delivery`; + return; + } + logger.error`INBOX watch failed for uid=${event.uid}: ${cause}`; + } + })(); + }); + + async function close(): Promise { + if (stopped) return; + stopped = true; + unsubscribe(); + replyDrain.stop(); + await agent.close(); + // The reply-drain loop exits once the underlying stream closes + // (close() above terminates streamConsumers). Awaiting here makes + // close idempotent and lets callers rely on a settled state. + await replyDrain.done; + } + + const harness: Harness = { + close, + deliver: (message) => agent.deliver(message), + setSource: (source) => agent.setSource(source), + setSources: (sources, defaultSource) => + agent.setSources(sources, defaultSource), + stream: () => agent.stream(), + blobReader: agent.blobReader, + }; + harnessSucceeded = true; + return harness; + } finally { + if (!harnessSucceeded) { + // Close the agent without waiting on its shutdown timeout so a + // synchronous post-`createAgent` throw does not stall the + // caller's failure path. The `.catch` swallows any rejection + // from the close: the caller is already receiving the original + // throw, and a noisier-than-original close failure here would + // mask it. + void agent.close().catch(() => { + // Swallow per the comment above. + }); + } + } +} diff --git a/vendor/intx/harness/src/index.ts b/vendor/intx/harness/src/index.ts new file mode 100644 index 000000000..6ea215ab4 --- /dev/null +++ b/vendor/intx/harness/src/index.ts @@ -0,0 +1,50 @@ +export { + createHarness, + defineMailTools, + type Harness, + type MailEnv, + type MailToolWrapper, +} from "./harness"; + +export { createHarnessRuntimeCapabilities } from "./runtime-capabilities"; +export type { HarnessRuntimeCapabilitiesOptions } from "./runtime-capabilities"; + +export { + createCredentialProviderRegistry, + createHttpCredentialProvider, + builtinCredentialProviders, +} from "./credential-providers"; +export type { + CredentialProviderRegistry, + FetchLike, + HttpCredentialProviderOptions, +} from "./credential-providers"; + +export { + createCredentialCapability, + reconcileDeclaredCredentials, +} from "./credential-capability"; +export type { + CredentialCapabilityDeps, + HostCredentialCapability, + ResolvedCredentialBinding, +} from "./credential-capability"; + +export { + createConnectorRouter, + NoActiveConnectorThreadError, +} from "./connector-router"; +export type { + ConnectorRouter, + ConnectorReplyParts, + ConnectorRouterOptions, + RouteDecision, +} from "./connector-router"; + +export { driveConnectorReplies } from "./reply-drain"; +export type { + AgentEventStream, + ConnectorReplyDrain, + ConnectorReplyDrainOpts, + ReplySettlement, +} from "./reply-drain"; diff --git a/vendor/intx/harness/src/reply-drain.ts b/vendor/intx/harness/src/reply-drain.ts new file mode 100644 index 000000000..8c604a064 --- /dev/null +++ b/vendor/intx/harness/src/reply-drain.ts @@ -0,0 +1,315 @@ +// Shared connector reply drain for the agent harness. +// +// A director emits a `connector.reply` event when the agent produces an +// outbound reply on its connector thread. Draining that event means: +// compose the threading headers for the active thread, send the reply +// through the transport, then advance the thread's `lastMessageId` from +// the send receipt. This module owns that loop so both the harness +// composition layer (`createHarness`) and the warm workflow-host agent +// path drive replies through one implementation rather than each keeping +// its own copy. +// +// The loop subscribes an agent event stream and serializes every reply +// through a single chain: two replies fired in quick succession do not +// interleave their compose / send / onReplySent sequence -- the second +// waits for the first's receipt to advance the thread before composing +// against it. A per-reply failure (compose, send, or onReplySent) is +// surfaced to `onSendFailed` and the reply is dropped with the thread left +// at its pre-send state; an abnormal stream termination (e.g. an agent +// stream backpressure violation) is surfaced to `onTerminated`. Neither +// escapes the returned `done` promise -- it always resolves -- so a caller +// can await teardown without guarding a rejection. + +import type { Agent } from "@intx/agent"; +import { getLogger } from "@intx/log"; +import type { OutboundMessage, SendReceipt } from "@intx/types/runtime"; + +import type { ConnectorReplyParts } from "./connector-router"; + +const logger = getLogger(["interchange", "harness", "reply-drain"]); + +/** + * The agent event stream the drain consumes -- exactly `agent.stream()`'s + * type. The stream yields the reactor's full emitted-event union (wider than + * `InferenceEvent`: it also carries `message.received`), so the drain accepts + * that union and lets every non-`connector.reply` event flow past untouched. + */ +export type AgentEventStream = ReturnType; + +export interface ConnectorReplyDrainOpts { + /** The agent event stream to drain. Each `connector.reply` sends a reply. */ + stream: AgentEventStream; + /** + * Produce the threading headers (`to`, `cc`, `inReplyTo`, `subject`) for + * the active connector thread. Throws when no thread is active; the throw + * is caught per reply and routed to `onSendFailed`. + */ + composeReply: () => ConnectorReplyParts; + /** + * Send the composed reply. The drain builds the `OutboundMessage` from + * `composeReply()`'s parts plus the reply content and a + * `conversation.message` type; the caller's `send` routes it to the + * transport / outbound bridge. + */ + send: (message: OutboundMessage) => Promise; + /** + * Resolve the full RFC 5322 References chain for a reply whose parent is + * `inReplyTo` (the Message-Id of the message being answered). Returns the + * parent's own References plus the parent's Message-Id, in order, so the + * outbound reply carries the complete conversational ancestry rather than + * a truncated single element. Returns `undefined` when the parent cannot be + * located (the very first reply on a fresh thread, or a malformed id); the + * drain then omits `references` and the transport derives `[inReplyTo]`. + * + * Optional: a caller with no mailbox to consult (`createHarness`) omits it, + * leaving the pre-existing single-element threading unchanged. The warm + * workflow-host wiring supplies it from the deployment's committed mailbox. + */ + resolveReferences?: (inReplyTo: string) => Promise; + /** + * Advance connector state after a successful send. May be synchronous + * (the in-process router's `onReplySent`) or asynchronous (a durable + * store that persists the advanced `lastMessageId`); the drain awaits it + * before composing the next reply. + */ + onReplySent: (receipt: SendReceipt) => void | Promise; + /** + * Invoked when `composeReply`, `send`, or `onReplySent` throws for one + * reply. The reply is dropped and the connector thread stays at its + * pre-send value. The drain awaits the callback (so an async callback's + * rejection is observed and logged, not left as an unhandled rejection) + * and absorbs any error it raises. + */ + onSendFailed?: (cause: unknown) => void | Promise; + /** + * Invoked when the stream's `for await` loop exits abnormally -- the + * documented case is a backpressure error thrown by the agent event + * stream. After it fires the drain no longer forwards replies. Awaited + * and absorbed the same way as `onSendFailed`. + */ + onTerminated?: (cause: unknown) => void | Promise; +} + +/** + * The settled outcome of one reply the drain processed. `ok` distinguishes a + * durably-sent reply (the send acked and `onReplySent` advanced the thread) + * from a failed one (compose, send, or `onReplySent` threw). A caller gating a + * side effect on the reply reaching the transport awaits the barrier and acts + * only on `ok: true`; `ok: false` carries the failure `cause` so the caller can + * surface it rather than treat the reply as sent. + */ +export type ReplySettlement = + | { readonly ok: true; readonly receipt: SendReceipt } + | { readonly ok: false; readonly cause: unknown }; + +export interface ConnectorReplyDrain { + /** + * Settles once the drain loop has exited and its last pending reply has + * drained. Always resolves -- per-reply and terminal failures are routed + * to the callbacks, never thrown out of here -- so a caller can await it + * on teardown without guarding a rejection. + */ + readonly done: Promise; + /** + * Signal the loop to stop at the next event. The loop also exits on its + * own when the underlying stream ends (e.g. the agent closes); `stop()` + * is the cooperative early exit for a caller tearing down before then. + */ + stop(): void; + /** + * The count of replies that have SETTLED so far -- sent-and-acked or failed. + * Monotonic. A per-turn caller captures this BEFORE the `agent.send` that may + * produce a reply, then, for a turn that did produce a `connector.reply`, + * awaits `waitForReplyAfter(captured)` to block until THIS turn's reply + * settles. The capture-before-send ordering is required: the agent resolves + * `agent.send` in the same synchronous step that pushes the `connector.reply` + * onto this drain's stream, so the reply is not yet enqueued when `send` + * resolves -- a post-send snapshot would miss it. + */ + replySeq(): number; + /** + * Resolve once more than `n` replies have settled -- i.e. the reply at index + * `n` (the `(n + 1)`th reply the drain processed) has settled -- with that + * reply's settlement. Because the warm agent is strictly serial and the drain + * is FIFO, a turn that captured `n` from `replySeq()` before its send and + * produced exactly one reply awaits reply `n` here. + * + * When the drain loop exits (stream end, `stop()`, or an abnormal + * termination) before reply `n` settles, resolves with a failure settlement + * rather than hanging, so a caller awaiting a reply that will never arrive + * fails its turn instead of blocking forever. + */ + waitForReplyAfter(n: number): Promise; +} + +async function invokeAbsorbing( + callback: (cause: unknown) => void | Promise, + cause: unknown, + label: string, +): Promise { + try { + await callback(cause); + } catch (callbackError) { + logger.error`${label} callback threw: ${callbackError}`; + } +} + +/** + * Drive an agent's `connector.reply` events out through a transport. Returns + * immediately with a handle; the drain runs in the background until the + * stream ends or `stop()` is called. + */ +export function driveConnectorReplies( + opts: ConnectorReplyDrainOpts, +): ConnectorReplyDrain { + let stopped = false; + // Reply sends are serialized through `replyChain` so two replies fired in + // quick succession do not interleave their compose / send / onReplySent + // sequence -- the second waits for the first's receipt to advance the + // thread before composing its own. + let replyChain: Promise = Promise.resolve(); + + // Per-turn settle barrier. `settlements[i]` is the outcome of the `i`th reply + // the drain processed; `settlements.length` is the monotonic settled count a + // caller snapshots through `replySeq()`. Waiters block until the settled + // count passes their target index, then resolve with that reply's outcome. A + // reply is recorded here on BOTH success and failure so a waiter never hangs; + // the outcome's `ok` tells the caller which happened. + const settlements: ReplySettlement[] = []; + let terminated = false; + type Waiter = { target: number; resolve: (s: ReplySettlement) => void }; + let waiters: Waiter[] = []; + + const terminalSettlement = (): ReplySettlement => ({ + ok: false, + cause: new Error( + "connector reply drain terminated before the reply was sent", + ), + }); + + function settlementAt(index: number): ReplySettlement { + const settlement = settlements[index]; + if (settlement === undefined) { + // Reached only if a waiter resolves for an index the drain never + // recorded -- an internal invariant break, surfaced loudly rather than + // handed back as a silent fallback. + throw new Error( + `connector reply drain: settlement ${String(index)} missing though ` + + `${String(settlements.length)} replies have settled`, + ); + } + return settlement; + } + + function recordSettlement(settlement: ReplySettlement): void { + settlements.push(settlement); + const settledCount = settlements.length; + const stillWaiting: Waiter[] = []; + for (const waiter of waiters) { + if (settledCount > waiter.target) { + waiter.resolve(settlementAt(waiter.target)); + } else { + stillWaiting.push(waiter); + } + } + waiters = stillWaiting; + } + + function releaseWaitersOnTermination(): void { + terminated = true; + const outstanding = waiters; + waiters = []; + for (const waiter of outstanding) { + // A waiter whose reply settled before teardown gets its real outcome; one + // whose reply never arrived (the drain stopped first) gets a terminal + // failure so the caller fails its turn rather than blocking. + waiter.resolve( + settlements.length > waiter.target + ? settlementAt(waiter.target) + : terminalSettlement(), + ); + } + } + + const done = (async () => { + try { + for await (const event of opts.stream) { + if (stopped) break; + if (event.type !== "connector.reply") continue; + const content = event.data.content; + replyChain = replyChain.then(async () => { + try { + const parts = opts.composeReply(); + // Resolve the full References ancestry for the parent this reply + // answers, when the caller supplies a resolver. A resolver miss + // (parent absent, malformed id) yields `undefined`, and the + // transport derives `[inReplyTo]` as before. + const references = + opts.resolveReferences !== undefined + ? await opts.resolveReferences(parts.inReplyTo) + : undefined; + const receipt = await opts.send({ + ...parts, + content, + type: "conversation.message", + ...(references !== undefined && references.length > 0 + ? { references } + : {}), + }); + await opts.onReplySent(receipt); + recordSettlement({ ok: true, receipt }); + } catch (cause) { + // The reply is dropped and the connector thread stays at its + // pre-send value. Surface the loss to `onSendFailed` in addition + // to the operator-facing log so programmatic consumers (retries, + // alerting) can observe what the log alone hides. Record the + // failure on the barrier too, so a per-turn caller awaiting this + // reply sees `ok: false` rather than treating it as sent. + logger.error`Failed to send connector reply: ${cause}`; + if (opts.onSendFailed !== undefined) { + await invokeAbsorbing(opts.onSendFailed, cause, "onSendFailed"); + } + recordSettlement({ ok: false, cause }); + } + }); + } + } catch (cause) { + // The agent's stream throws on backpressure violations; log and exit. + // The reply path stops working but the caller's other consumers keep + // running until teardown. Surface the loss to `onTerminated` so + // programmatic consumers (alerting, watchdogs) can observe it. + logger.warn`Reply-drain stream terminated: ${cause}`; + if (opts.onTerminated !== undefined) { + await invokeAbsorbing(opts.onTerminated, cause, "onTerminated"); + } + } finally { + // Drain the pending reply before the loop exits so its settlement is + // recorded and a caller awaiting `done` sees a settled state. Then + // release any barrier waiter still blocked on a reply that will never + // arrive, so a per-turn caller cannot hang past teardown. + await replyChain; + releaseWaitersOnTermination(); + } + })(); + + return { + done, + stop() { + stopped = true; + }, + replySeq() { + return settlements.length; + }, + waitForReplyAfter(n: number): Promise { + if (settlements.length > n) { + return Promise.resolve(settlementAt(n)); + } + if (terminated) { + return Promise.resolve(terminalSettlement()); + } + return new Promise((resolve) => { + waiters.push({ target: n, resolve }); + }); + }, + }; +} diff --git a/vendor/intx/harness/src/runtime-capabilities.ts b/vendor/intx/harness/src/runtime-capabilities.ts new file mode 100644 index 000000000..41ee95fd6 --- /dev/null +++ b/vendor/intx/harness/src/runtime-capabilities.ts @@ -0,0 +1,22 @@ +// Harness-side factory for the RuntimeCapabilities that tool packages +// consume. The wrapper exists so callers (sidecar, alternate runtimes) +// pass a config object keyed by domain (`transport`) and the harness +// owns the translation to RuntimeCapabilityMap keys (`mail.transport`). +// When new capabilities are added, callers' shapes evolve through this +// wrapper, not at the call site. + +import { + createRuntimeCapabilities, + type RuntimeCapabilities, +} from "@intx/types/runtime-capabilities"; +import type { MessageTransport } from "@intx/types/runtime"; + +export interface HarnessRuntimeCapabilitiesOptions { + transport: MessageTransport; +} + +export function createHarnessRuntimeCapabilities( + opts: HarnessRuntimeCapabilitiesOptions, +): RuntimeCapabilities { + return createRuntimeCapabilities({ "mail.transport": opts.transport }); +} diff --git a/vendor/intx/harness/tsconfig.json b/vendor/intx/harness/tsconfig.json new file mode 100644 index 000000000..dbbb0384b --- /dev/null +++ b/vendor/intx/harness/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.base.json", + "include": [ + "src/**/*.ts" + ], + "compilerOptions": { + "types": [ + "bun" + ] + } +} diff --git a/vendor/intx/hub-agent/README.md b/vendor/intx/hub-agent/README.md new file mode 100644 index 000000000..9beed284c --- /dev/null +++ b/vendor/intx/hub-agent/README.md @@ -0,0 +1,37 @@ +# @intx/hub-agent + +Sidecar-side orchestrator. Wires the package-side pieces of the +sidecar runtime — the per-agent key and repo stores, a session +manager, and the hub WebSocket link — into a single start/close +handle, applies deploy and asset packs received from the hub, and +forwards a spawned child's verified inference events back to the hub. + +In-process harness construction and agent provisioning are retired: +every agent now runs as a supervised workflow-process child on the +workflow-run substrate. What remains in `createSessionManager` is a +thin serialization layer over the agent repo store (deploy/asset-pack +applies, state-pack reads, deploy-ref reads, directory teardown); +operations run one at a time per agent so a teardown never races an +in-flight git op. + +Consumed by `apps/sidecar` as the orchestrator that turns a sidecar +process into a host for hub-deployed agents. + +`createSidecarOrchestrator` takes a `SidecarOrchestratorConfig` +specifying how to reach the hub (`hubURL`, `sidecarId`, `token`, +`transport`), where to persist agent state (`dataDir`), the sidecar's +crypto operations (`cryptoOps`), and the host-injected deploy-router +factory (`createDeployRouter`) that routes every `agent.deploy` frame +on the link. Optional fields supply the multi-step inbound routers +(`mailInboundRouter`, `signalInboundRouter`, `drainInboundRouter`, +`sourcesInboundRouter`), the workflow-address announce and routability +hooks, and the reconnect cadence. See `SidecarOrchestratorConfig` in +`src/sidecar-orchestrator.ts` for the full surface. + +`HarnessBuilder` is a one-method source-admission seam +(`canBuildSource`) the host supplies: the deploy router calls it to +admit a step's pinned inference source before spawning, so an +unbuildable source is rejected on the control plane rather than during +the next inference call. It is not a harness-wiring seam — the package +declares the shape and stays free of the concrete inference packages +the check consults. diff --git a/vendor/intx/hub-agent/VENDORED-FROM b/vendor/intx/hub-agent/VENDORED-FROM new file mode 100644 index 000000000..9be44faa5 --- /dev/null +++ b/vendor/intx/hub-agent/VENDORED-FROM @@ -0,0 +1,4 @@ +Source: https://github.com/faremeter/interchange (packages/hub-agent) +Commit: a8bc06ae38661c5e0ed91ded8559bf09f502213d (origin/main, 2026-08-27) +License: LGPL-2.1-only (see vendor/intx/LICENSE) +Local modifications: exports map repointed from the upstream intx-src condition to direct TypeScript source resolution (types/default -> ./src/...); dist references removed. No source delta. diff --git a/vendor/intx/hub-agent/package.json b/vendor/intx/hub-agent/package.json new file mode 100644 index 000000000..c4b05c1e2 --- /dev/null +++ b/vendor/intx/hub-agent/package.json @@ -0,0 +1,44 @@ +{ + "name": "@intx/hub-agent", + "description": "Sidecar-side orchestrator linking per-agent stores and sessions to the hub over WebSocket", + "version": "0.3.0", + "license": "LGPL-2.1-only", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./paths": { + "types": "./src/paths.ts", + "default": "./src/paths.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@intx/harness": "workspace:*", + "@intx/log": "0.3.0", + "@intx/mail-memory": "workspace:*", + "@intx/pack-transport": "0.3.0", + "@intx/storage-isogit": "0.3.0", + "@intx/types": "workspace:*", + "arktype": "catalog:", + "isomorphic-git": "catalog:" + }, + "devDependencies": { + "@types/bun": "catalog:", + "hono": "catalog:", + "typescript": "catalog:" + }, + "files": [ + "src", + "README.md", + "LICENSE" + ], + "sideEffects": false, + "publishConfig": { + "access": "public" + } +} diff --git a/vendor/intx/hub-agent/src/agent-key-store.ts b/vendor/intx/hub-agent/src/agent-key-store.ts new file mode 100644 index 000000000..7f3b35677 --- /dev/null +++ b/vendor/intx/hub-agent/src/agent-key-store.ts @@ -0,0 +1,195 @@ +// Per-agent Ed25519 key custody. +// +// Persists key pairs as raw 32-byte binary files on disk and produces +// them on demand. The cryptographic primitives (keypair generation, +// challenge signing, deploy-commit verification) are host-supplied so +// the package does not pin a particular crypto backend. +// +// In addition to the on-disk persistence, the store keeps an in-memory +// cache of the keypair and the paired hub public key for every agent +// that has been loaded or recorded during the process lifetime. The +// cache backs the per-frame crypto operations the wire layer needs: +// signChallenge for challenge response frames and verifyDeployCommit +// for incoming deploy packs. + +import fsp from "node:fs/promises"; +import { hasCode, hexDecode } from "@intx/types"; +import type { KeyPair } from "@intx/types/runtime"; + +import { keysDir, privateKeyPath, publicKeyPath } from "./agent-paths"; + +export type AgentKeyStoreDeps = { + dataDir: string; + generateKeyPair: () => Promise; + /** + * Sign `payload` with the supplied raw Ed25519 private key. Returns + * the raw 64-byte detached signature. Used by signChallenge. + */ + signEd25519: ( + privateKey: Uint8Array, + payload: Uint8Array, + ) => Promise; + /** + * Verify an SSH signature block against the supplied public key. + * Used by verifyDeployCommit. + */ + verifySSHSig: ( + payload: string, + signature: string, + publicKey: Uint8Array, + ) => Promise; +}; + +export type AgentKeyStore = { + /** + * Load the existing keypair for an agent, or mint and persist a new + * one. The keypair is also cached in memory so subsequent + * signChallenge calls do not touch disk. The `isNew` flag is true + * when the keypair was just generated. + */ + loadOrGenerateKey( + address: string, + ): Promise<{ keyPair: KeyPair; isNew: boolean }>; + /** + * Sign the challenge payload with the agent's private key. On a cache + * miss the durable on-disk key is reloaded, so an address whose cache + * was wiped by forgetAgent (e.g. a challenge.failed during the deploy + * window) can still answer a later challenge instead of being stranded + * until process restart. Returns null only when no key is persisted for + * the address — the caller (HubLink) treats that as "skip this + * challenge." + */ + signChallenge( + address: string, + payload: Uint8Array, + ): Promise; + /** + * Record the hub public key the agent has been paired with. Cached in + * memory only; the deploy path re-records it on every deploy, so it + * does not need to survive a restart. + */ + recordHubKey(address: string, hexHubPublicKey: string): void; + /** + * Verify an SSH signature against the cached hub public key for the + * given address. Throws when no hub key is cached — a deploy pack + * cannot be verified without one. + */ + verifyDeployCommit( + address: string, + payload: string, + signature: string, + ): Promise; + /** + * Drop the in-memory caches for an agent. Called on undeploy and on + * challenge.failed. + */ + forgetAgent(address: string): void; +}; + +export function createAgentKeyStore(deps: AgentKeyStoreDeps): AgentKeyStore { + const { dataDir, generateKeyPair, signEd25519, verifySSHSig } = deps; + + const agentKeys = new Map(); + const hubKeys = new Map(); + + async function loadKeyFromDisk(address: string): Promise { + const privPath = privateKeyPath(dataDir, address); + const pubPath = publicKeyPath(dataDir, address); + + const [privExists, pubExists] = await Promise.all([ + fileExists(privPath), + fileExists(pubPath), + ]); + + if (privExists !== pubExists) { + const missing = privExists ? "public" : "private"; + throw new Error( + `Corrupt key pair for "${address}": ${missing} key file is missing`, + ); + } + + if (!privExists) return null; + + const [privateKey, publicKey] = await Promise.all([ + fsp.readFile(privPath), + fsp.readFile(pubPath), + ]); + const keyPair: KeyPair = { + privateKey: new Uint8Array(privateKey), + publicKey: new Uint8Array(publicKey), + }; + agentKeys.set(address, keyPair); + return keyPair; + } + + async function loadOrGenerateKey( + address: string, + ): Promise<{ keyPair: KeyPair; isNew: boolean }> { + const fromDisk = await loadKeyFromDisk(address); + if (fromDisk !== null) return { keyPair: fromDisk, isNew: false }; + + const keyPair = await generateKeyPair(); + await fsp.mkdir(keysDir(dataDir, address), { recursive: true }); + await Promise.all([ + fsp.writeFile(privateKeyPath(dataDir, address), keyPair.privateKey, { + mode: 0o600, + }), + fsp.writeFile(publicKeyPath(dataDir, address), keyPair.publicKey), + ]); + agentKeys.set(address, keyPair); + return { keyPair, isNew: true }; + } + + async function signChallenge( + address: string, + payload: Uint8Array, + ): Promise { + const keyPair = agentKeys.get(address) ?? (await loadKeyFromDisk(address)); + if (keyPair === null || keyPair === undefined) return null; + return signEd25519(keyPair.privateKey, payload); + } + + function recordHubKey(address: string, hexHubPublicKey: string): void { + hubKeys.set(address, hexDecode(hexHubPublicKey)); + } + + async function verifyDeployCommit( + address: string, + payload: string, + signature: string, + ): Promise { + const hubKey = hubKeys.get(address); + if (hubKey === undefined) { + throw new Error( + `signature_invalid: no hub public key recorded for "${address}"`, + ); + } + return verifySSHSig(payload, signature, hubKey); + } + + function forgetAgent(address: string): void { + agentKeys.delete(address); + hubKeys.delete(address); + } + + return { + loadOrGenerateKey, + signChallenge, + recordHubKey, + verifyDeployCommit, + forgetAgent, + }; +} + +async function fileExists(filePath: string): Promise { + try { + await fsp.access(filePath); + return true; + } catch (err: unknown) { + if (hasCode(err) && err.code === "ENOENT") return false; + // Any other failure mode (EACCES, EBUSY, EIO, …) must surface so a + // restart does not silently mint a fresh key over an existing one + // when the existence check is denied or transiently failing. + throw err; + } +} diff --git a/vendor/intx/hub-agent/src/agent-paths.ts b/vendor/intx/hub-agent/src/agent-paths.ts new file mode 100644 index 000000000..edbc6882b --- /dev/null +++ b/vendor/intx/hub-agent/src/agent-paths.ts @@ -0,0 +1,35 @@ +// Per-agent on-disk layout helpers. +// +// The sanitization scheme is an internal implementation detail: it gives +// each run address a stable filesystem-safe directory name, but the +// mapping is lossy and the directory name cannot be reversed. Callers +// that need to find an agent's directory must go through this module +// (or through AgentRepoStore.getAgentDir) rather than computing the +// path independently. + +import path from "node:path"; + +// The per-agent key-file layout the address-keyed helpers below build on. +const KEYS_DIR_NAME = "keys"; +const PRIVATE_KEY_FILE = "id_ed25519"; +const PUBLIC_KEY_FILE = "id_ed25519.pub"; + +export function sanitizeAddress(address: string): string { + return address.replace(/@/g, "_at_").replace(/[^a-zA-Z0-9_-]/g, "_"); +} + +export function agentDir(dataDir: string, address: string): string { + return path.join(dataDir, sanitizeAddress(address)); +} + +export function keysDir(dataDir: string, address: string): string { + return path.join(agentDir(dataDir, address), KEYS_DIR_NAME); +} + +export function privateKeyPath(dataDir: string, address: string): string { + return path.join(keysDir(dataDir, address), PRIVATE_KEY_FILE); +} + +export function publicKeyPath(dataDir: string, address: string): string { + return path.join(keysDir(dataDir, address), PUBLIC_KEY_FILE); +} diff --git a/vendor/intx/hub-agent/src/agent-repo-store.ts b/vendor/intx/hub-agent/src/agent-repo-store.ts new file mode 100644 index 000000000..7310832a7 --- /dev/null +++ b/vendor/intx/hub-agent/src/agent-repo-store.ts @@ -0,0 +1,110 @@ +// Per-agent on-disk repository layout. +// +// Owns the isogit repo wrapper and the deploy-pack apply / state-pack +// produce flow. Key custody lives in AgentKeyStore alongside this +// store; both share the directory-layout helpers in agent-paths. + +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import git from "isomorphic-git"; +import { getLogger } from "@intx/log"; +import { hasCode } from "@intx/types"; +import { + initAgentRepo, + applyPack, + createDeployPack, + currentBranch, + type CommitVerifier, +} from "@intx/storage-isogit/node"; + +import { agentDir } from "./agent-paths"; + +const logger = getLogger(["interchange", "hub-agent", "repo-store"]); + +export type ApplyDeployPackArgs = { + address: string; + pack: Uint8Array; + ref: string; + commitSha: string; + transferId: string; + verifyCommit?: CommitVerifier; +}; + +export type AgentRepoStore = { + /** + * Resolve the on-disk directory for an agent. Exposed for integration + * tests that need to assert against disk state without depending on + * the (intentionally opaque) directory naming scheme. + */ + getAgentDir(address: string): string; + initRepo(address: string): Promise; + applyDeployPack(args: ApplyDeployPackArgs): Promise; + createStatePack( + address: string, + ): Promise<{ pack: Uint8Array; commitSha: string; ref: string }>; + getDeployRef(address: string): Promise; + remove(address: string): Promise; +}; + +export function createAgentRepoStore(config: { + dataDir: string; +}): AgentRepoStore { + const { dataDir } = config; + + function getAgentDir(address: string): string { + return agentDir(dataDir, address); + } + + async function initRepo(address: string): Promise { + await initAgentRepo(getAgentDir(address)); + } + + async function applyDeployPackImpl(args: ApplyDeployPackArgs): Promise { + const { address, pack, ref, commitSha, transferId, verifyCommit } = args; + await applyPack( + getAgentDir(address), + pack, + ref, + commitSha, + transferId, + verifyCommit, + ); + logger.info`Applied deploy pack for ${address} at ${commitSha.slice(0, 8)}`; + } + + async function createStatePack( + address: string, + ): Promise<{ pack: Uint8Array; commitSha: string; ref: string }> { + const dir = getAgentDir(address); + const branch = await currentBranch(dir); + const ref = `refs/heads/${branch}`; + const { pack, commitSha } = await createDeployPack(dir, ref); + return { pack, commitSha, ref }; + } + + async function getDeployRef(address: string): Promise { + const dir = getAgentDir(address); + try { + return await git.resolveRef({ fs, dir, ref: "refs/heads/deploy" }); + } catch (err: unknown) { + if (hasCode(err) && err.code === "NotFoundError") { + return null; + } + throw err; + } + } + + async function remove(address: string): Promise { + await fsp.rm(getAgentDir(address), { recursive: true }); + logger.info`Deleted agent directory for ${address}`; + } + + return { + getAgentDir, + initRepo, + applyDeployPack: applyDeployPackImpl, + createStatePack, + getDeployRef, + remove, + }; +} diff --git a/vendor/intx/hub-agent/src/apply-asset-pack.ts b/vendor/intx/hub-agent/src/apply-asset-pack.ts new file mode 100644 index 000000000..f0751107d --- /dev/null +++ b/vendor/intx/hub-agent/src/apply-asset-pack.ts @@ -0,0 +1,155 @@ +// Materialize an asset pack as plain files under a workspace mount path. +// +// Asset packs are git packfiles produced by the hub for assets attached +// to an agent (today only `skill`). The sidecar receives the pack at +// session start and writes its tree contents under +// `//`. The workspace is plain files, not a +// git working tree — asset packs must not share the agent's deploy +// `.git/` (separate ref namespaces, separate object lifecycles), so we +// index the pack against a scratch git directory and copy the tree out. +// +// Asset packs in v1 are unsigned: they originate from the hub itself +// (synthetic content authored via the asset service) and are validated +// by the kind handler's `validatePush` on the hub-side write path. +// The cryptographic signature scheme that `applyDeployPack` enforces +// does not apply. + +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import path from "node:path"; +import git from "isomorphic-git"; + +import { getLogger } from "@intx/log"; +import { + DEFAULT_PACK_MATERIALIZATION_LIMITS, + indexPackIntoGitDir, + writeTreeToDisk, +} from "@intx/storage-isogit/node"; + +const logger = getLogger(["interchange", "hub-agent", "apply-asset-pack"]); + +const SAFE_PATH_SEGMENT = /^[a-zA-Z0-9_./-]+$/; + +export type ApplyAssetPackArgs = { + workspaceRoot: string; + /** Repo-relative directory (with or without trailing slash) under + * `workspaceRoot` where the pack's tree contents should land. */ + mountPath: string; + pack: Uint8Array; + ref: string; + commitSha: string; +}; + +/** + * Materialize an asset pack at `//`. + * + * Throws an Error with prefix `asset_materialization_failed:` on any + * failure (pack index error, missing commit, missing tree, blob write + * error). Callers in the WS layer classify this as the existing + * `pack.reject` reason `corrupt`. + */ +export async function applyAssetPack(args: ApplyAssetPackArgs): Promise { + const { workspaceRoot, mountPath, pack, ref, commitSha } = args; + + if (mountPath.length === 0 || mountPath.startsWith("/")) { + throw new Error( + `asset_materialization_failed: invalid mountPath ${JSON.stringify(mountPath)}`, + ); + } + // Reject any all-dots segment (".", "..", "...") before per-segment + // SAFE_PATH_SEGMENT screening. The base regex permits "." since it + // allows the character; without this guard a mountPath of "." would + // resolve destDir to workspaceRoot itself and the subsequent + // recursive rm would wipe the entire workspace. + for (const segment of mountPath.split("/")) { + if (segment === "") continue; + if (/^\.+$/.test(segment) || !SAFE_PATH_SEGMENT.test(segment)) { + throw new Error( + `asset_materialization_failed: invalid mountPath segment in ${JSON.stringify(mountPath)}`, + ); + } + } + // Defense-in-depth: after segment-level checks, normalize the path + // and reject anything that still resolves to "." or contains ".." + // (e.g. a permutation the per-segment loop missed). + const normalized = path.posix.normalize(mountPath); + if ( + normalized === "." || + normalized === "./" || + normalized.split("/").includes("..") + ) { + throw new Error( + `asset_materialization_failed: mountPath ${JSON.stringify(mountPath)} normalizes to a workspace-root or escaping path`, + ); + } + + const normalizedMount = mountPath.endsWith("/") + ? mountPath.slice(0, -1) + : mountPath; + const destDir = path.join(workspaceRoot, normalizedMount); + + await fsp.mkdir(workspaceRoot, { recursive: true }); + + const scratchDir = await fsp.mkdtemp( + path.join(workspaceRoot, ".intx-asset-scratch-"), + ); + // Set while a materialized-files temp dir awaits publish; cleared once it is + // renamed onto the mount. The `finally` removes it if a failure leaves it. + let materializeDir: string | undefined; + + try { + // Index the pack into the scratch `.git` and assert the pinned commit is + // present. The scratch dir is discarded in the `finally`; this reuses the + // same "index a pack into a gitDir and assert the commit" step a durable + // source-asset delivery keeps. + await indexPackIntoGitDir( + scratchDir, + pack, + commitSha, + DEFAULT_PACK_MATERIALIZATION_LIMITS, + ); + + const { commit } = await git.readCommit({ + fs, + dir: scratchDir, + oid: commitSha, + }); + + // Materialize into a sibling temp dir, then atomically publish it to the + // mount by rename, so a crash mid-write never leaves a partial mount that + // restore's dir-exists check would trust. A re-delivery keeps the prior + // mount until the new one is ready, so a failed materialization does not + // destroy a working mount. + materializeDir = await fsp.mkdtemp( + path.join(workspaceRoot, ".intx-asset-materialize-"), + ); + await writeTreeToDisk( + scratchDir, + materializeDir, + commit.tree, + DEFAULT_PACK_MATERIALIZATION_LIMITS, + ); + // Publish atomically: ensure the mount's PARENT exists, clear any prior + // mount, and rename the fully materialized temp into place. + await fsp.mkdir(path.dirname(destDir), { recursive: true }); + await fsp.rm(destDir, { recursive: true, force: true }); + await fsp.rename(materializeDir, destDir); + materializeDir = undefined; // published; nothing left to clean up + + logger.info`Materialized asset pack at ${destDir} (${commitSha.slice(0, 8)} on ${ref})`; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + // The mount is published only by the atomic rename above, so a failure here + // leaves the prior (complete) mount untouched -- do not remove it. The + // partial temp is cleaned in the `finally`. + throw new Error(`asset_materialization_failed: ${msg}`, { cause: err }); + } finally { + for (const dir of [scratchDir, materializeDir]) { + if (dir === undefined) continue; + await fsp.rm(dir, { recursive: true, force: true }).catch((rmErr) => { + const rmMsg = rmErr instanceof Error ? rmErr.message : String(rmErr); + logger.warn`asset pack temp cleanup failed at ${dir}: ${rmMsg}`; + }); + } + } +} diff --git a/vendor/intx/hub-agent/src/deploy-tree.ts b/vendor/intx/hub-agent/src/deploy-tree.ts new file mode 100644 index 000000000..7afd24c43 --- /dev/null +++ b/vendor/intx/hub-agent/src/deploy-tree.ts @@ -0,0 +1,106 @@ +// Deploy tree reader: extracts the system prompt, the tool-package +// manifest, and the asset-mounts map from the deploy directory of an +// agent's git repository. +// +// The deploy tree is written by `applyDeployPack` and contains: +// deploy/prompt.md — system prompt for inference +// deploy/tool-packages-manifest.json — optional, full pinned closure +// of NPM-distributed tool +// packages +// deploy/asset-mounts.json — optional, assetId → mount +// path map covering every +// `kind: "asset"` entry in +// the manifest + +import fs from "node:fs"; +import path from "node:path"; +import { type } from "arktype"; + +import { hasCode } from "@intx/types"; + +const AssetMountsFile = type({ + assetMounts: type({ "[string]": "string" }), +}); + +export type DeployTree = { + systemPrompt: string | undefined; + /** + * Raw, un-parsed bytes of `deploy/tool-packages-manifest.json`. + * JSON parsing and arktype validation are the caller's + * responsibility — the sidecar's harness builder does both inside + * `materializeToolPackages` so a corrupt or schema-invalid manifest + * fails the apply loudly (category `manifest.invalid`) the same way + * as every other apply-time failure. + * + * Undefined means the manifest file is not present. + */ + toolPackageManifestRaw: string | undefined; + /** + * Parsed `deploy/asset-mounts.json`, validated by arktype. The map + * is empty when the file is absent — that is the legitimate shape + * for a deploy with no asset-sourced tool packages, and the loader's + * own gating raises if a manifest entry asks for a missing assetId. + */ + assetMounts: ReadonlyMap; +}; + +/** + * Read the system prompt and tool-package manifest bytes from the + * deploy directory. Each field is independently optional — undefined + * means the corresponding file is not present in the materialized + * deploy. An agent that has not yet received a deploy pack returns + * both as undefined. + * + * No parsing or validation of the manifest happens here; the caller + * runs both inside the loader boundary so parse errors and schema + * errors land on the same failure path. + */ +export async function readDeployTree(dir: string): Promise { + const promptPath = path.join(dir, "deploy", "prompt.md"); + const manifestPath = path.join(dir, "deploy", "tool-packages-manifest.json"); + const assetMountsPath = path.join(dir, "deploy", "asset-mounts.json"); + + let systemPrompt: string | undefined; + try { + const raw = await fs.promises.readFile(promptPath, "utf-8"); + systemPrompt = raw.trim() === "" ? undefined : raw; + } catch (e) { + if (hasCode(e) && e.code === "ENOENT") { + systemPrompt = undefined; + } else { + throw e; + } + } + + let toolPackageManifestRaw: string | undefined; + try { + toolPackageManifestRaw = await fs.promises.readFile(manifestPath, "utf-8"); + } catch (e) { + if (hasCode(e) && e.code === "ENOENT") { + toolPackageManifestRaw = undefined; + } else { + throw e; + } + } + + let assetMounts: ReadonlyMap = new Map(); + try { + const raw = await fs.promises.readFile(assetMountsPath, "utf-8"); + const parsed: unknown = JSON.parse(raw); + const validated = AssetMountsFile(parsed); + if (validated instanceof type.errors) { + throw new Error( + `deploy/asset-mounts.json failed validation: ${validated.summary}`, + ); + } + assetMounts = new Map(Object.entries(validated.assetMounts)); + } catch (e) { + if (hasCode(e) && e.code === "ENOENT") { + assetMounts = new Map(); + } else { + throw e; + } + } + + return { systemPrompt, toolPackageManifestRaw, assetMounts }; +} diff --git a/vendor/intx/hub-agent/src/harness-builder.ts b/vendor/intx/hub-agent/src/harness-builder.ts new file mode 100644 index 000000000..b5e42001b --- /dev/null +++ b/vendor/intx/hub-agent/src/harness-builder.ts @@ -0,0 +1,18 @@ +// HarnessBuilder seam. +// +// The package declares the shape of the host's source-admission check; +// the host (apps/sidecar today, any custom sidecar tomorrow) supplies +// the concrete implementation. This keeps @intx/hub-agent free of +// dependencies on the concrete inference packages the check consults. + +import type { InferenceSource } from "@intx/types/runtime"; + +export type HarnessBuilder = { + /** + * Throws if the supplied source cannot be built by this host. The + * sidecar deploy router calls this to admit a step's pinned inference + * source before spawning, so the operator sees rejection on the + * control plane rather than during the next inference call. + */ + canBuildSource(source: InferenceSource): void; +}; diff --git a/vendor/intx/hub-agent/src/index.ts b/vendor/intx/hub-agent/src/index.ts new file mode 100644 index 000000000..a2b298ec0 --- /dev/null +++ b/vendor/intx/hub-agent/src/index.ts @@ -0,0 +1,39 @@ +export { + createAgentRepoStore, + type AgentRepoStore, + type ApplyDeployPackArgs, +} from "./agent-repo-store"; +export { + createAgentKeyStore, + type AgentKeyStore, + type AgentKeyStoreDeps, +} from "./agent-key-store"; +export type { HarnessBuilder } from "./harness-builder"; +export { + createSessionManager, + type SessionManager, + type SessionManagerConfig, +} from "./session-manager"; +export { + createHubLink, + type DeployRouter, + type DeployRouterResult, + type HubLink, + type HubLinkConfig, + type MailInboundRouter, + type SignalInboundRouter, + type DrainInboundRouter, + type GrantsInboundRouter, + type WorkflowRunPackApplier, + type ReconnectScheduler, +} from "./ws/hub-link"; +export { + createSidecarOrchestrator, + type CreateDeployRouter, + type SidecarOrchestrator, + type SidecarOrchestratorConfig, + type SidecarCryptoOps, +} from "./sidecar-orchestrator"; +export { applyAssetPack, type ApplyAssetPackArgs } from "./apply-asset-pack"; +export { readDeployTree, type DeployTree } from "./deploy-tree"; +export { agentDir, sanitizeAddress } from "./agent-paths"; diff --git a/vendor/intx/hub-agent/src/paths.ts b/vendor/intx/hub-agent/src/paths.ts new file mode 100644 index 000000000..d0a313ae7 --- /dev/null +++ b/vendor/intx/hub-agent/src/paths.ts @@ -0,0 +1,15 @@ +// Dependency-light entry for an agent's on-disk layout helpers. +// +// `@intx/hub-agent` is the sidecar orchestrator. Importing its barrel +// (`index.ts`) evaluates the orchestrator's own module graph -- the session +// manager, the hub-link WebSocket layer, the sidecar orchestrator -- plus +// `@intx/pack-transport`. A few consumers need only the small filesystem +// helpers for an agent's on-disk deploy state -- reading the deploy tree +// under an agent's directory and deriving that directory's name from the +// run address. For the spawned workflow-child loading the orchestrator to +// get them is both wasted module-evaluation and a backwards dependency on the +// very component that launches it. Neither helper's module imports the +// orchestrator graph, so this entry exposes them without it. + +export { readDeployTree, type DeployTree } from "./deploy-tree"; +export { agentDir } from "./agent-paths"; diff --git a/vendor/intx/hub-agent/src/session-manager.ts b/vendor/intx/hub-agent/src/session-manager.ts new file mode 100644 index 000000000..6b42aad2e --- /dev/null +++ b/vendor/intx/hub-agent/src/session-manager.ts @@ -0,0 +1,200 @@ +// Per-agent on-disk repo operations for supervised deployments. +// +// The in-process session runtime -- harness construction, agent +// provisioning, disk restore, and per-agent mail audit -- has been +// retired: every agent now runs as a supervised workflow-process child +// on the workflow-run substrate. What remains here is the thin +// serialization layer over the agent repo store that the deploy path +// and the hub-link still call: deploy/asset-pack applies, state-pack +// reads, deploy-ref reads, and directory teardown, each run +// one-at-a-time per agent so a teardown never races an in-flight git op. + +import path from "node:path"; + +import type { AgentRepoStore } from "./agent-repo-store"; +import { applyAssetPack as applyAssetPackFn } from "./apply-asset-pack"; + +export type SessionManagerConfig = { + repoStore: AgentRepoStore; +}; + +export type SessionManager = { + /** + * Initialize the on-disk deploy-tree repo for an address. A single-step + * workflow deploy uses this at the head so the follow-up deploy-pack + * apply has a repo to apply into. + */ + initRepo(address: string): Promise; + /** + * Apply a deploy pack to the agent's repo. Thin wrapper around + * AgentRepoStore for callers that already have a SessionManager handle. + */ + applyDeployPack( + agentAddress: string, + pack: Uint8Array, + ref: string, + commitSha: string, + transferId: string, + verifyCommit?: (payload: string, signature: string) => Promise, + ): Promise; + /** + * Materialize an asset pack at `//` for the + * agent. The workspace root is per-agent; this is distinct from the + * agent's deploy git tree. Asset packs are unsigned in v1 -- no + * `verifyCommit` parameter. + */ + applyAssetPack( + agentAddress: string, + mountPath: string, + pack: Uint8Array, + ref: string, + commitSha: string, + ): Promise; + createStatePack( + agentAddress: string, + ): Promise<{ pack: Uint8Array; commitSha: string; ref: string }>; + deleteAgentDir(agentAddress: string): Promise; + getDeployRef(agentAddress: string): Promise; + /** + * Session addresses this manager hosts. The in-process session runtime + * is retired, so this is always empty; the hub-link ships it in the + * register frame alongside the sidecar's workflow-deployment addresses. + */ + getAddresses(): string[]; + /** + * Session id for an address' outbound mail forwarding. Always undefined + * now that no in-process sessions exist; the hub-link tolerates a + * missing id and forwards the mail without one. + */ + getSessionId(agentAddress: string): string | undefined; +}; + +export function createSessionManager( + config: SessionManagerConfig, +): SessionManager { + const { repoStore } = config; + + // Per-agent promise chain that serializes the operations against an agent's + // on-disk directory -- state-pack and deploy-ref reads and deploy/asset-pack + // applies all run one-at-a-time per agent. The chain exists for teardown: + // drainRepoOps awaits it before deleting the directory, so an operation that + // was valid when it started never runs against a path that has since + // vanished underneath it. Serializing additionally avoids corruption for the + // members that share the agent's `.git/` object store (state-pack and + // deploy-ref reads, deploy-pack applies), which isogit, lacking a + // cross-process lock, would otherwise let interleave. Asset-pack applies are + // on the chain only for the teardown reason -- they materialize into a + // workspace subtree, not the agent repo's object store. + const repoOpQueues = new Map>(); + + function runRepoOp( + agentAddress: string, + fn: () => Promise, + ): Promise { + const prev = repoOpQueues.get(agentAddress) ?? Promise.resolve(); + // Run fn once prev settles. prev is either the initial Promise.resolve() + // or the rejection-swallowing tail stored below, so it never rejects; + // passing fn as both the fulfilled and rejected handler keeps this op + // independent of that detail and guarantees fn runs exactly once after the + // previous op completes. + const result = prev.then(fn, fn); + // Store a rejection-swallowing tail so one failed op does not poison the + // chain for the next caller. The caller still observes this op's own + // result or rejection through `result`. + repoOpQueues.set( + agentAddress, + result.then( + () => undefined, + () => undefined, + ), + ); + return result; + } + + // Await the agent's current operation chain so teardown removes the + // directory only after in-flight git work finishes. Capturing the tail and + // clearing the entry means an op enqueued AFTER this point starts a fresh + // chain this drain does not await. That is safe only because every caller + // invokes runRepoOp synchronously, before its first await, inside the + // serialized frame dispatch -- so by the time a later agent.undeploy frame + // reaches deleteAgentDir, every racing op is already on the chain. A handler + // that deferred its runRepoOp call past an await would reopen the + // delete-under-in-flight-op race. + async function drainRepoOps(agentAddress: string): Promise { + const inflight = repoOpQueues.get(agentAddress); + repoOpQueues.delete(agentAddress); + if (inflight !== undefined) await inflight; + } + + async function applyDeployPack( + agentAddress: string, + pack: Uint8Array, + ref: string, + commitSha: string, + transferId: string, + verifyCommit?: (payload: string, signature: string) => Promise, + ): Promise { + const args = + verifyCommit !== undefined + ? { + address: agentAddress, + pack, + ref, + commitSha, + transferId, + verifyCommit, + } + : { address: agentAddress, pack, ref, commitSha, transferId }; + await runRepoOp(agentAddress, () => repoStore.applyDeployPack(args)); + } + + async function applyAssetPack( + agentAddress: string, + mountPath: string, + pack: Uint8Array, + ref: string, + commitSha: string, + ): Promise { + const workspaceRoot = path.join( + repoStore.getAgentDir(agentAddress), + "workspace", + ); + await runRepoOp(agentAddress, () => + applyAssetPackFn({ + workspaceRoot, + mountPath, + pack, + ref, + commitSha, + }), + ); + } + + async function createStatePack( + agentAddress: string, + ): Promise<{ pack: Uint8Array; commitSha: string; ref: string }> { + return runRepoOp(agentAddress, () => + repoStore.createStatePack(agentAddress), + ); + } + + async function deleteAgentDir(agentAddress: string): Promise { + await drainRepoOps(agentAddress); + await repoStore.remove(agentAddress); + } + + async function getDeployRef(agentAddress: string): Promise { + return runRepoOp(agentAddress, () => repoStore.getDeployRef(agentAddress)); + } + + return { + initRepo: (address: string) => repoStore.initRepo(address), + applyDeployPack, + applyAssetPack, + createStatePack, + deleteAgentDir, + getDeployRef, + getAddresses: () => [], + getSessionId: () => undefined, + }; +} diff --git a/vendor/intx/hub-agent/src/sidecar-orchestrator.ts b/vendor/intx/hub-agent/src/sidecar-orchestrator.ts new file mode 100644 index 000000000..d968fa056 --- /dev/null +++ b/vendor/intx/hub-agent/src/sidecar-orchestrator.ts @@ -0,0 +1,354 @@ +// SidecarOrchestrator: constructs and wires every package-side piece +// of the sidecar runtime — stores, SessionManager, HubLink — and +// returns a single start/close handle the host driver uses. +// +// The host supplies policy (the data directory, the low-level crypto +// primitives, the hub credentials, the deploy-router factory); the +// orchestrator does the composition. The multi-step deploy path +// forwards a spawned child's verified InferenceEvents to the hub +// through a sink the orchestrator owns: it points at a no-op closure +// until HubLink is constructed, then is rewired to hubLink.sendEvent, +// so the cross-reference is contained inside this module rather than +// leaking up to the host entry point. + +import { getLogger } from "@intx/log"; +import type { HubTransport } from "@intx/mail-memory"; +import type { SignalKind } from "@intx/types"; +import type { + ApprovalSnapshot, + InferenceEvent, + KeyPair, +} from "@intx/types/runtime"; + +import { createAgentKeyStore, type AgentKeyStore } from "./agent-key-store"; +import { createAgentRepoStore, type AgentRepoStore } from "./agent-repo-store"; +import { createSessionManager, type SessionManager } from "./session-manager"; +import { + createHubLink, + type DeployRouter, + type HubLink, + type MailInboundRouter, + type SignalInboundRouter, + type DrainInboundRouter, + type GrantsInboundRouter, + type SourcesInboundRouter, + type CredentialsInboundRouter, + type WorkflowRunPackApplier, + type WorkflowProbeExecutor, + type ReconnectScheduler, +} from "./ws/hub-link"; + +const log = getLogger(["interchange", "hub-agent", "orchestrator"]); + +export type SidecarCryptoOps = { + generateKeyPair(): Promise; + signEd25519(privateKey: Uint8Array, payload: Uint8Array): Promise; + verifySSHSig( + payload: string, + signature: string, + publicKey: Uint8Array, + ): Promise; +}; + +/** + * Factory the orchestrator invokes once `sessions` and `keyStore` + * are constructed. The host returns the `DeployRouter` the link + * routes every inbound `agent.deploy` through; production wires + * this against the sidecar's workflow-run deploy router. The host is + * responsible for closing over any other state the router needs + * (transport, substrate handle, signing keys) at the call site. + */ +export type CreateDeployRouter = (deps: { + sessions: SessionManager; + keyStore: AgentKeyStore; + /** + * Per-event sink the multi-step branch routes a spawned child's + * verified `InferenceEvent`s through, keyed by the deployment's agent + * address and the deploy's session id. Wired to the same hub-link + * `agent.event` sink the in-process path's `onEvent` uses, so a step + * agent's events reach the hub timeline keyed to the right session. + * The `sessionId` is optional because a deploy frame need not carry + * one (a headless deployment); the sink drops a sessionless event + * rather than guessing a session. + */ + publishWorkflowInferenceEvent: ( + agentAddress: string, + event: InferenceEvent, + sessionId: string | undefined, + ) => void; + /** + * Control-plane suspension sink the multi-step branch routes a + * supervisor's `park.notify` registration through. Wired to the hub-link's + * `sendSignalCorrelationRegister` so a parked run's correlation is + * registered at the hub (routing + approval rows). Mirrors + * `publishWorkflowInferenceEvent`: a no-op until HubLink is constructed, + * then swapped to the link's sink. + */ + publishWorkflowSuspension: (registration: { + correlationId: string; + runId: string; + anchorRunId: string; + agentAddress: string; + kind: SignalKind; + approvalSnapshot?: ApprovalSnapshot; + }) => void; +}) => DeployRouter; + +export type SidecarOrchestratorConfig = { + hubURL: string; + sidecarId: string; + token: string; + dataDir: string; + transport: HubTransport; + cryptoOps: SidecarCryptoOps; + /** + * Host-injected `DeployRouter` factory. The orchestrator calls it + * once after `sessions` and `keyStore` are constructed; the + * returned router routes every `agent.deploy` frame on the link. + */ + createDeployRouter: CreateDeployRouter; + /** + * Optional pre-fallback mail dispatcher the link consults on every + * inbound `mail.inbound` frame. Production wires this against the + * sidecar's multi-step deployment mail handler registry so a + * deployment-address inbound flows into the supervisor's mail-bus + * subscription instead of the legacy session path. The orchestrator + * forwards the binding unchanged to `createHubLink`. + */ + mailInboundRouter?: MailInboundRouter; + /** + * Optional pre-fallback signal dispatcher the link consults on every + * inbound `signal.deliver` frame. Production wires this against the + * sidecar's multi-step deployment signal handler registry so a + * deployment-address signal flows into the supervisor's + * `deliverSignal`. The orchestrator forwards the binding unchanged + * to `createHubLink`. + */ + signalInboundRouter?: SignalInboundRouter; + /** + * Optional pre-fallback drain dispatcher the link consults on every + * inbound `drain.deliver` frame. Production wires this against the + * sidecar's multi-step deployment drain handler registry so a + * deployment-address drain flows into the supervisor's `drain`. The + * orchestrator forwards the binding unchanged to `createHubLink`. + */ + drainInboundRouter?: DrainInboundRouter; + /** + * Optional inbound grants dispatcher the link consults on every inbound + * `run.grants` frame. Production wires this against the sidecar's + * multi-step deployment grants handler registry so a deployment-address + * grants frame flows into the deployment's wiring, which writes the + * run's grants to its `workflow-run` repo. The orchestrator forwards + * the binding unchanged to `createHubLink`. + */ + grantsInboundRouter?: GrantsInboundRouter; + /** + * Optional inbound sources-rotation dispatcher the link consults on + * every inbound `sources.update` frame. Production wires this against + * the sidecar's single-step deployment sources handler registry so a + * deployment-address rotation flows into the supervisor's + * `deliverSources`. The orchestrator forwards the binding unchanged to + * `createHubLink`. + */ + sourcesInboundRouter?: SourcesInboundRouter; + /** Apply Hub-authoritative workflow-run refs before replacement deploy. */ + applyWorkflowRunPack: WorkflowRunPackApplier; + /** + * Optional inbound credential-delivery dispatcher the link consults on every + * inbound `credentials.update` frame. Production wires this against the + * sidecar's per-deployment credential handler registry so a delivery flows + * into the supervisor's `deliverCredentials`. The orchestrator forwards the + * binding unchanged to `createHubLink`. + */ + credentialsInboundRouter?: CredentialsInboundRouter; + /** + * Optional workflow-probe executor. The orchestrator forwards it + * unchanged to `createHubLink`, where it answers every inbound + * `workflow.probe.request`. Production wires the sidecar host's + * airlocked executor here; omitted, the link falls back to its rejecting + * placeholder so a probe is answered with an error rather than dropped. + */ + workflowProbeExecutor?: WorkflowProbeExecutor; + /** + * Returns the workflow-substrate deployment addresses this sidecar + * currently hosts. Forwarded to the hub link, which announces them on + * every (re)connect so the hub re-registers them for routing without a + * challenge. Production wires this to the deploy router's + * `activeAddresses`; omitted, the link announces none. + */ + getWorkflowAddresses?: () => string[]; + /** + * Invoked with the workflow-substrate addresses the link just answered a + * reconnect challenge for. Forwarded to the hub link, which fires it once + * per challenge so the workflow-run pack pusher can re-drive a push a + * disconnect cancelled -- gated on the address becoming routable again. + * Production wires this to the boot-edge pack-pushing store's + * "address routable" notifier; omitted, the link fires nothing. + */ + onWorkflowAddressesRoutable?: (addresses: string[]) => void; + /** + * Invoked on WS disconnect with the workflow-substrate addresses the link + * hosts, so the workflow-run pack pusher blocks their pushes until the + * reconnect challenge re-routes them. Paired with + * `onWorkflowAddressesRoutable`. Production wires this to the boot-edge + * pack-pushing store's block notifier; omitted, the link fires nothing. + */ + onWorkflowAddressesUnroutable?: (addresses: string[]) => void; + pingIntervalMs?: number; + reconnectDelayMs?: number; + scheduleReconnect?: ReconnectScheduler; +}; + +export type SidecarOrchestrator = { + /** Open the hub connection and put the runtime into service. */ + start(): void; + /** Tear the runtime down: close the hub connection. */ + close(): void; + /** The store handles, for callers that need to inspect them. */ + readonly repoStore: AgentRepoStore; + readonly keyStore: AgentKeyStore; + readonly sessions: SessionManager; + readonly hubLink: HubLink; +}; + +export function createSidecarOrchestrator( + config: SidecarOrchestratorConfig, +): SidecarOrchestrator { + const { + hubURL, + sidecarId, + token, + dataDir, + transport, + cryptoOps, + createDeployRouter, + mailInboundRouter, + signalInboundRouter, + drainInboundRouter, + grantsInboundRouter, + sourcesInboundRouter, + credentialsInboundRouter, + applyWorkflowRunPack, + workflowProbeExecutor, + getWorkflowAddresses, + onWorkflowAddressesRoutable, + onWorkflowAddressesUnroutable, + pingIntervalMs, + reconnectDelayMs, + scheduleReconnect, + } = config; + + const repoStore = createAgentRepoStore({ dataDir }); + const keyStore = createAgentKeyStore({ + dataDir, + generateKeyPair: cryptoOps.generateKeyPair, + signEd25519: cryptoOps.signEd25519, + verifySSHSig: cryptoOps.verifySSHSig, + }); + + // Sink the multi-step deploy path routes a spawned child's verified + // InferenceEvents through. It points at a no-op until HubLink is + // constructed below, at which point it is swapped to the link's + // sendEvent method. + let dispatchEvent: ( + agentAddress: string, + sessionId: string, + event: InferenceEvent, + ) => void = () => { + /* replaced after HubLink construction */ + }; + + // Sink the multi-step deploy path routes a supervisor's `park.notify` + // suspension registration through. Points at a no-op until HubLink is + // constructed below, at which point it is swapped to the link's + // sendSignalCorrelationRegister method. + let dispatchSuspension: (registration: { + correlationId: string; + runId: string; + anchorRunId: string; + agentAddress: string; + kind: SignalKind; + approvalSnapshot?: ApprovalSnapshot; + }) => void = () => { + /* replaced after HubLink construction */ + }; + + const sessions = createSessionManager({ repoStore }); + + const deployRouter = createDeployRouter({ + sessions, + keyStore, + // Route a spawned child's verified InferenceEvents up the same + // hub-link `agent.event` sink the in-process path uses, so step + // agent events reach the hub timeline keyed to the deploy's + // session. `dispatchEvent` is a no-op until HubLink is constructed + // below; the closure reads it lazily so the post-construction swap + // is observed. A sessionless event is dropped rather than guessed + // onto an arbitrary session -- the hub timeline is session-keyed and + // a forged session id would mis-route the event. + publishWorkflowInferenceEvent: (agentAddress, event, sessionId) => { + if (sessionId === undefined) { + log.warn( + "Dropping workflow inference event for {agentAddress}: deploy carried no sessionId", + { agentAddress }, + ); + return; + } + dispatchEvent(agentAddress, sessionId, event); + }, + // Route a supervisor's suspension registration up the hub-link so the + // hub co-writes the parked run's routing + approval rows. + // `dispatchSuspension` is a no-op until HubLink is constructed below; the + // closure reads it lazily so the post-construction swap is observed. + publishWorkflowSuspension: (registration) => { + dispatchSuspension(registration); + }, + }); + + const hubLink = createHubLink({ + hubURL, + sidecarId, + token, + transport, + sessions, + keyStore, + deployRouter, + applyWorkflowRunPack, + ...(mailInboundRouter !== undefined ? { mailInboundRouter } : {}), + ...(signalInboundRouter !== undefined ? { signalInboundRouter } : {}), + ...(drainInboundRouter !== undefined ? { drainInboundRouter } : {}), + ...(grantsInboundRouter !== undefined ? { grantsInboundRouter } : {}), + ...(sourcesInboundRouter !== undefined ? { sourcesInboundRouter } : {}), + ...(credentialsInboundRouter !== undefined + ? { credentialsInboundRouter } + : {}), + ...(workflowProbeExecutor !== undefined ? { workflowProbeExecutor } : {}), + ...(getWorkflowAddresses !== undefined ? { getWorkflowAddresses } : {}), + ...(onWorkflowAddressesRoutable !== undefined + ? { onWorkflowAddressesRoutable } + : {}), + ...(onWorkflowAddressesUnroutable !== undefined + ? { onWorkflowAddressesUnroutable } + : {}), + ...(pingIntervalMs !== undefined ? { pingIntervalMs } : {}), + ...(reconnectDelayMs !== undefined ? { reconnectDelayMs } : {}), + ...(scheduleReconnect !== undefined ? { scheduleReconnect } : {}), + }); + + dispatchEvent = hubLink.sendEvent; + dispatchSuspension = hubLink.sendSignalCorrelationRegister; + + function start(): void { + hubLink.connect(); + log.info("Sidecar {sidecarId} connecting to {hubURL}", { + sidecarId, + hubURL, + }); + } + + function close(): void { + hubLink.close(); + } + + return { start, close, repoStore, keyStore, sessions, hubLink }; +} diff --git a/vendor/intx/hub-agent/src/ws/hub-link.ts b/vendor/intx/hub-agent/src/ws/hub-link.ts new file mode 100644 index 000000000..def792e1d --- /dev/null +++ b/vendor/intx/hub-agent/src/ws/hub-link.ts @@ -0,0 +1,1800 @@ +// HubLink: the sidecar-side WebSocket protocol. +// +// Connects to the hub, sends the register frame, forwards outbound +// mail and inference events, and handles inbound agent lifecycle +// commands. Per-agent key material lives on AgentKeyStore; +// the link calls into the store for challenge signing, deploy-commit +// verification, and hub-key bookkeeping. The wire layer itself never +// touches raw key bytes. + +import { getLogger } from "@intx/log"; +import type { HubTransport } from "@intx/mail-memory"; +import { type } from "arktype"; +import { + HubFrame, + type SidecarFrame, + type RegisterFrame, + type ReconnectFrame, + type AgentDeployFrame, + type AgentErrorFrame, + type SessionErrorFrame, + type AgentUndeployFrame, + type ChallengeFrame, + type ChallengeFailedFrame, + type PackPushFrame, + type PackDoneFrame, + type PackAckFrame, + type PackRejectFrame, + type PackRejectReason, + RepoId, + type SignalDeliverFrame, + type RunGrantsFrame, + type SignalCorrelationRegisterFrame, + type SignalCorrelationRegisterAckFrame, + type DrainDeliverFrame, + type SourcesUpdateFrame, + type CredentialsUpdateFrame, + type SyncRequestFrame, + type WorkflowProbeRequestFrame, + type WorkflowProbeResultFrame, +} from "@intx/types/sidecar"; +import type { SignalKind } from "@intx/types"; +import { createPackReceiver, createPackSender } from "@intx/pack-transport"; +import { + createRegisterAcker, + DEFAULT_REGISTER_ACK_MAX_ATTEMPTS, + DEFAULT_REGISTER_ACK_TIMEOUT_MS, +} from "./register-acker"; +import { base64Decode, base64Encode, hexDecode, hexEncode } from "@intx/types"; +import type { ApprovalSnapshot, InferenceEvent } from "@intx/types/runtime"; + +import type { AgentKeyStore } from "../agent-key-store"; +import type { SessionManager } from "../session-manager"; + +/** + * Sink the link exposes for forwarding a spawned child's verified + * InferenceEvents to the hub timeline, keyed by the deploy's session id. + */ +export type SessionEventSink = ( + agentAddress: string, + sessionId: string, + event: InferenceEvent, +) => void; + +const logger = getLogger(["interchange", "hub-agent", "ws"]); + +/** + * Permissive envelope over a raw inbound frame that failed `HubFrame` + * validation. A malformed request/ack frame usually still carries an + * intact discriminator and correlation key -- the malformation is in a + * nested field -- so these top-level fields can be recovered to answer the + * requester. + */ +const MalformedRequestEnvelope = type({ + "type?": "string", + "requestId?": "string", + "agentAddress?": "string", + "transferId?": "string", + // `repoId` is carried as `unknown` and validated only inside the pack + // branch below. Validating it here would fail the whole envelope for a + // non-pack frame that happens to carry a malformed `repoId`-shaped field, + // sinking its recovery through its own correlation key. + "repoId?": "unknown", +}); + +/** + * Inbound request/ack frames the sidecar dispatches that the hub + * correlates by `requestId`, whose failure reply is a `session.error`. + * `sources.update` and `credentials.update` qualify -- both are answered with a + * `session.error`. Frames answered through the other correlation keys live in + * `AGENT_ERROR_REQUEST_TYPES` and `PACK_REJECT_REQUEST_TYPES`; a request-shaped + * frame in none of the three sets has no requester to answer and is dropped. + */ +const SESSION_ERROR_REQUEST_TYPES: ReadonlySet = new Set([ + "sources.update", + "credentials.update", +]); + +/** + * Inbound request/ack frames the hub correlates by `agentAddress` and + * whose failure reply is an `agent.error` -- the frames the hub tracks in + * its per-address pending-deploy / pending-undeploy maps. + */ +const AGENT_ERROR_REQUEST_TYPES: ReadonlySet = new Set([ + "agent.deploy", + "agent.undeploy", +]); + +/** + * Inbound chunked-pack request frames the hub correlates by `transferId` + * and whose failure reply is a `repo.pack.reject`. The hub tracks these in + * its per-transfer pending map with the longest timeout of any request + * frame. + */ +const PACK_REJECT_REQUEST_TYPES: ReadonlySet = new Set([ + "repo.pack.push", + "repo.pack.done", +]); + +/** + * Answer a malformed inbound request/ack control frame with an error reply + * so the hub's request does not hang to its timeout. Two control-frame + * families answer through their correlation key: the `requestId`-correlated + * frame (sources.update) replies `session.error`; the + * `agentAddress`-correlated frames (agent.deploy, agent.undeploy) reply + * `agent.error`. The fire-and-forget frames + * (mail/signal/drain/...) have no requester waiting on a reply, so a + * malformed one is correctly left to be logged and dropped by the caller. + * + * The chunked `repo.pack` streaming transfers (repo.pack.push, + * repo.pack.done) are the third family: correlated by `transferId`, + * rejected by `repo.pack.reject`. A valid reject also carries the frame's + * `agentAddress` and structured `repoId`, so it is answerable only when + * all three survive the malformation; when `repoId` (or the transferId) is + * itself unrecoverable the frame is left to be logged and dropped, because + * a valid `repo.pack.reject` cannot be constructed without them. + * + * Returns `true` when it answered; `false` when no correlation key is + * recoverable (an unknown/absent type, a fire-and-forget frame, or a + * request/ack frame whose key is itself missing) -- the caller then logs + * and drops, because there is nothing to answer. + */ +/** + * Classify an `applyAssetPack` failure message into a `repo.pack.reject` reason. + * A structural rejection -- a symlink or submodule the checkout cannot reproduce + * faithfully, or a mountPath that escapes -- is a `path_violation`, distinct from + * `corrupt` (bad or incomplete bytes). The match is on the messages + * `writeTreeToDisk` and the mountPath guard raise; a wording drift only reverts + * the reason to `corrupt`, never misclassifies bytes as a path issue. The raw + * message rides on the frame's `detail` regardless, so the operator always sees + * the specific cause. + */ +export function classifyAssetPackRejectReason(msg: string): PackRejectReason { + if (msg.startsWith("sha_mismatch")) return "sha_mismatch"; + if ( + msg.startsWith("signature_invalid") || + msg.startsWith("signature_unsigned") + ) { + return "signature_invalid"; + } + if (/symlink at |submodule reference at |mountPath|escaping path/.test(msg)) { + return "path_violation"; + } + return "corrupt"; +} + +export function answerMalformedRequestFrame( + raw: unknown, + summary: string, + send: (frame: SessionErrorFrame | AgentErrorFrame | PackRejectFrame) => void, +): boolean { + const envelope = MalformedRequestEnvelope(raw); + if (envelope instanceof type.errors) return false; + const frameType = envelope.type; + if (frameType === undefined) return false; + if ( + SESSION_ERROR_REQUEST_TYPES.has(frameType) && + envelope.requestId !== undefined && + envelope.requestId.length > 0 + ) { + send({ + type: "session.error", + requestId: envelope.requestId, + error: `malformed ${frameType} frame: ${summary}`, + }); + return true; + } + if ( + AGENT_ERROR_REQUEST_TYPES.has(frameType) && + envelope.agentAddress !== undefined && + envelope.agentAddress.length > 0 + ) { + send({ + type: "agent.error", + agentAddress: envelope.agentAddress, + error: `malformed ${frameType} frame: ${summary}`, + }); + return true; + } + if ( + PACK_REJECT_REQUEST_TYPES.has(frameType) && + envelope.transferId !== undefined && + envelope.transferId.length > 0 && + envelope.agentAddress !== undefined && + envelope.agentAddress.length > 0 + ) { + // A valid repo.pack.reject carries the frame's structured repoId, so + // recover it here (kept out of the shared envelope to protect the other + // families). When the repoId is itself malformed there is no valid + // reject to build, so the frame is left to be dropped. The hub + // correlates the reject by transferId alone; "corrupt" is the reason + // for a frame that failed to parse. + const repoId = RepoId(envelope.repoId); + if (repoId instanceof type.errors) return false; + send({ + type: "repo.pack.reject", + agentAddress: envelope.agentAddress, + repoId, + transferId: envelope.transferId, + reason: "corrupt", + }); + return true; + } + return false; +} + +const DEFAULT_PING_INTERVAL_MS = 30_000; +const DEFAULT_RECONNECT_DELAY_MS = 3_000; + +/** + * The reason string `packSender.cancelAll` rejects an in-flight transfer with + * when the link cycles on the reconnect `open` handler. A push that fails with + * this is a dropped connection, not a receiver-side rejection, so the + * workflow-run push path must not fast-retry it (see `runWithBootstrap`); the + * pushing store's post-challenge re-drive owns reconnect recovery. + */ +const CONNECTION_LOST_REASON = "Connection lost"; + +function isConnectionLost(err: unknown): boolean { + return err instanceof Error && err.message === CONNECTION_LOST_REASON; +} + +/** + * Schedules a deferred callback and returns a cancel function. Injection + * point for tests: a fake scheduler records the callback so the test + * can observe whether cancellation actually happened, without relying + * on wall-clock waits. + */ +export type ReconnectScheduler = ( + callback: () => void, + delayMs: number, +) => () => void; + +const defaultScheduleReconnect: ReconnectScheduler = (callback, delayMs) => { + const handle = setTimeout(callback, delayMs); + return () => { + clearTimeout(handle); + }; +}; + +/** + * Result the deploy router returns to the link once a deploy has + * staged. Carries the values the link folds into the outbound + * `agent.deploy.ack` frame; the link itself stays out of the deploy + * details. + */ +export type DeployRouterResult = { + /** Hex-encoded agent public key the hub records for verification. */ + publicKey: string; +}; + +/** + * Single-ingress deploy contract the link routes every `agent.deploy` + * frame through. The sidecar's workflow-run deploy router is the + * production implementation -- it stages every deploy through the + * workflow-run substrate. The shape lives on hub-agent so the package + * boundary stays one-way (`@intx/hub-agent` does not import + * `@intx/workflow-host`). + */ +export interface DeployRouter { + deploy(frame: AgentDeployFrame): Promise; + /** + * Symmetric teardown for `deploy`. The link invokes this when an + * `agent.undeploy` frame lands so the router can release any + * per-deployment registrations the deploy path installed + * (`MultistepMailRouter`, `MultistepSignalRouter`, + * `MultistepDrainRouter`, `DeploymentAddressRegistry`). Optional + * so test routers can omit the implementation. + */ + undeploy?: (frame: AgentUndeployFrame) => Promise; +} + +/** + * Per-address mail handler registry the link consults on every + * `mail.inbound` frame. Production wires this against the sidecar's + * `createMultistepMailRouter` so a supervised deployment's supervisor + * receives the bytes through its mail-bus subscription. Mail for an + * address with no registered handler has no receiver and is dropped. + * The shape lives on hub-agent so the link does not import the sidecar + * host's wiring module, and so tests can substitute a stub. + */ +export interface MailInboundRouter { + /** + * Attempt to dispatch `message` to a handler registered against + * `agentAddress`. Returns `null` if no handler is registered, in which + * case the link logs and drops the mail (and sends no ack). Otherwise + * returns the handler's durable settlement: a promise that resolves once + * the message is durably accepted (its inbox write landed, or it was + * already durably present) and rejects when it was not (a transient + * failure, a stale refusal, or a tearing-down phase). The link sends a + * `mail.inbound.ack` only on resolution, so resolve is the ack signal and + * reject is the withhold signal. + */ + tryRoute(agentAddress: string, message: Uint8Array): Promise | null; +} + +/** + * Per-deployment-address signal handler registry the link consults on + * every inbound `signal.deliver` frame. Production wires this against + * the sidecar's multi-step deploy registry so the frame flows into the + * deployment's supervisor (which forwards `signal.deliver` over the + * control IPC to the workflow-process child). The link logs and drops + * a frame whose `agentAddress` matches no registered handler so the + * wire surface fails loudly rather than silently absorbing a misrouted + * delivery. + * + * The shape lives on hub-agent so the link does not import the sidecar + * host's wiring module, and so tests can substitute a stub. + */ +export interface SignalInboundRouter { + /** + * Attempt to dispatch `frame` to the supervisor registered against + * `frame.agentAddress`. Returns a promise that resolves to `true` + * when a handler accepted the frame, `false` when no handler is + * registered; the promise rejects when the handler is registered but + * the supervisor's `deliverSignal` itself throws. The link surfaces + * a rejection through a logged warning -- a structured failure-reply + * frame for signals does not exist on the wire today. + */ + tryRoute(frame: SignalDeliverFrame): Promise; +} + +/** + * Per-deployment-address drain handler registry the link consults on + * every inbound `drain.deliver` frame. Production wires this against + * the sidecar's multi-step deploy registry so the frame flows into the + * deployment's supervisor (which forwards a `drain` control IPC frame + * to the workflow-process child and arms one drainTimeout accumulator + * per in-flight run). The link logs and drops a frame whose + * `agentAddress` matches no registered handler so the wire surface + * fails loudly rather than silently absorbing a misrouted delivery. + * + * The shape lives on hub-agent so the link does not import the sidecar + * host's wiring module, and so tests can substitute a stub. + */ +export interface DrainInboundRouter { + /** + * Attempt to dispatch `frame` to the supervisor registered against + * `frame.agentAddress`. Returns a promise that resolves to `true` + * when a handler accepted the frame, `false` when no handler is + * registered; the promise rejects when the handler is registered but + * the supervisor's `drain` itself throws. The link surfaces a + * rejection through a logged warning -- a structured failure-reply + * frame for drain does not exist on the wire today. + */ + tryRoute(frame: DrainDeliverFrame): Promise; +} + +/** + * Per-deployment-address grants registry the link consults on every + * inbound `run.grants` frame. Production wires this against the sidecar's + * multi-step deploy registry so the frame flows into the deployment's + * wiring, which writes the run's grants to its `workflow-run` repo. The + * link logs and drops a frame whose `agentAddress` matches no registered + * handler so the wire surface fails loudly rather than silently absorbing + * a misrouted delivery. + * + * The shape lives on hub-agent so the link does not import the sidecar + * host's wiring module, and so tests can substitute a stub. + */ +export interface GrantsInboundRouter { + /** + * Attempt to dispatch `frame` to the deployment registered against + * `frame.agentAddress`. Returns a promise that resolves to `true` when + * a handler accepted the frame, `false` when no handler is registered; + * the promise rejects when the handler is registered but the durable + * grants write itself throws. The link surfaces a rejection through a + * logged warning -- a structured failure-reply frame for run grants + * does not exist on the wire today. + */ + tryRoute(frame: RunGrantsFrame): Promise; +} + +/** + * Per-deployment-address sources-rotation registry the link consults on + * every inbound `sources.update` frame. Unlike signal/drain, `sources.update` + * is a REQUEST/ACK frame, so the link answers `session.ack` / `session.error` + * rather than logging and dropping -- a missing answer hangs the hub's + * request for its full timeout. + * + * The shape lives on hub-agent so the link does not import the sidecar + * host's wiring module, and so tests can substitute a stub. + */ +export interface SourcesInboundRouter { + /** + * Attempt to dispatch `frame` to the supervisor registered against + * `frame.agentAddress`. Resolves `true` when a handler accepted the + * rotation, `false` when no handler is registered (an unrouted address). + * Rejects when the handler is registered but the rotation is invalid or + * the supervisor's `deliverSources` throws; the link turns a rejection + * into a `session.error` carrying the reason. + */ + tryRoute(frame: SourcesUpdateFrame): Promise; +} + +/** + * Per-deployment-address credential-delivery registry the link consults on + * every inbound `credentials.update` frame. Like `sources.update`, this is a + * REQUEST/ACK frame, so the link answers `session.ack` / `session.error` + * rather than logging and dropping -- a missing answer hangs the hub's request. + * + * The shape lives on hub-agent so the link does not import the sidecar host's + * wiring module, and so tests can substitute a stub. + */ +export interface CredentialsInboundRouter { + /** + * Attempt to dispatch `frame` to the supervisor registered against + * `frame.agentAddress`. Resolves `true` when a handler accepted the + * delivery, `false` when no handler is registered. Rejects when the handler + * is registered but the delivery is invalid or the supervisor's + * `deliverCredentials` throws; the link turns a rejection into a + * `session.error` carrying the reason. + */ + tryRoute(frame: CredentialsUpdateFrame): Promise; +} + +/** + * Applies one Hub-authoritative workflow-run ref before a replacement + * supervisor is allowed to spawn. The host owns the workflow substrate, so + * the websocket layer validates and assembles the transfer but delegates the + * actual ref update through this boundary. + */ +export type WorkflowRunPackApplier = (args: { + agentAddress: string; + repoId: RepoId; + pack: Uint8Array; + ref: string; + commitSha: string; +}) => Promise; + +/** + * The inert answer a probe execution produces, lifted off the + * `workflow.probe.result` frame: the workflow's needs-surface projection, the + * inert grant set derived from it, the un-flattened grant walk snapshot the set + * is derived from, and the projection's content hash. + */ +export type WorkflowProbeResult = Pick< + WorkflowProbeResultFrame, + "projection" | "grants" | "grantWalkSnapshot" | "wireHash" +>; + +/** + * Seam the link routes every inbound `workflow.probe.request` through. + * Production wiring supplies an executor that materializes the frame's frozen + * dependency closure, evaluates the `interchange.workflow` entry module to a + * live `WorkflowDefinition` in a one-shot child, projects it to its inert + * needs surface, and returns that projection plus the derived grant set and + * content hash. `probe` throws when any step fails; the link turns a throw + * into a `workflow.probe.error` reply so the hub's probe never hangs. + * + * The shape lives on hub-agent so the link does not import the sidecar host's + * probe wiring, and so tests can substitute a stub. + */ +export interface WorkflowProbeExecutor { + probe(frame: WorkflowProbeRequestFrame): Promise; +} + +/** + * Placeholder probe executor wired when no real one is supplied. It rejects so + * the link answers `workflow.probe.error` -- never a silent drop -- until the + * sidecar host wires an executor that runs the child evaluation. + */ +const defaultWorkflowProbeExecutor: WorkflowProbeExecutor = { + probe() { + return Promise.reject( + new Error("workflow probe execution is not implemented on this sidecar"), + ); + }, +}; + +export type HubLinkConfig = { + hubURL: string; + sidecarId: string; + token: string; + transport: HubTransport; + sessions: SessionManager; + /** + * Key custody and per-frame crypto. HubLink calls into the store for + * challenge signing, deploy-commit verification, hub-key recording, + * and per-agent forgetting; it does not maintain its own copy of + * those tables. + */ + keyStore: AgentKeyStore; + /** + * Routes every inbound `agent.deploy` frame. Production wiring + * supplies a router that stages each deploy through the workflow-run + * substrate: a provision-step frame primes a per-step repo, and a + * workflow frame spawns the supervised workflow-process child. The + * router owns the routing decision; the link does not re-decide. + */ + deployRouter: DeployRouter; + /** + * Optional inbound mail dispatcher. When present, the link consults + * this router on every inbound `mail.inbound` frame. Production wires + * this against the sidecar's multi-step deploy registry so a + * deployment-address inbound flows into the supervisor's mail-bus + * subscription. Absent (or a `false` return) means no handler claims + * the mail, so the link logs and drops it. + */ + mailInboundRouter?: MailInboundRouter; + /** + * Optional inbound signal dispatcher. When present, the link routes + * every inbound `signal.deliver` frame through this router. Production + * wires this against the sidecar's multi-step deploy registry so a + * deployment-address signal flows into the supervisor's + * `deliverSignal`. Absent (or a `false` return) causes inbound signal + * frames to be logged-and-dropped so a misrouted delivery is + * observable rather than silent. + */ + signalInboundRouter?: SignalInboundRouter; + /** + * Optional inbound drain dispatcher. When present, the link routes + * every inbound `drain.deliver` frame through this router. Production + * wires this against the sidecar's multi-step deploy registry so a + * deployment-address drain flows into the supervisor's `drain`. Absent + * (or a `false` return) causes inbound drain frames to be + * logged-and-dropped so a misrouted delivery is observable rather than + * silent. + */ + drainInboundRouter?: DrainInboundRouter; + /** + * Optional inbound grants dispatcher. When present, the link routes + * every inbound `run.grants` frame through this router. Production wires + * this against the sidecar's multi-step deploy registry so a + * deployment-address grants frame flows into the deployment's wiring, + * which writes the run's grants to its `workflow-run` repo. Absent (or a + * `false` return) causes inbound grants frames to be logged-and-dropped + * so a misrouted delivery is observable rather than silent. + */ + grantsInboundRouter?: GrantsInboundRouter; + /** + * Optional inbound sources-rotation dispatcher. When present, the link + * routes every inbound `sources.update` frame through this router and + * answers the request/ack frame: `session.ack` when the router accepted + * the rotation, `session.error` when no deployment is registered, when + * the rotation is invalid, or when delivery throws. Absent means the + * link answers `session.error` for every rotation -- required because a + * request/ack frame with no reply hangs the hub's request. + */ + sourcesInboundRouter?: SourcesInboundRouter; + /** + * Optional inbound credential-delivery dispatcher. When present, the link + * routes every inbound `credentials.update` frame through this router and + * answers the request/ack frame: `session.ack` when the router accepted the + * delivery, `session.error` when no deployment is registered, when the + * delivery is invalid, or when delivery throws. Absent means the link answers + * `session.error` for every delivery -- required because a request/ack frame + * with no reply hangs the hub's request. + */ + credentialsInboundRouter?: CredentialsInboundRouter; + /** + * Restore boundary for Hub→sidecar workflow-run packs. Optional for hosts + * that never accept exclusive workflow allocations; receiving such a pack + * without an applier fails closed with `repo.pack.reject`. + */ + applyWorkflowRunPack?: WorkflowRunPackApplier; + /** + * Optional workflow-probe executor. When present, the link routes every + * inbound `workflow.probe.request` frame through it and answers the + * request/response frame: `workflow.probe.result` when the executor returns + * an inert projection + grant set + hash, `workflow.probe.error` when it + * throws. Absent, the link wires a placeholder executor that always throws, + * so a probe still gets answered with an error (never dropped) until the + * sidecar host supplies a real executor -- required because a + * request/response frame with no reply hangs the hub's probe. + */ + workflowProbeExecutor?: WorkflowProbeExecutor; + /** + * Returns the workflow-substrate deployment addresses this sidecar + * currently hosts a live supervisor for. Called on every (re)connect to + * announce them to the hub for routing through the CHALLENGED reconnect + * frame: each deployment carries its own Ed25519 key (minted at deploy, + * acked to the hub), so it proves ownership via challenge/response exactly + * like a launched agent -- there is no keyless routing shortcut. Without + * this announcement the hub drops the deployment's route on a WS reconnect. + * Defaults to none when omitted (tests / deployments with no workflow + * substrate). + */ + getWorkflowAddresses?: () => string[]; + /** + * Invoked once per reconnect ownership challenge with the addresses this + * link just signed a `challenge.response` for. Signing the response is the + * sidecar-local proxy for "the hub is about to (re)route these addresses": + * the hub adds a verified address to its routing index in + * `handleChallengeResponse`, and because both `challenge.response` and the + * subsequent `repo.pack.push`/`done` frames are QUEUE-class on the hub's + * per-connection chain, the hub processes the response (routing the + * address) BEFORE it sees any pack re-shipped in reaction to this callback. + * The workflow-run pack pusher subscribes so it can re-drive a push that a + * disconnect cancelled -- gated on this signal so the re-ship cannot race + * ahead of the address becoming routable again. Absent when omitted (tests + * / deployments with no workflow-run pack pipeline). + */ + onWorkflowAddressesRoutable?: (addresses: string[]) => void; + /** + * Invoked on WS disconnect with the workflow-substrate addresses this link + * hosts (`getWorkflowAddresses()`). Their hub route is gone until the next + * reconnect challenge re-proves ownership, so the workflow-run pack pusher + * blocks their pushes in the interim -- a push shipped on the fresh, + * not-yet-challenged connection is dropped by the hub as "unrouted". Paired + * with `onWorkflowAddressesRoutable`, which lifts the block once the + * challenge passes. Absent when omitted (tests / deployments with no + * workflow-run pack pipeline). + */ + onWorkflowAddressesUnroutable?: (addresses: string[]) => void; + pingIntervalMs?: number; + reconnectDelayMs?: number; + /** Per-attempt watchdog before re-sending an unacked correlation register. */ + registerAckTimeoutMs?: number; + /** Total register sends (initial + retries) before giving up. */ + registerAckMaxAttempts?: number; + scheduleReconnect?: ReconnectScheduler; +}; + +export type HubLink = { + /** + * Open the connection. Must not be called after `close()`; calling it + * on a closed client throws. + */ + connect(): void; + close(): void; + sendEvent: SessionEventSink; + /** + * Register a control-plane suspension with the hub. Sends a + * `signal.correlation.register` frame so the hub co-writes the parked run's + * routing + approval rows. Fired by the sidecar's supervisor when a workflow + * agent step parks on a reserved correlation channel; the fields converge at + * this seam (`correlationId`/`runId`/`kind` from the child, `anchorRunId`/ + * `agentAddress` stamped by the supervisor). Mirrors `sendEvent`: a + * fire-and-forget hub-bound send that queues while disconnected. + */ + sendSignalCorrelationRegister: (registration: { + correlationId: string; + runId: string; + anchorRunId: string; + agentAddress: string; + kind: SignalKind; + approvalSnapshot?: ApprovalSnapshot; + }) => void; + /** + * Ship a workflow-run pack to the hub. Streams the supplied pack as + * `repo.pack.push` chunks followed by a `repo.pack.done`, then + * resolves on the matching `repo.pack.ack` (rejects on + * `repo.pack.reject` with the carried reason). The hub routes the + * pack to its `workflow-run` receiver because `repoId.kind` is + * `"workflow-run"`. + */ + pushWorkflowRunPack: (opts: { + agentAddress: string; + repoId: RepoId; + pack: Uint8Array; + ref: string; + commitSha: string; + }) => Promise; +}; + +export function createHubLink(config: HubLinkConfig): HubLink { + const { + hubURL, + sidecarId, + token, + transport, + sessions, + keyStore, + deployRouter, + mailInboundRouter, + signalInboundRouter, + drainInboundRouter, + grantsInboundRouter, + sourcesInboundRouter, + credentialsInboundRouter, + applyWorkflowRunPack, + workflowProbeExecutor = defaultWorkflowProbeExecutor, + getWorkflowAddresses = () => [], + onWorkflowAddressesRoutable, + onWorkflowAddressesUnroutable, + pingIntervalMs = DEFAULT_PING_INTERVAL_MS, + reconnectDelayMs = DEFAULT_RECONNECT_DELAY_MS, + registerAckTimeoutMs = DEFAULT_REGISTER_ACK_TIMEOUT_MS, + registerAckMaxAttempts = DEFAULT_REGISTER_ACK_MAX_ATTEMPTS, + scheduleReconnect = defaultScheduleReconnect, + } = config; + + let ws: WebSocket | null = null; + let closed = false; + let pingTimer: ReturnType | null = null; + let cancelReconnect: (() => void) | null = null; + let lastPongAt = 0; + // An OPEN socket is not application-ready until its one authoritative + // register/reconnect frame is on the wire. Async deploy-ref reads can keep + // that handshake pending briefly, so ordinary outbound traffic remains in + // the existing bounded queue until the handshake has been sent. + let handshakePending = true; + + const packReceiver = createPackReceiver(); + // One sender owns the agent-state push path (`handleSyncRequest`, + // `handleAgentUndeploy`) and the workflow-run push path + // (`pushWorkflowRunPack`). transferIds for the two flows live in + // disjoint namespaces (`undeploy-*` / sync-supplied / `workflow-run-*`), + // so a single pending-id map is unambiguous; the protocol logic + // (chunking, ack-handshake) lives once in `@intx/pack-transport`. + const packSender = createPackSender({ sendFrame: (frame) => send(frame) }); + + // Retry `signal.correlation.register` until the hub acks it. A register is + // fire-and-forget on the wire and can be lost on an open socket or evicted + // from the bounded queue below; the acker re-sends on a tight watchdog while + // the link is open and gives up on disconnect, leaving the reconnect re-emit + // as the backstop. `isOpen` tracks the transport lifetime; `send` separately + // holds retries in the queue until the initial handshake is on the wire. + const registerAcker = createRegisterAcker({ + sendFrame: (frame) => send(frame), + isOpen: () => ws !== null && ws.readyState === WebSocket.OPEN, + timeoutMs: registerAckTimeoutMs, + maxAttempts: registerAckMaxAttempts, + }); + + // Serialize frame processing so async handlers (deploy, undeploy, abort) + // cannot race against each other. + let messageQueue: Promise = Promise.resolve(); + + // Outbound frames queued while disconnected. + const MAX_QUEUE = 1024; + const queue: SidecarFrame[] = []; + + function send(frame: SidecarFrame): void { + if ( + ws !== null && + ws.readyState === WebSocket.OPEN && + (!handshakePending || frame.type === "ping") + ) { + ws.send(JSON.stringify(frame)); + return; + } + if (queue.length >= MAX_QUEUE) { + logger.warn`Outbound queue full, dropping oldest frame`; + queue.shift(); + } + queue.push(frame); + } + + function flush(): void { + while ( + queue.length > 0 && + ws !== null && + ws.readyState === WebSocket.OPEN && + !handshakePending + ) { + ws.send(JSON.stringify(queue.shift())); + } + } + + function sendOnConnection( + connection: WebSocket, + frame: SidecarFrame, + ): boolean { + if (ws !== connection || connection.readyState !== WebSocket.OPEN) { + return false; + } + connection.send(JSON.stringify(frame)); + return true; + } + + /** + * Send the initial handshake only if `connection` is still the active + * socket. Deploy-ref collection is asynchronous; this attempt fence keeps a + * late completion from sending onto (or flushing the queue through) a newer + * reconnect attempt. + */ + function completeHandshake( + connection: WebSocket, + frame: RegisterFrame | ReconnectFrame, + ): void { + if (!sendOnConnection(connection, frame)) return; + handshakePending = false; + flush(); + } + + // Wire the transport's remote send handler to push mail.outbound frames + // for routing. These carry only the raw message and recipients — the hub + // routes them to the destination sidecar. + transport.setRemoteSendHandler(async (rawMessage, recipients) => { + const encoded = base64Encode(rawMessage); + send({ + type: "mail.outbound", + rawMessage: encoded, + recipients, + }); + }); + + // Forward every send to the hub for audit and event emission. Local-only + // sends are marked delivered: true so the hub does not re-route them. + // Remote sends are marked delivered: true as well — routing was already + // handled by the RemoteSendHandler above. + transport.addMessageSentHandler(async (ctx) => { + const encoded = base64Encode(ctx.rawMessage); + const sessionId = sessions.getSessionId(ctx.senderAddress); + send({ + type: "mail.outbound", + rawMessage: encoded, + recipients: ctx.recipients, + senderAddress: ctx.senderAddress, + ...(sessionId !== undefined ? { sessionId } : {}), + messageId: ctx.messageId, + to: ctx.to, + ...(ctx.cc.length > 0 ? { cc: ctx.cc } : {}), + delivered: true, + }); + }); + + async function handleAgentDeploy(frame: AgentDeployFrame): Promise { + try { + // The deploy router (production: the sidecar's workflow-run deploy + // router) stages the deploy through the substrate and returns the + // deploy public key the link folds into the outbound ack. The link + // itself does not re-decide; routing lives on the router side of + // the seam. + const result = await deployRouter.deploy(frame); + send({ + type: "agent.deploy.ack", + agentAddress: frame.agentAddress, + publicKey: result.publicKey, + }); + logger.info`Deployed agent ${frame.agentAddress}`; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + send({ + type: "agent.error", + agentAddress: frame.agentAddress, + error: message, + }); + } + } + + async function handleAgentUndeploy(frame: AgentUndeployFrame): Promise { + let statePushed = false; + + // Release per-deployment routing state the deploy router installed + // for this address (multi-step mail/signal/drain handlers and the + // deployment-address mapping) before the session tears down. With + // the registrations released, any in-flight `signal.deliver` / + // `drain.deliver` / `mail.inbound` frame that lands during teardown + // is rejected by the router rather than dispatched into a + // soon-to-be-orphaned supervisor handler. Test stubs omit the hook; + // an absent hook means there was nothing to release. + if (deployRouter.undeploy !== undefined) { + try { + await deployRouter.undeploy(frame); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.warn`Deploy router undeploy hook failed for ${frame.agentAddress}: ${msg}`; + } + } + + // Prune `workflowRunPackBootstrapped` entries recorded under this + // address so a future workflow-run-repo reset for the same + // `(kind, id, ref)` triple re-runs the bootstrap-retry arm. Without + // the prune the flag survives across the deployment's lifetime, + // grows unbounded over the link's lifetime, and a hub-side rotation + // / disaster-recovery reset surfaces as a `non_fast_forward` on the + // first post-reset push (the link skips the retry on the stale + // flag). + const bootstrapped = workflowRunPackBootstrappedByAddress.get( + frame.agentAddress, + ); + if (bootstrapped !== undefined) { + for (const key of bootstrapped) { + workflowRunPackBootstrapped.delete(key); + } + workflowRunPackBootstrappedByAddress.delete(frame.agentAddress); + } + + // Best-effort state push to the hub before deleting the directory. + // statePushed reflects whether we sent the pack frames, not whether + // the hub acknowledged them. We intentionally skip waiting for + // repo.pack.ack here to avoid blocking the undeploy on a round-trip + // that may never complete if the hub is shutting down -- so the + // pending Promise's rejection on disconnect is intentionally + // swallowed below. + try { + const { pack, commitSha, ref } = await sessions.createStatePack( + frame.agentAddress, + ); + const repoId: RepoId = { + kind: "agent-state", + id: frame.agentAddress, + }; + + void packSender + .send({ + agentAddress: frame.agentAddress, + repoId, + transferId: `undeploy-${frame.agentAddress}`, + pack, + ref, + commitSha, + }) + .catch(() => { + // Intentional: undeploy's pack push is best-effort. See above. + }); + + statePushed = true; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.warn`State push failed for ${frame.agentAddress}: ${msg}`; + } + + // Delete the agent directory. + try { + await sessions.deleteAgentDir(frame.agentAddress); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.warn`Failed to delete agent directory for ${frame.agentAddress}: ${msg}`; + } + + keyStore.forgetAgent(frame.agentAddress); + + send({ + type: "agent.undeploy.ack", + agentAddress: frame.agentAddress, + statePushed, + }); + logger.info`Undeployed agent ${frame.agentAddress}: ${frame.reason}`; + } + + async function handleChallenge( + frame: ChallengeFrame, + connection: WebSocket, + ): Promise { + const responses: { address: string; signature: string }[] = []; + + for (const { address, nonce } of frame.challenges) { + const nonceBytes = hexDecode(nonce); + const addressBytes = new TextEncoder().encode(address); + const payload = new Uint8Array(nonceBytes.length + addressBytes.length); + payload.set(nonceBytes); + payload.set(addressBytes, nonceBytes.length); + + const sig = await keyStore.signChallenge(address, payload); + if (sig === null) { + logger.warn`No key pair for challenged address ${address}`; + continue; + } + + responses.push({ + address, + signature: hexEncode(sig), + }); + } + + // A challenge response belongs only to the socket that received its + // nonce. Signing is asynchronous, so a disconnect can supersede this + // handler before it finishes; never queue that stale response onto the + // next connection, where it could consume the next attempt's challenge. + if ( + !sendOnConnection(connection, { + type: "challenge.response", + responses, + }) + ) { + return; + } + + // Signal the workflow-run pack pusher that these addresses are becoming + // routable again, so it can re-drive a push a disconnect cancelled. Fires + // AFTER the response is sent: the hub routes each verified address before + // it processes any pack the pusher re-ships in reaction (both frame + // families queue on the hub's per-connection chain), so the re-ship + // cannot arrive at the hub ahead of the address's routing write. Only the + // addresses this link actually signed for are announced; an address with + // no key pair was skipped above and stays unrouted, so re-driving its + // push would just re-fail. + if (onWorkflowAddressesRoutable !== undefined && responses.length > 0) { + onWorkflowAddressesRoutable(responses.map((r) => r.address)); + } + } + + async function handleChallengeFailed( + frame: ChallengeFailedFrame, + ): Promise { + // The hub rejected this agent during reconnect -- forget its key + // material so the address is freed for future deploys. + keyStore.forgetAgent(frame.address); + + logger.warn`Challenge failed for ${frame.address}, agent torn down: ${frame.reason}`; + } + + function handlePackPush(frame: PackPushFrame): void { + const reason = packReceiver.handlePush(frame); + if (reason !== null) { + send({ + type: "repo.pack.reject", + agentAddress: frame.agentAddress, + repoId: frame.repoId, + transferId: frame.transferId, + reason, + }); + } + } + + async function handlePackDone(frame: PackDoneFrame): Promise { + const result = packReceiver.handleDone(frame); + if (result === null) { + send({ + type: "repo.pack.reject", + agentAddress: frame.agentAddress, + repoId: frame.repoId, + transferId: frame.transferId, + reason: "corrupt", + }); + return; + } + + try { + if (frame.repoId.kind === "workflow-run") { + if (frame.mountPath !== undefined) { + throw new Error( + "workflow_run_restore_invalid: workflow-run packs cannot carry mountPath", + ); + } + if (applyWorkflowRunPack === undefined) { + throw new Error( + "workflow_run_restore_unconfigured: no workflow-run pack applier is configured", + ); + } + await applyWorkflowRunPack({ + agentAddress: frame.agentAddress, + repoId: frame.repoId, + pack: result.pack, + ref: result.ref, + commitSha: result.commitSha, + }); + } else if (frame.mountPath !== undefined) { + // Asset pack: route to the workspace materializer. Use + // frame.agentAddress for destination routing — frame.repoId.id + // names the source asset at the hub, which is a different + // entity than the destination agent. + await sessions.applyAssetPack( + frame.agentAddress, + frame.mountPath, + result.pack, + result.ref, + result.commitSha, + ); + } else { + const verifyCommit = (payload: string, signature: string) => + keyStore.verifyDeployCommit(frame.agentAddress, payload, signature); + + await sessions.applyDeployPack( + frame.agentAddress, + result.pack, + result.ref, + result.commitSha, + frame.transferId, + verifyCommit, + ); + } + send({ + type: "repo.pack.ack", + agentAddress: frame.agentAddress, + repoId: frame.repoId, + transferId: frame.transferId, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + const reason = classifyAssetPackRejectReason(msg); + logger.warn`Pack apply failed for ${frame.agentAddress}: ${msg}`; + send({ + type: "repo.pack.reject", + agentAddress: frame.agentAddress, + repoId: frame.repoId, + transferId: frame.transferId, + reason, + detail: msg, + }); + } + } + + // Counter the boot edge consumes via `pushWorkflowRunPack` to mint + // collision-free transferIds. Lives on the link so undeploy / + // sync-request / workflow-run all share one monotonically increasing + // sequence space. + let workflowRunPackCounter = 0; + + // Per-(repoId.id, ref) flag tracking whether at least one workflow-run + // pack push has been accepted by the hub, and a per-(repoId.id, ref) + // serialization queue. Both are needed because the hub's + // `receiveWorkflowRunPack` resolves the ref OUTSIDE the substrate's + // per-repo lock, then enters `receivePack` which acquires the lock + // and calls `initRepo` BEFORE the CAS check. + // + // First-push race: + // The hub's `initRepo` creates a `.gitignore` genesis commit on + // `refs/heads/main` inside the lock. `receivePackObjects`'s CAS + // then compares that genesis (now the ref's tip) against the + // caller-supplied `expectedOldSha` (null, because the caller's + // pre-lock `resolveRef` observed an absent repo) and rejects with + // `non_fast_forward`. The hub surfaces the failure as + // `reason: "corrupt"` on the wire. + // + // Concurrent-push race: + // Two pushes arriving close together both run their pre-lock + // `resolveRef` against the same hub state; whichever loses the + // `withRepoLock` race observes a stale `expectedOldSha` and + // rejects with `non_fast_forward`. + // + // We close both windows on the sender side: serialize every push + // per `(repoId, ref)` so the second sender only fires after the + // first has been acked or rejected, and retry the FIRST push once + // to absorb the bootstrap race against the hub's `initRepo` step. + // Re-shipping the same pack against the now-initialized hub repo + // works because the hub's next `resolveRef` returns the genesis + // sha (instead of null) and the CAS passes. The retry is bounded + // to the first push per `(repoId, ref)` so a genuine corruption + // surfaces verbatim once the repo has been bootstrapped. + const workflowRunPackBootstrapped = new Set(); + const workflowRunPackQueues = new Map>(); + // Reverse index: agentAddress -> bootstrap keys recorded under that + // address. `handleAgentUndeploy` consults this to prune + // `workflowRunPackBootstrapped` entries owned by the just-undeployed + // deployment so a future workflow-run-repo reset for the same + // `(kind, id, ref)` triple re-runs the bootstrap-retry arm instead of + // skipping it on the stale flag and failing with `non_fast_forward`. + // Indexed by `agentAddress` (not `anchorRunId`) because the link + // does not own the address->anchorRunId derivation -- the sidecar's + // deploy router does. Every workflow-run push the link sees carries + // the originating address explicitly, so the index closes the gap + // structurally without leaking the derivation across the package + // boundary. + const workflowRunPackBootstrappedByAddress = new Map>(); + function workflowRunPackKey(repoId: RepoId, ref: string): string { + return `${repoId.kind}:${repoId.id}:${ref}`; + } + + async function handleSyncRequest(frame: SyncRequestFrame): Promise { + const { agentAddress, transferId } = frame; + try { + const { pack, commitSha, ref } = + await sessions.createStatePack(agentAddress); + const repoId: RepoId = { kind: "agent-state", id: agentAddress }; + + await packSender.send({ + agentAddress, + repoId, + transferId, + pack, + ref, + commitSha, + }); + + logger.info`State push complete for ${agentAddress} (${commitSha.slice(0, 8)})`; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.warn`State push failed for ${agentAddress}: ${msg}`; + } + } + + function handlePackAck(frame: PackAckFrame): void { + if (!packSender.handleAck(frame)) { + logger.warn`Received repo.pack.ack for unknown transferId ${frame.transferId}`; + } + } + + function handlePackReject(frame: PackRejectFrame): void { + if (!packSender.handleReject(frame)) { + logger.warn`Received repo.pack.reject for unknown transferId ${frame.transferId}`; + } + } + + function handleSignalCorrelationRegisterAck( + frame: SignalCorrelationRegisterAckFrame, + ): void { + // A no-match is normal: the retry may have already been acked, exhausted, + // or abandoned on a disconnect. The ack still truthfully asserts the row + // exists, so there is nothing to recover -- log at debug, not warn. + if (!registerAcker.handleAck(frame.correlationId)) { + logger.debug`Received signal.correlation.register.ack for uncorrelated ${frame.correlationId}`; + } + } + + async function handleSignalDeliver(frame: SignalDeliverFrame): Promise { + if (signalInboundRouter === undefined) { + logger.warn`Received signal.deliver for ${frame.agentAddress} but no signalInboundRouter is wired; dropping`; + return; + } + try { + const routed = await signalInboundRouter.tryRoute(frame); + if (!routed) { + logger.warn`signal.deliver for ${frame.agentAddress} did not match any registered deployment; dropping`; + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.warn`signal.deliver delivery failed for ${frame.agentAddress}: ${msg}`; + } + } + + async function handleDrainDeliver(frame: DrainDeliverFrame): Promise { + if (drainInboundRouter === undefined) { + logger.warn`Received drain.deliver for ${frame.agentAddress} but no drainInboundRouter is wired; dropping`; + return; + } + try { + const routed = await drainInboundRouter.tryRoute(frame); + if (!routed) { + logger.warn`drain.deliver for ${frame.agentAddress} did not match any registered deployment; dropping`; + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.warn`drain.deliver delivery failed for ${frame.agentAddress}: ${msg}`; + } + } + + async function handleRunGrants(frame: RunGrantsFrame): Promise { + if (grantsInboundRouter === undefined) { + logger.warn`Received run.grants for ${frame.agentAddress} but no grantsInboundRouter is wired; dropping`; + return; + } + try { + const routed = await grantsInboundRouter.tryRoute(frame); + if (!routed) { + logger.warn`run.grants for ${frame.agentAddress} did not match any registered deployment; dropping`; + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.error`run.grants write failed for ${frame.agentAddress}: ${msg}`; + } + } + + async function handleSourcesUpdate(frame: SourcesUpdateFrame): Promise { + // `sources.update` is request/ack (the hub awaits a reply within its + // request timeout), so every path answers `session.ack` or + // `session.error` -- unlike the fire-and-forget signal/drain frames + // that log and drop. A missing router still answers, or the hub hangs. + if (sourcesInboundRouter === undefined) { + send({ + type: "session.error", + requestId: frame.requestId, + error: "no sourcesInboundRouter is wired", + }); + return; + } + try { + const routed = await sourcesInboundRouter.tryRoute(frame); + if (routed) { + send({ type: "session.ack", requestId: frame.requestId }); + } else { + send({ + type: "session.error", + requestId: frame.requestId, + error: `no deployment registered for ${frame.agentAddress}`, + }); + } + } catch (err) { + // A registered address whose rotation was rejected: an invalid list + // (the router validates before dispatch) or the supervisor's + // `deliverSources` throwing (e.g. a recycling phase). The reason + // rides back verbatim so the hub sees why the rotation failed. + const msg = err instanceof Error ? err.message : String(err); + send({ + type: "session.error", + requestId: frame.requestId, + error: msg, + }); + } + } + + async function handleCredentialsUpdate( + frame: CredentialsUpdateFrame, + ): Promise { + // `credentials.update` is request/ack, exactly like `sources.update`: every + // path answers `session.ack` or `session.error`. A missing router still + // answers, or the hub hangs. + if (credentialsInboundRouter === undefined) { + send({ + type: "session.error", + requestId: frame.requestId, + error: "no credentialsInboundRouter is wired", + }); + return; + } + try { + const routed = await credentialsInboundRouter.tryRoute(frame); + if (routed) { + send({ type: "session.ack", requestId: frame.requestId }); + } else { + send({ + type: "session.error", + requestId: frame.requestId, + error: `no deployment registered for ${frame.agentAddress}`, + }); + } + } catch (err) { + // A registered address whose delivery was rejected: an invalid delivery + // (the router validates before dispatch) or the supervisor's + // `deliverCredentials` throwing (e.g. a recycling phase). The reason + // rides back verbatim so the hub sees why the delivery failed. + const msg = err instanceof Error ? err.message : String(err); + send({ + type: "session.error", + requestId: frame.requestId, + error: msg, + }); + } + } + + async function handleWorkflowProbeRequest( + frame: WorkflowProbeRequestFrame, + ): Promise { + // `workflow.probe.request` is request/response (the hub awaits a reply + // within its probe timeout), so every path answers `workflow.probe.result` + // or `workflow.probe.error` -- never a log-and-drop. The executor runs the + // child evaluation; a throw (including the placeholder executor's + // not-implemented throw) rides back as an error reply so the hub's probe + // fails fast instead of hanging. + try { + const result = await workflowProbeExecutor.probe(frame); + send({ + type: "workflow.probe.result", + requestId: frame.requestId, + projection: result.projection, + grants: result.grants, + grantWalkSnapshot: result.grantWalkSnapshot, + wireHash: result.wireHash, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + send({ + type: "workflow.probe.error", + requestId: frame.requestId, + error: msg, + }); + } + } + + async function pushWorkflowRunPack(opts: { + agentAddress: string; + repoId: RepoId; + pack: Uint8Array; + ref: string; + commitSha: string; + }): Promise { + const key = workflowRunPackKey(opts.repoId, opts.ref); + + async function sendOnce(): Promise { + const transferId = `workflow-run-${++workflowRunPackCounter}-${opts.repoId.id}`; + await packSender.send({ + agentAddress: opts.agentAddress, + repoId: opts.repoId, + transferId, + pack: opts.pack, + ref: opts.ref, + commitSha: opts.commitSha, + }); + } + + async function runWithBootstrap(): Promise { + if (workflowRunPackBootstrapped.has(key)) { + await sendOnce(); + return; + } + try { + await sendOnce(); + } catch (first) { + // A disconnect that cancelled the transfer (`cancelAll` on the + // link's reconnect `open`) is NOT the initRepo bootstrap race: the + // link just cycled, and re-sending on the fresh, not-yet-challenged + // connection would ship to a hub that has dropped this address's + // route (the frames land "unrouted"). Reconnect recovery is owned by + // the pushing store's post-challenge re-drive, not by this + // fast-retry, so re-throw and let the caller latch the failure. Only + // the genuine bootstrap race -- a receiver reject against an + // uninitialised hub repo -- retries here. + if (isConnectionLost(first)) { + throw first; + } + // First push to a never-bootstrapped (repoId, ref) lost the + // race with the hub substrate's `receivePack` initRepo step + // (see the comment on `workflowRunPackBootstrapped` above). + // The hub has now initialized the repo as a side effect of + // the failed push; the retry uses the same pack but observes + // the bootstrap genesis as the CAS baseline and lands. + const reason = first instanceof Error ? first.message : String(first); + logger.warn`Workflow-run pack push bootstrap retry for ${opts.repoId.id}/${opts.ref}: ${reason}`; + await sendOnce(); + } + workflowRunPackBootstrapped.add(key); + let perAddress = workflowRunPackBootstrappedByAddress.get( + opts.agentAddress, + ); + if (perAddress === undefined) { + perAddress = new Set(); + workflowRunPackBootstrappedByAddress.set(opts.agentAddress, perAddress); + } + perAddress.add(key); + } + + // Serialize pushes per (repoId, ref). The hub's `receiveWorkflowRunPack` + // does its `resolveRef` outside the substrate's per-repo lock, so + // overlapping pushes from this sender would each observe a stale + // baseline and the second to acquire the hub-side lock would + // reject with `non_fast_forward`. Chaining through this queue + // keeps the receive ordering consistent end-to-end. + const prior = workflowRunPackQueues.get(key) ?? Promise.resolve(); + const next = prior.catch(() => undefined).then(() => runWithBootstrap()); + workflowRunPackQueues.set(key, next); + try { + await next; + } finally { + // Drop the queue entry when the chain has settled and no + // follower has appended, so a long-idle (repoId, ref) does not + // hold a dead promise reference. A racing append replaces this + // entry before we get here; the conditional avoids clobbering + // a still-active chain. + if (workflowRunPackQueues.get(key) === next) { + workflowRunPackQueues.delete(key); + } + } + } + + async function handleMessage( + data: string, + connection: WebSocket, + ): Promise { + let raw: unknown; + try { + raw = JSON.parse(data) as unknown; + } catch { + logger.warn`Received unparseable frame from hub`; + return; + } + const validated = HubFrame(raw); + if (validated instanceof type.errors) { + // A malformed request/ack frame must still be answered, or the hub's + // request hangs to its timeout. `sources.update` and `agent.deploy` + // usually keep an intact correlation key even when a nested field is + // malformed, so reply with the matching error frame; a fire-and-forget + // frame (or one with no recoverable key) is only logged and dropped. + answerMalformedRequestFrame(raw, validated.summary, send); + logger.warn`Invalid hub frame: ${validated.summary}`; + return; + } + const frame = validated; + + switch (frame.type) { + case "mail.inbound": { + const rawBytes = base64Decode(frame.rawMessage); + // Supervised deployments register the deployment-level mail + // address on `mailInboundRouter` once their supervisor spawns; + // that handler delivers the bytes to the supervisor's mail-bus + // subscription, which is what the workflow-host's `awaitSignal` + // listens on. Mail for an address with no registered handler has + // no receiver -- the in-process session runtime that once backed + // it is retired -- so it is logged and dropped. + // + // Guard the router call with try/catch so a synchronous throw does + // not reject this `handleMessage` promise and wedge the per-connection + // `messageQueue` chain. A rejected chain would silently drop every + // subsequent frame -- including the heartbeat `pong` -- and stall the + // link. The durable settlement is observed off the chain (below). + let durable: Promise | null = null; + if (mailInboundRouter !== undefined) { + try { + durable = mailInboundRouter.tryRoute(frame.agentAddress, rawBytes); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.warn`mail.inbound router threw for ${frame.agentAddress}: ${msg}`; + } + } + if (durable === null) { + logger.warn`Dropping mail.inbound for ${frame.agentAddress}: no registered handler`; + break; + } + // Acknowledge durable receipt only AFTER the inbox write settles, and + // only for hub-originated mail carrying a hub-minted messageId (the + // ack handshake). Observe the settlement DETACHED from the + // `messageQueue` chain so a slow or failing inbox write never wedges + // frame processing; on rejection (transient failure, stale refusal, or + // a tearing-down phase) no ack is sent, so the hub redelivers. + const ackMessageId = frame.messageId; + if (ackMessageId !== undefined) { + void durable + .then(() => { + send({ + type: "mail.inbound.ack", + agentAddress: frame.agentAddress, + messageId: ackMessageId, + }); + }) + .catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + logger.warn`Withholding mail.inbound.ack for ${frame.agentAddress} ${ackMessageId}; hub will redeliver: ${msg}`; + }); + } else { + // Relayed agent-to-agent mail carries no hub-minted messageId and + // does not participate in the ack handshake. Still observe the + // settlement so a rejection is logged, not left unhandled. + void durable.catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + logger.warn`Inbound mail delivery failed for ${frame.agentAddress}: ${msg}`; + }); + } + break; + } + case "agent.deploy": + await handleAgentDeploy(frame); + break; + case "agent.undeploy": + await handleAgentUndeploy(frame); + break; + case "challenge": + await handleChallenge(frame, connection); + break; + case "pong": + lastPongAt = Date.now(); + break; + case "challenge.failed": + await handleChallengeFailed(frame); + break; + case "repo.pack.push": + handlePackPush(frame); + break; + case "repo.pack.done": + await handlePackDone(frame); + break; + case "sync.request": + void handleSyncRequest(frame); + break; + case "signal.deliver": + await handleSignalDeliver(frame); + break; + case "run.grants": + await handleRunGrants(frame); + break; + case "drain.deliver": + await handleDrainDeliver(frame); + break; + case "sources.update": + await handleSourcesUpdate(frame); + break; + case "credentials.update": + await handleCredentialsUpdate(frame); + break; + case "workflow.probe.request": + await handleWorkflowProbeRequest(frame); + break; + case "repo.pack.ack": + handlePackAck(frame); + break; + case "repo.pack.reject": + handlePackReject(frame); + break; + case "signal.correlation.register.ack": + handleSignalCorrelationRegisterAck(frame); + break; + default: + logger.warn`Unknown frame type from hub: ${(frame as { type: string }).type}`; + } + } + + function connect(): void { + // Reconnect cancellation in close() is the load-bearing protection + // against post-close reconnect attempts. A caller invoking connect() + // after close() is a misuse, not a recoverable state — fail loudly. + if (closed) { + throw new Error("HubLink.connect called after close"); + } + + handshakePending = true; + const connection = new WebSocket(hubURL); + ws = connection; + + connection.addEventListener("open", () => { + if (ws !== connection) { + connection.close(); + return; + } + logger.info`Connected to hub at ${hubURL}`; + + lastPongAt = Date.now(); + pingTimer = setInterval(() => { + if (Date.now() - lastPongAt >= pingIntervalMs * 2) { + logger.warn`Hub pong timeout, closing connection`; + if (pingTimer !== null) { + clearInterval(pingTimer); + pingTimer = null; + } + connection.close(); + return; + } + send({ type: "ping" }); + }, pingIntervalMs); + + packReceiver.reset(); + packSender.cancelAll(CONNECTION_LOST_REASON); + // Abandon register retries armed against the prior connection. Any still + // parked correlation is re-registered by the reconnect re-emit once the + // challenge below re-routes the addresses, so a stale retry firing onto + // this fresh, not-yet-challenged socket would only land unrouted. + registerAcker.cancelAll(); + + // The first handshake is the sidecar's complete hosted-address + // announcement. A fresh sidecar sends register; one that restored a + // deployment sends reconnect instead. Sending an empty register before + // reconnect would expose a false empty inventory and let allocation + // reconciliation restore Hub state over the live workflow. + const restoredAddresses = getWorkflowAddresses(); + if (restoredAddresses.length === 0) { + completeHandshake(connection, { + type: "register", + sidecarId, + token, + agentAddresses: [], + }); + } else { + // The active-address inventory includes both workflow-derived and + // plain run addresses. The Hub skips deploy-ref freshness for + // workflow-derived addresses; the rest still require their refs to + // avoid an unnecessary full deploy-pack catch-up. + void (async () => { + try { + const deployRefs: Record = {}; + for (const address of restoredAddresses) { + const ref = await sessions.getDeployRef(address); + if (ref !== null) { + deployRefs[address] = ref; + } + } + completeHandshake(connection, { + type: "reconnect", + sidecarId, + token, + agentAddresses: restoredAddresses, + ...(Object.keys(deployRefs).length > 0 ? { deployRefs } : {}), + }); + } catch (err) { + // A failed ref read leaves the Hub unable to determine whether a + // plain agent needs catch-up. Retry the whole connection instead + // of sending a partial inventory. The attempt fence prevents a + // late failure from closing a newer socket. + if (ws !== connection) return; + const msg = err instanceof Error ? err.message : String(err); + logger.error`Deployment re-announce failed, closing connection: ${msg}`; + connection.close(); + } + })(); + } + }); + + connection.addEventListener("message", (event) => { + if (typeof event.data === "string") { + // Attach a tail `.catch` to the chained handler so any + // unhandled throw inside `handleMessage` is observed and + // surfaces as a logged warning rather than rejecting the + // shared `messageQueue` chain. A rejected chain wedges every + // subsequent `messageQueue.then(...)` -- including the + // heartbeat `pong` path -- and silently stalls the link. + // Per-arm guards (mail/signal/drain) are the primary defence; + // this catch is the belt-and-braces guarantee that no future + // unguarded arm can wedge the link. + // + // This chain also serializes inbound frames: each frame's + // handler runs to completion before the next begins. A downstream + // invariant depends on that ordering -- the workflow + // source-rotation persist rolls back on failure assuming no second + // rotation is in flight, which holds only because sources.update + // frames are processed one at a time here. Parallelizing this + // dispatch would break that rollback. + const data = event.data; + messageQueue = messageQueue.then(() => + handleMessage(data, connection).catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + logger.warn`Unhandled error in handleMessage: ${msg}`; + }), + ); + } + }); + + connection.addEventListener("close", () => { + // A late close from a superseded attempt must not null or reschedule the + // active socket. Normal reconnects also pass this fence: the next socket + // is not created until this handler schedules it. + if (ws !== connection) return; + logger.info`Disconnected from hub`; + ws = null; + handshakePending = true; + if (pingTimer !== null) { + clearInterval(pingTimer); + pingTimer = null; + } + // Abandon in-flight register retries: the link is down, so recovery + // belongs to the reconnect re-emit, and a lingering watchdog would only + // fire onto a closed socket. + registerAcker.cancelAll(); + // The hub dropped every route this link held. Block workflow-run pushes + // for the deployments it hosts until the reconnect challenge re-routes + // them, so the coalescing pusher does not re-ship onto the fresh, + // not-yet-challenged connection (which the hub drops as "unrouted"). + // `onWorkflowAddressesRoutable`, fired when the challenge passes, lifts + // the block and re-drives. + if (onWorkflowAddressesUnroutable !== undefined) { + const hosted = getWorkflowAddresses(); + if (hosted.length > 0) { + onWorkflowAddressesUnroutable(hosted); + } + } + if (!closed) { + cancelReconnect = scheduleReconnect(() => { + cancelReconnect = null; + // Defense in depth for fake or misbehaving schedulers whose + // cancel function is a no-op: re-check `closed` before + // re-entering connect() so a fired-but-not-yet-executed + // callback after close() does not propagate the + // "called after close" throw out of the scheduler. + if (closed) return; + connect(); + }, reconnectDelayMs); + } + }); + + connection.addEventListener("error", (event) => { + logger.warn`WebSocket error: ${String(event)}`; + }); + } + + function close(): void { + closed = true; + if (cancelReconnect !== null) { + cancelReconnect(); + cancelReconnect = null; + } + if (pingTimer !== null) { + clearInterval(pingTimer); + pingTimer = null; + } + registerAcker.cancelAll(); + if (ws !== null) { + ws.close(); + ws = null; + } + } + + const sendEvent: SessionEventSink = (agentAddress, sessionId, event) => { + send({ + type: "agent.event", + agentAddress, + sessionId, + event, + }); + }; + + const sendSignalCorrelationRegister: HubLink["sendSignalCorrelationRegister"] = + (registration) => { + // The ask rail is the only producer of this frame, and every ask-rail + // suspension carries a snapshot. A registration without one is an + // in-process wiring defect, not a wire condition: fail loud here rather + // than send a snapshot-less frame the receiver would reject. + if (registration.approvalSnapshot === undefined) { + throw new Error( + `signal.correlation.register built with no approval snapshot for ${registration.correlationId}; ask-rail suspensions always carry one`, + ); + } + const frame: SignalCorrelationRegisterFrame = { + type: "signal.correlation.register", + correlationId: registration.correlationId, + runId: registration.runId, + anchorRunId: registration.anchorRunId, + agentAddress: registration.agentAddress, + kind: registration.kind, + snapshot: registration.approvalSnapshot, + }; + // Send through the acker, which retries until the hub acks the co-write. + registerAcker.send(frame); + }; + + return { + connect, + close, + sendEvent, + sendSignalCorrelationRegister, + pushWorkflowRunPack, + }; +} diff --git a/vendor/intx/hub-agent/src/ws/register-acker.ts b/vendor/intx/hub-agent/src/ws/register-acker.ts new file mode 100644 index 000000000..cf65f31ac --- /dev/null +++ b/vendor/intx/hub-agent/src/ws/register-acker.ts @@ -0,0 +1,135 @@ +import { getLogger } from "@intx/log"; +import type { SignalCorrelationRegisterFrame } from "@intx/types/sidecar"; + +const logger = getLogger(["interchange", "hub-agent", "ws", "register-acker"]); + +/** + * Per-attempt watchdog for one register frame. Tight -- a single frame plus one + * DB upsert, not a child enumerating runs -- so retries cover the + * connected-but-lost-frame window without lingering. + */ +export const DEFAULT_REGISTER_ACK_TIMEOUT_MS = 2_000; + +/** + * Total sends before giving up (the initial send plus retries). On exhaustion + * the acker stops and logs: the correlation is not lost, because the next + * re-establishment (child respawn/recycle, hub reconnect) re-registers the whole + * parked set from durable state. + */ +export const DEFAULT_REGISTER_ACK_MAX_ATTEMPTS = 3; + +type PendingRegister = { + frame: SignalCorrelationRegisterFrame; + attempts: number; + timer: ReturnType; +}; + +export type RegisterAckerConfig = { + /** + * Put a register frame on the wire. Called for the initial send and each + * retry; the acker never touches the socket itself, so the link's normal + * `send` (queue-on-disconnect) semantics are preserved. + */ + sendFrame: (frame: SignalCorrelationRegisterFrame) => void; + /** + * True only when the link is OPEN. The acker abandons a pending retry the + * moment the link is not open: re-sending onto a fresh, not-yet-challenged + * socket would land "unrouted", and the reconnect re-emit re-registers the + * whole parked set anyway. + */ + isOpen: () => boolean; + timeoutMs?: number; + maxAttempts?: number; +}; + +/** + * Reliable-resend helper for `signal.correlation.register` frames, modelled on + * the pack sender's pending-ack machine. A register is fire-and-forget on the + * wire and can be lost on an open socket or evicted from the link's bounded send + * queue; without an ack the parked run is never registered and cannot be + * approved. This tracks each register until the hub's + * `signal.correlation.register.ack` lands, re-sending on a tight watchdog, and + * gives up (leaving recovery to the next re-establishment) rather than retrying + * across a disconnect. + */ +export interface RegisterAcker { + /** + * Send a register frame and track it until acked or abandoned. A second send + * for a correlationId already pending refreshes the frame and resets the + * watchdog rather than arming a second one -- the initial park, a + * respawn/reconnect re-emit, and a retry all carry the same correlationId and + * drive the same idempotent co-write, so one pending entry per correlation is + * correct. + */ + send(frame: SignalCorrelationRegisterFrame): void; + /** Settle the pending retry for this correlationId; false if none was pending. */ + handleAck(correlationId: string): boolean; + /** + * Abandon every pending retry without re-sending or re-acking. Called on link + * close and on the reconnect open edge, symmetric with the ping timer and the + * pack sender's `cancelAll`, so no timer leaks and no retry fires onto a dead + * or not-yet-challenged socket. + */ + cancelAll(): void; +} + +export function createRegisterAcker( + config: RegisterAckerConfig, +): RegisterAcker { + const timeoutMs = config.timeoutMs ?? DEFAULT_REGISTER_ACK_TIMEOUT_MS; + const maxAttempts = config.maxAttempts ?? DEFAULT_REGISTER_ACK_MAX_ATTEMPTS; + const pending = new Map(); + + function schedule(correlationId: string): ReturnType { + return setTimeout(() => onTimeout(correlationId), timeoutMs); + } + + function onTimeout(correlationId: string): void { + const entry = pending.get(correlationId); + if (entry === undefined) return; + // Abandon the moment the link is not open -- the reconnect re-emit owns + // recovery from here, and a resend onto a fresh socket would land unrouted. + if (!config.isOpen()) { + pending.delete(correlationId); + return; + } + if (entry.attempts >= maxAttempts) { + pending.delete(correlationId); + logger.warn`signal.correlation.register for ${correlationId} unacked after ${String(maxAttempts)} attempts; leaving recovery to the next re-establishment`; + return; + } + entry.attempts += 1; + config.sendFrame(entry.frame); + entry.timer = schedule(correlationId); + } + + function send(frame: SignalCorrelationRegisterFrame): void { + const existing = pending.get(frame.correlationId); + if (existing !== undefined) { + clearTimeout(existing.timer); + } + config.sendFrame(frame); + pending.set(frame.correlationId, { + frame, + attempts: 1, + timer: schedule(frame.correlationId), + }); + } + + function handleAck(correlationId: string): boolean { + const entry = pending.get(correlationId); + if (entry === undefined) return false; + clearTimeout(entry.timer); + pending.delete(correlationId); + return true; + } + + function cancelAll(): void { + for (const entry of pending.values()) { + clearTimeout(entry.timer); + } + pending.clear(); + } + + return { send, handleAck, cancelAll }; +} diff --git a/vendor/intx/hub-agent/tsconfig.json b/vendor/intx/hub-agent/tsconfig.json new file mode 100644 index 000000000..dbbb0384b --- /dev/null +++ b/vendor/intx/hub-agent/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.base.json", + "include": [ + "src/**/*.ts" + ], + "compilerOptions": { + "types": [ + "bun" + ] + } +} diff --git a/vendor/intx/workflow-host/VENDORED-FROM b/vendor/intx/workflow-host/VENDORED-FROM index 631711869..a14b96fb0 100644 --- a/vendor/intx/workflow-host/VENDORED-FROM +++ b/vendor/intx/workflow-host/VENDORED-FROM @@ -1,4 +1,4 @@ Source: https://github.com/faremeter/interchange (packages/workflow-host) -Commit: b5580a02fb918eebccc33ded7727ffee781ffbd1 (tag v0.3.0) +Commit: a8bc06ae38661c5e0ed91ded8559bf09f502213d (origin/main, 2026-08-27) License: LGPL-2.1-only (see vendor/intx/LICENSE) -Local modifications: exports map repointed from the upstream intx-src condition to direct TypeScript source resolution (types/default -> ./src/...); dist references removed. CL-6164: the supervisor's signal.deliver branch drops mail whose extracted conversation body is empty (the new hasConversationText gate in conversation-text.ts), recording an empty_conversation_content rejection, instead of delivering "" -- which throws in agent.send and kills the run with StepFailed/retriesExhausted. Attachments-only conversation.message mail (e.g. @corbits/chat's workbench.agent-joined event send) is exactly that shape. CL-6325: adds the action-primitive adapters (adapters/action-invoker.ts, adapters/effect-ledger.ts, adapters/run-blobs.ts and their tests) -- copied from gtm-workbench's packages/workflow-host workspace fork, not from upstream, which has no action-primitive adapters at the pinned commit (see VENDORED.md) -- and the completed run-child bind in child/run-child.ts: RunWorkflowChildBindings gains resolveActionHandler (awaited once per child with the re-verified definition and the live CredentialWiring) and loopFns (both defaulting to the fail-closed empty registries), and buildRuntimeEnv wires effects, invokeAction, loopFns, and runLoopIteration (createLoopIteration) into every run's WorkflowRuntimeEnv. buildRuntimeEnv is exported (child/index.ts, index.ts) so a host's runtime-env-level probe can exercise the bind without the full control-channel harness. Bridging edits against the re-pinned @intx/types and @intx/hub-sessions (a8bc06ae): child/supervisor-backed-transport.ts's expunge stub returns Promise<{ expungedUids: number[] }> (upstream bcabb1f8), and supervisor.ts's boot replay reads ownedMessageIds from scanRunsForBoot (upstream f89bb51b) since readOwnedMessageIds no longer exists; both gone with this tree's own re-pin. +Local modifications: exports map repointed from the upstream intx-src condition to direct TypeScript source resolution (types/default -> ./src/...); dist references removed. CL-6448: the suspendable-child (onTrigger body) spawn seam threads the parent child's credentials-backed authorize, live CredentialWiring and MailPartReader: RunSuspendableChild's input and createInMemorySpawnSuspendableChild's opts gain the three optional fields (adapters/spawn-child.ts), and child/run-child.ts hoists `authorize` above the body resolver and passes all three when building it, so a tool-bearing body agent gates its tool calls through the same per-step grant snapshot a top-level step does, resolves credentials, and reads an attachments-only inbound mail's parts instead of throwing. Upstream runs bodies toolless and has no analog. child/run-child.ts also resolves a step's grants entry through findStepGrantsEntry (head collapse: a body step whose own id is absent from a single-step deployment's snapshot resolves to the sole entry). Retired at this pin: the empty-mail drop (CL-6164; upstream 81ef5ad9 decodes mail and omits empty content) and the action/loop runtime bind (CL-6325; upstream 3bd5b837/1ea2f39b load closure exports natively, and no workbench workflow authors an action step). diff --git a/vendor/intx/workflow-host/package.json b/vendor/intx/workflow-host/package.json index 2f4652bb2..5ed0a72c9 100644 --- a/vendor/intx/workflow-host/package.json +++ b/vendor/intx/workflow-host/package.json @@ -11,20 +11,19 @@ } }, "scripts": { - "typecheck": "tsc --noEmit", - "test": "bun test" + "typecheck": "tsc --noEmit" }, "dependencies": { - "@corbits/workflow-host-actions": "workspace:*", - "@intx/agent": "0.3.0", + "@intx/agent": "workspace:*", "@intx/crypto": "0.3.0", "@intx/hub-sessions": "workspace:*", - "@intx/inference": "0.3.0", + "@intx/inference": "workspace:*", "@intx/log": "0.3.0", - "@intx/mail-memory": "0.3.0", - "@intx/mime": "0.3.0", + "@intx/mail-memory": "workspace:*", + "@intx/mailbox": "workspace:*", + "@intx/mime": "workspace:*", "@intx/storage-isogit": "0.3.0", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", "arktype": "catalog:" }, diff --git a/vendor/intx/workflow-host/src/adapters/mail-part-store.ts b/vendor/intx/workflow-host/src/adapters/mail-part-store.ts new file mode 100644 index 000000000..faf7f5bf0 --- /dev/null +++ b/vendor/intx/workflow-host/src/adapters/mail-part-store.ts @@ -0,0 +1,340 @@ +// Durable store for a run's inbound-mail parts. +// +// The supervisor decodes an inbound MIME message into parts (via `decodeMail`) +// and commits each part's decoded bytes here as a real file under +// `runs//parts//-`, returning the +// JSON-safe `MailPart[]` descriptors that ride in the run's trigger/signal +// payload. A `MailPartReader` resolves a descriptor's opaque `ref` back to the +// committed bytes for any consumer -- an agent's content-block projection, a +// workflow tool, a child run -- through the single, environment-agnostic +// `MailPartReader` interface. +// +// Modeled on the sibling `blob-substrate` adapter: same per-run handles, same +// substrate primitives (`writeTreePreservingPrefix` to write raw bytes; +// `openCommittedReads` to read them back from a coherent object-store snapshot +// rather than the lagging working tree). The write happens in one commit per +// message (write-once, atomic). The kind handler validates the subtree shape; +// this module sanitizes untrusted names to satisfy it and reuses the handler's +// path-component byte cap. + +import { + MAX_MAIL_PART_PATH_COMPONENT_BYTES, + WORKFLOW_RUN_PARTS_DIR, + WORKFLOW_RUN_RUNS_PREFIX, +} from "@intx/hub-sessions/substrate"; +import type { + Principal, + RepoId, + RepoStore as SubstrateRepoStore, +} from "@intx/hub-sessions/substrate"; +import type { + Mail, + MailPart, + MailPartReader, + MessageHeaders, + MessagePart, +} from "@intx/types/runtime"; + +const REF_SCHEME = "mail-part:///"; + +// Content types whose bytes are UTF-8 text and small enough to also inline as +// `MailPart.text`, so a selector can read them without resolving the ref. +const INLINE_TEXT_MAX_BYTES = 1024 * 1024; + +const CONTROL_CHAR_MAX = 0x1f; +const DEL_CHAR = 0x7f; +// Unicode line/paragraph separators. JavaScript's regex `.` does NOT match +// these, so the kind handler's `-` check (whose name group is +// `.+`) rejects a filename containing them. The sanitizer must strip them to +// keep its "satisfies the handler by construction" contract. +const LINE_SEPARATOR = 0x2028; +const PARAGRAPH_SEPARATOR = 0x2029; + +const encoder = new TextEncoder(); + +function byteLength(value: string): number { + return encoder.encode(value).length; +} + +/** + * Thrown for a DETERMINISTIC, input-shaped rejection of an inbound mail -- a + * messageId that cannot form a usable path segment. Distinct from a transient + * substrate write failure so the caller drops the offending mail (replaying it + * would fail identically) rather than treating it as a retryable fault. + */ +export class InvalidMailError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = "InvalidMailError"; + } +} + +function encodeMessageSegment(messageId: string): string { + const encoded = encodeURIComponent(messageId); + if (byteLength(encoded) > MAX_MAIL_PART_PATH_COMPONENT_BYTES) { + throw new InvalidMailError( + `mail part store: messageId ${JSON.stringify(messageId)} url-encodes to ${String(byteLength(encoded))} bytes, over the ${String(MAX_MAIL_PART_PATH_COMPONENT_BYTES)}-byte path-component limit`, + ); + } + // `encodeURIComponent` leaves `.` unescaped, so "." or ".." would form a + // traversal segment; reject it where the messageId -> segment constraint is + // owned. An empty segment is unreachable for a non-empty messageId but is + // refused for the same reason. + if (encoded.length === 0 || encoded === "." || encoded === "..") { + throw new InvalidMailError( + `mail part store: messageId ${JSON.stringify(messageId)} url-encodes to ${JSON.stringify(encoded)}, which is not a usable path segment`, + ); + } + return encoded; +} + +/** + * Reduce an untrusted part name (a MIME filename, or a fallback) to one safe + * path segment: path separators, NUL, and control characters become `_`. The + * `-` prefix guarantees per-message uniqueness, so a sanitization + * collision between two parts of one message is harmless. + */ +function sanitizePartName(name: string): string { + let out = ""; + for (const ch of name) { + const code = ch.codePointAt(0) ?? 0; + out += + ch === "/" || + ch === "\\" || + code <= CONTROL_CHAR_MAX || + code === DEL_CHAR || + code === LINE_SEPARATOR || + code === PARAGRAPH_SEPARATOR + ? "_" + : ch; + } + return out.length > 0 ? out : "part"; +} + +/** Truncate to at most `maxBytes` UTF-8 bytes on a codepoint boundary. */ +function truncateToBytes(value: string, maxBytes: number): string { + if (byteLength(value) <= maxBytes) return value; + let out = ""; + let used = 0; + for (const ch of value) { + const chBytes = byteLength(ch); + if (used + chBytes > maxBytes) break; + out += ch; + used += chBytes; + } + return out; +} + +/** + * The on-disk filename for one part: `-`, sanitized and truncated + * to the handler's byte cap. Satisfies the handler's `-` shape by + * construction. + */ +function partFilename(index: number, part: MessagePart): string { + const prefix = `${String(index)}-`; + const budget = MAX_MAIL_PART_PATH_COMPONENT_BYTES - byteLength(prefix); + const rawName = part.filename ?? defaultPartName(part.contentType); + const safeName = truncateToBytes(sanitizePartName(rawName), budget); + return `${prefix}${safeName.length > 0 ? safeName : "part"}`; +} + +/** A stable fallback name for a part with no filename, derived from its type. */ +function defaultPartName(contentType: string): string { + const slash = contentType.indexOf("/"); + const subtype = slash === -1 ? contentType : contentType.slice(slash + 1); + const safeSubtype = subtype.replace(/[^a-z0-9]+/gi, "") || "bin"; + return `part.${safeSubtype}`; +} + +function isTextType(contentType: string): boolean { + return ( + contentType.startsWith("text/") || + contentType === "application/json" || + contentType === "application/vnd.interchange+json" + ); +} + +function mailPartRef( + runId: string, + messageSegment: string, + filename: string, +): string { + return `${REF_SCHEME}${encodeURIComponent(runId)}/${messageSegment}/${encodeURIComponent(filename)}`; +} + +/** + * Parse a `mail-part:///` ref into its run id, message segment, and filename. + * The ref is persisted in the event log and re-read on resume, so it is + * treated as untrusted: the scheme must match, it must be exactly three + * non-empty segments, and no segment may traverse. + */ +function parseMailPartRef(ref: string): { + runId: string; + messageSegment: string; + filename: string; +} { + if (!ref.startsWith(REF_SCHEME)) { + throw new Error( + `mail part reader: unrecognized ref ${JSON.stringify(ref)}`, + ); + } + const rest = ref.slice(REF_SCHEME.length); + const segments = rest.split("/"); + const malformed = `mail part reader: malformed ref ${JSON.stringify(ref)}`; + if ( + segments.length !== 3 || + segments.some((s) => s.length === 0) || + rest.includes("\\") + ) { + throw new Error(malformed); + } + // `runId` and `filename` were percent-encoded into the ref; `messageSegment` + // is stored encoded and matches the on-disk directory name verbatim. + let runId: string; + let filename: string; + try { + runId = decodeURIComponent(segments[0] ?? ""); + filename = decodeURIComponent(segments[2] ?? ""); + } catch (cause) { + throw new Error(malformed, { cause }); + } + const messageSegment = segments[1] ?? ""; + // Reject traversal on the DECODED values too: a ref could encode `..` + // (`%2e%2e`) or a path separator (`%2f`, `%5c`) that only reveals itself + // after decoding, forming a compound traversal segment like `../..`. + const traverses = (s: string): boolean => + s === "." || s === ".." || s.includes("/") || s.includes("\\"); + if ([runId, messageSegment, filename].some(traverses)) { + throw new Error(malformed); + } + return { runId, messageSegment, filename }; +} + +export type MailPartStoreOpts = { + substrate: SubstrateRepoStore; + repoId: RepoId; + principal: Principal; + runId: string; + ref: string; +}; + +/** + * Commit a decoded message's parts and assemble the JSON-safe `Mail`. Every + * part's bytes are written in ONE prefix-preserving commit under the message's + * directory (write-once, atomic), and each part becomes a `MailPart` descriptor + * carrying its metadata, an opaque `ref`, and -- for a small UTF-8 text part -- + * its decoded `text` inline. + */ +export async function commitMail( + opts: MailPartStoreOpts, + messageId: string, + decoded: { + headers: MessageHeaders; + rawHeaders: Record; + parts: MessagePart[]; + }, +): Promise { + const messageSegment = encodeMessageSegment(messageId); + const messagePrefix = `${WORKFLOW_RUN_RUNS_PREFIX}/${opts.runId}/${WORKFLOW_RUN_PARTS_DIR}/${messageSegment}/`; + const fresh: Record = {}; + const mailParts: MailPart[] = decoded.parts.map((part, index) => { + const filename = partFilename(index, part); + fresh[`${messagePrefix}${filename}`] = part.content; + const descriptor: MailPart = { + contentType: part.contentType, + ref: mailPartRef(opts.runId, messageSegment, filename), + }; + if (part.filename !== undefined) descriptor.filename = part.filename; + if (part.disposition !== undefined) + descriptor.disposition = part.disposition; + if ( + isTextType(part.contentType) && + part.content.byteLength <= INLINE_TEXT_MAX_BYTES + ) { + descriptor.text = new TextDecoder("utf-8", { fatal: false }).decode( + part.content, + ); + } + return descriptor; + }); + + if (Object.keys(fresh).length > 0) { + try { + await opts.substrate.writeTreePreservingPrefix( + opts.principal, + opts.repoId, + opts.ref, + { + preservePrefix: messagePrefix, + merge: async (existing) => { + const files: Record = {}; + for (const [k, v] of existing) files[k] = v; + for (const [k, v] of Object.entries(fresh)) files[k] = v; + return files; + }, + message: `commit ${String(decoded.parts.length)} mail part(s) for message ${messageId} of run ${opts.runId}`, + }, + ); + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + // A path_violation is a shape rejection of this message's own (already + // sanitized) content: it is deterministic, so replaying the same bytes + // fails identically. Surface it as InvalidMailError so the caller drops + // the mail rather than retrying it forever as a transient fault. + if (message.startsWith("path_violation: ")) { + throw new InvalidMailError(message.slice("path_violation: ".length), { + cause, + }); + } + throw cause; + } + } + + return { + headers: decoded.headers, + rawHeaders: decoded.rawHeaders, + parts: mailParts, + }; +} + +export type MailPartReaderOpts = { + substrate: SubstrateRepoStore; + repoId: RepoId; + principal: Principal; + ref: string; +}; + +/** + * Construct the single mail-part reader for a deployment's workflow-run repo. + * `read` resolves any run's `MailPart.ref` to the committed bytes through a + * committed read pinned to the object store, so a cross-run read (a childflow + * or body step resolving a parent's part) never observes the lagging working + * tree. + */ +export function createMailPartReader(opts: MailPartReaderOpts): MailPartReader { + return { + async read(ref) { + const { runId, messageSegment, filename } = parseMailPartRef(ref); + const dir = `${WORKFLOW_RUN_RUNS_PREFIX}/${runId}/${WORKFLOW_RUN_PARTS_DIR}/${messageSegment}`; + const reads = await opts.substrate.openCommittedReads( + opts.principal, + opts.repoId, + opts.ref, + ); + if (reads === null) { + throw new Error( + `mail part reader: repo ${opts.repoId.id} ref ${opts.ref} has no committed tree; cannot resolve ${ref}`, + ); + } + const entry = (await reads.listDir(dir)).find( + (e) => e.name === filename && e.type === "blob", + ); + if (entry === undefined) { + throw new Error( + `mail part reader: no committed part at ${dir}/${filename}`, + ); + } + return reads.readBlobByOid(entry.oid); + }, + }; +} diff --git a/vendor/intx/workflow-host/src/adapters/spawn-child.ts b/vendor/intx/workflow-host/src/adapters/spawn-child.ts index 547d9fc98..a88565b5d 100644 --- a/vendor/intx/workflow-host/src/adapters/spawn-child.ts +++ b/vendor/intx/workflow-host/src/adapters/spawn-child.ts @@ -60,7 +60,7 @@ // childRunId, ... }`) is the seam that makes the scoping unambiguous // at the boundary. -import type { InferenceEvent } from "@intx/types/runtime"; +import type { InferenceEvent, MailPartReader } from "@intx/types/runtime"; import type { SpawnChildWorkflow, SpawnSuspendableChild, @@ -198,6 +198,12 @@ export type RunSuspendableChild = ( * capabilities exactly as a top-level step's do. */ credentialWiring?: CredentialWiring; + /** + * The parent child's mail part reader (CL-6448), so a body step's + * attachments-only inbound mail resolves its parts the way a top-level + * step's does instead of throwing for want of a reader. + */ + mailPartReader?: MailPartReader; }, /** * Live inference-event sink for the child's agent steps. Threaded from the @@ -241,6 +247,7 @@ export function createInMemorySpawnSuspendableChild(opts: { /** Threaded through verbatim to every spawn's input (CL-6448). */ authorize?: WorkflowAuthorizeFn; credentialWiring?: CredentialWiring; + mailPartReader?: MailPartReader; }): HostSpawnSuspendableChild { return async ( { @@ -285,6 +292,9 @@ export function createInMemorySpawnSuspendableChild(opts: { ...(opts.credentialWiring !== undefined ? { credentialWiring: opts.credentialWiring } : {}), + ...(opts.mailPartReader !== undefined + ? { mailPartReader: opts.mailPartReader } + : {}), }, onEvent, ); diff --git a/vendor/intx/workflow-host/src/adapters/step-invoker.ts b/vendor/intx/workflow-host/src/adapters/step-invoker.ts index 59b49db47..986d241b5 100644 --- a/vendor/intx/workflow-host/src/adapters/step-invoker.ts +++ b/vendor/intx/workflow-host/src/adapters/step-invoker.ts @@ -61,12 +61,16 @@ import { type SendResult, } from "@intx/agent"; import { getLogger } from "@intx/log"; -import { createInboundMessage } from "@intx/mime"; +import { createInboundMessage, extractAddrSpec, isMessageId } from "@intx/mime"; import type { InboundMessage, InferenceEvent, InferenceSource, + Mail, + MailPartReader, + MessageAttachment, } from "@intx/types/runtime"; +import { isMail } from "@intx/types/runtime"; import type { AuthorizeContext, StepInvokeRequest, @@ -78,7 +82,9 @@ import type { import type { WarmAgentCache, WarmEventSinkRef, + WarmReplyDrive, } from "../child/warm-agent-cache"; +import { runBodyThenCleanup } from "../run-body-then-cleanup"; const logger = getLogger(["workflow-host", "step-invoker"]); @@ -186,6 +192,59 @@ export interface WorkflowStepInvokerOpts { * has no cross-run conversation to mirror. */ onRunBoundary?: (key: string) => Promise; + /** + * Seed hook for the warm path (design §3c threading). When supplied, the + * adapter calls it before `agent.send` on every message whose delivered + * input is a mail-derived `InboundMessage`, passing the step identity + * (`authzContext.stepId`, the same key `onRunBoundary` and the warm cache + * use) and that message. The sidecar wires this to the warm agent's + * durable conversation store, which routes the message onto the connector + * thread (seeding threadRoot / lastMessageId / replyTo) so the reply path + * can compose a threaded reply. Awaited before the send so the thread + * state is committed and durably flushed before the reply is produced; a + * seed failure surfaces by rejecting the step. + * + * Only mail-derived inbound messages seed: an approval-resume inbound + * carries a synthetic sender and a correlation id, and a synthesized + * string input is not a message, so neither advances the connector + * thread. Omitted on the cold path, which has no durable connector state + * to seed. + */ + seedInbound?: (key: string, message: InboundMessage) => Promise; + /** + * Connector reply-drain hook for the warm path (design §3c). When supplied, + * the adapter invokes it ONCE -- at the warm agent's first-message build -- + * with the step identity (`authzContext.stepId`, the same key the warm + * cache, seed, and run-boundary hooks use) and the agent's lifetime event + * stream. The sidecar wires this to the shared connector reply drain: on + * every `connector.reply` the agent emits, the drain composes a threaded + * reply from the durable store's connector thread and sends it through the + * supervisor-backed outbound bridge, then advances the thread from the send + * receipt. + * + * The returned drive handle exposes the drain's lifetime `done` promise -- + * which settles when the agent's stream ends at eviction, folded into the + * warm entry's event-forward promise so the cache drains the reply loop + * alongside the observability forwarder -- plus the per-turn settle barrier + * (`replySeq` / `waitForReplyAfter`) the warm step gates each reply turn on, + * so the run parks only after the reply is durably sent. + * + * Omitted on the cold path (a torn-down per-step agent has no cross-message + * connector thread) and whenever the deployment is not warm-kept. + */ + driveReplies?: ( + key: string, + stream: ReturnType, + ) => WarmReplyDrive; + /** + * Reader for the run's inbound-mail parts. When the step input is a decoded + * `Mail`, the adapter resolves each part's `ref` to its committed bytes + * through this reader and delivers a real `InboundMessage` (text and/or + * attachments) to `agent.send`. Supplied by the run child for the top-level + * run's steps; absent for body steps, where a part whose bytes must be read + * is refused loudly rather than silently flattened to text. + */ + mailPartReader?: MailPartReader; } /** @@ -249,24 +308,37 @@ async function invokeColdStep( // flow through by default. const eventForward = subscribeAgentEvents(agent, opts.onEvent); - try { - return stepResultFromSend( - await sendWithAbort(agent, req, { closeOnAbort: true }), - ); - } finally { - // `close` is idempotent: a second call after the send already - // resolved still releases the workdir lock and tears down stream - // consumers. We await so the lock is gone before the adapter - // returns -- a subsequent step on the same workdir must not race - // a still-closing agent. - await agent.close(); - // `agent.close()` terminates every active `stream()` iterator, so - // the forwarder's for-await loop has ended (or is about to). Await - // it after close so the subscription is fully drained before the - // adapter returns and no listener outlives the step. Awaited last - // because the loop only ends once close has fired. - await eventForward; - } + // `agent.close()` is the wrapAgentClose-wrapped close: it runs the + // agent's own close (idempotent -- releases the workdir lock and tears + // down stream consumers) and then the plugin/tool-bundle disposers, + // which now reject the close if a disposer (e.g. the LSP subprocess + // kill) fails. Route the close through runBodyThenCleanup so a disposer + // failure surfaces on a clean step but never masks a step error already + // unwinding from `sendWithAbort`. `eventForward` is drained on every + // path -- `agent.close()` ends the forwarder's for-await loop, and it + // never rejects (subscribeAgentEvents swallows), so awaiting it in the + // cleanup cannot mask either error. + return runBodyThenCleanup( + async () => + stepResultFromSend( + await sendWithAbort(agent, req, { + closeOnAbort: true, + mailPartReader: opts.mailPartReader, + // Cold-path per-step agents have no durable connector state; the + // warm path is the only connector-seeding path. + seedInbound: undefined, + }), + ), + async () => { + try { + await agent.close(); + } finally { + await eventForward; + } + }, + (cause) => + logger.error`step invoker: agent.close failed while unwinding a step error; surfacing the step error, close failure: ${cause instanceof Error ? cause.message : String(cause)}`, + ); } /** @@ -316,7 +388,27 @@ async function invokeWarmStep( const sink = eventSinkRef.current; if (sink !== null) sink(event); }); - warmCache.store(key, agent, eventSinkRef, eventForward); + // Establish the connector reply drain over the agent's lifetime stream + // (design §3c). A second independent consumer of the agent's stream + // alongside the observability forwarder: on each `connector.reply` it + // composes a threaded reply from the durable connector thread and sends + // it through the outbound bridge. Fold its lifetime `done` promise into + // the stored forwarder promise so the warm cache drains BOTH when it + // closes the agent at eviction -- the cache awaits one promise per entry, + // so a caller that wants the reply loop torn down with the agent combines + // the two here. The drive handle's per-turn barrier is stored on the entry + // so every message (not just this first build) can gate its reply turn on + // a durable send. Present only on the warm mail path; a warm deployment + // with no durable connector state omits it. + const replyDrive = + opts.driveReplies !== undefined + ? opts.driveReplies(key, agent.stream()) + : null; + const lifetimeForward = + replyDrive !== null + ? Promise.all([eventForward, replyDrive.done]).then(() => undefined) + : eventForward; + warmCache.store(key, agent, eventSinkRef, lifetimeForward, replyDrive); // Re-apply the live source table to the just-built agent. A rotation // that arrived during the (async) build hit the still-empty cache as a // no-op `applySources` while the build had already captured the prior @@ -333,10 +425,46 @@ async function invokeWarmStep( if (opts.onEvent !== undefined) { warmCache.setEventSink(key, opts.onEvent); } + // Bind the seed hook to this step's identity so it resolves the same + // per-agent durable store the warm cache and run-boundary flush use. + const seedInbound = opts.seedInbound; + // Snapshot the reply barrier BEFORE the send. The agent resolves + // `agent.send` in the same synchronous step it pushes `connector.reply` + // onto the drain's stream, so the reply is not yet enqueued when the send + // resolves -- a snapshot taken after the send would miss this turn's reply. + const replyDrive = warmCache.getReplyDrive(key); + const replySeqBeforeSend = replyDrive !== null ? replyDrive.replySeq() : 0; try { - return stepResultFromSend( - await sendWithAbort(agent, req, { closeOnAbort: false }), - ); + const sendResult = await sendWithAbort(agent, req, { + closeOnAbort: false, + mailPartReader: opts.mailPartReader, + seedInbound: + seedInbound !== undefined + ? (message) => seedInbound(key, message) + : undefined, + }); + const stepResult = stepResultFromSend(sendResult); + // Gate the step's return on THIS turn's reply being durably sent, so the + // run parks -- and the supervisor consumes the inbound mail -- only after + // the auto-reply reaches the transport. Only a reply turn produces a + // `connector.reply`; a suspended/gate turn produces none, so it must NOT + // await the barrier (that reply never arrives and the wait would hang). + // A failed send resolves the barrier with `ok: false`: fail the turn so + // the inbound mail is not consumed as replied and the run's claim-check + // replays it (at-least-once via reprocessing) rather than dropping the + // reply. + if (replyDrive !== null && sendResult.type === "reply") { + const settlement = await replyDrive.waitForReplyAfter(replySeqBeforeSend); + if (!settlement.ok) { + throw new Error( + "workflow step invoker: the warm agent's auto-reply send failed; " + + "failing the turn so the inbound mail replays rather than being " + + "consumed with the reply dropped", + { cause: settlement.cause }, + ); + } + } + return stepResult; } finally { // Do NOT close the agent or drain its forwarder: both span // messages and are owned by the warm cache, torn down at eviction. @@ -405,18 +533,38 @@ async function buildStepAgent( async function sendWithAbort( agent: Agent, req: StepInvokeRequest, - cfg: { closeOnAbort: boolean }, + cfg: { + closeOnAbort: boolean; + mailPartReader: MailPartReader | undefined; + seedInbound: ((message: InboundMessage) => Promise) | undefined; + }, ): Promise { + // Re-check the abort signal before building the message. `buildEnv` and + // `agentFactory` (or a warm-cache acquire) yield to the microtask queue, and + // the caller can fire `signal.abort()` in between. Building the message can + // itself yield (attachment resolution), so guard here too. + if (req.signal.aborted) throw abortError(req.signal); + // Resolve the step input into the value `agent.send` receives. A build + // failure (a bad resume shape, an unresolvable attachment) rejects the step. + const { message, mailInbound } = await buildSendMessage( + req, + cfg.mailPartReader, + ); + // Seed the warm agent's connector thread from a mail-derived inbound before + // the send, so the reply path has thread state (design §3c). Only the + // mail branch surfaces `mailInbound`; an approval-resume inbound and a + // synthesized string do not advance the thread. The seed is awaited so its + // durable flush completes (or surfaces) before the reply is produced; a + // seed failure rejects the step rather than composing an unthreaded reply. + if (mailInbound !== null && cfg.seedInbound !== undefined) { + await cfg.seedInbound(mailInbound); + } let abortListener: (() => void) | null = null; try { return await new Promise((resolve, reject) => { - // Re-check the abort signal inside the executor. `buildEnv` and - // `agentFactory` (or a warm-cache acquire) yield to the - // microtask queue, and the caller can fire `signal.abort()` - // between the entry-time check and here. Without this re-check, - // a mid-construction abort would attach the listener to an - // already-aborted signal that never fires the event again, and - // the send would hang to the workflow runtime's step timeout. + // Re-check after the (async) message build: a mid-build abort must not + // attach the listener to an already-aborted signal that never fires the + // event again, or the send would hang to the runtime's step timeout. if (req.signal.aborted) { reject(abortError(req.signal)); return; @@ -432,38 +580,6 @@ async function sendWithAbort( }; abortListener = onAbort; req.signal.addEventListener("abort", onAbort, { once: true }); - let message: string | InboundMessage; - try { - // How the resumed input is delivered depends on the park kind: - // - // - `"approval"`: the reactor is parked mid-turn on a tool/authz gate. - // Build the full `InboundMessage` stamped with `resume.correlationId` - // so the header reaches the reactor's `tryCorrelate` and matches the - // rehydrated gate. The object form is load-bearing -- a plain string - // would drop the correlation id and the resumed cycle would never - // match. - // - `"input"`: the step re-armed between turns; the decision is simply - // the next user turn, with NO gate to correlate. Deliver it as the - // plain synthesized content, exactly as a first invocation does. - // - // A first invocation (no resume) sends the plain synthesized input; - // `agent.send` stamps its own synthetic addressing. - message = - req.resume === undefined - ? synthesizeInputContent(req.input) - : req.resume.kind === "input" - ? synthesizeInputContent(req.resume.decision) - : createInboundMessage({ - from: "signal@local", - to: "agent@local", - content: synthesizeInputContent(req.resume.decision), - interchangeType: "conversation.message", - correlationId: req.resume.correlationId, - }); - } catch (cause) { - reject(cause instanceof Error ? cause : new Error(String(cause))); - return; - } const sendOpts = cfg.closeOnAbort ? undefined : { signal: req.signal }; agent.send(message, sendOpts).then(resolve, (cause: unknown) => { reject(cause instanceof Error ? cause : new Error(String(cause))); @@ -576,6 +692,150 @@ function stepResultFromSend(result: SendResult): StepInvokeResult { return { output: { reply: result.reply, turn: result.turn } }; } +/** + * Resolve the step input into the value `agent.send` receives. The delivery + * depends on the resume kind and the input shape: + * + * - `"approval"` resume: the reactor is parked mid-turn on a tool/authz gate. + * Build the full `InboundMessage` stamped with `resume.correlationId` so the + * header reaches the reactor's `tryCorrelate` and matches the rehydrated + * gate. The object form is load-bearing -- a plain string would drop the + * correlation id and the resumed cycle would never match. An approval + * decision is never a mail-derived `Mail`, so it stays on this branch. + * - A mail-derived `Mail` (first invocation or `"input"` resume): project its + * parts into a real `InboundMessage` (text and/or attachments), resolving + * each non-text part's bytes through the reader. + * - Anything else (a first invocation or `"input"` resume carrying an + * arbitrary step value): synthesize plain text; `agent.send` stamps its own + * synthetic addressing. + */ +async function buildSendMessage( + req: StepInvokeRequest, + mailPartReader: MailPartReader | undefined, +): Promise<{ + message: string | InboundMessage; + mailInbound: InboundMessage | null; +}> { + if (req.resume !== undefined && req.resume.kind !== "input") { + return { + message: createInboundMessage({ + from: "signal@local", + to: "agent@local", + content: synthesizeInputContent(req.resume.decision), + interchangeType: "conversation.message", + correlationId: req.resume.correlationId, + }), + mailInbound: null, + }; + } + const rawInput = req.resume === undefined ? req.input : req.resume.decision; + // A step whose input is a decoded `Mail` is projected into the agent's + // inbound message; the strict `isMail` guard keeps an arbitrary step value + // from matching. This is the one branch that carries real threading headers, + // so `mailInbound` surfaces the message for the warm path's connector seed. + if (isMail(rawInput)) { + const message = await buildInboundMessageFromMail(rawInput, mailPartReader); + return { message, mailInbound: message }; + } + return { message: synthesizeInputContent(rawInput), mailInbound: null }; +} + +/** Extract a bare addr-spec from a header value, or fall back to a synthetic + * local address when the value is absent or unparseable. */ +function safeAddr(raw: string | undefined, fallback: string): string { + if (raw === undefined || raw === "") return fallback; + try { + return extractAddrSpec(raw); + } catch { + return fallback; + } +} + +/** + * Project a decoded `Mail` into the agent's `InboundMessage`. Text parts become + * the conversation body; every other part becomes an attachment the reactor + * turns into a media / document content block. Part bytes are resolved through + * the reader; a text part small enough to have inlined `text` skips the read. + * The real sender / recipient headers are carried through so the agent frames + * the turn with the actual `From:` rather than a synthetic address. The decoded + * `Message-ID` and, when present, `In-Reply-To` / `References` ride through too, + * so the delivered message keeps its place in the conversation thread rather + * than being stamped with a fresh synthesized id. Content is omitted when empty + * -- `createInboundMessage` rejects an empty string, and an attachments-only + * message is valid. + * + * The reader is required only when a part's bytes must actually be read (a + * non-text part, or a text part too large to have inlined its `text`). A + * text-only mail whose parts all inlined -- e.g. one routed to a body step, + * which is not wired with a reader -- still delivers; a part that needs bytes + * with no reader is refused loudly rather than silently dropped. + */ +async function buildInboundMessageFromMail( + mail: Mail, + mailPartReader: MailPartReader | undefined, +): Promise { + const noReader = (): Error => + new Error( + "workflow step invoker: a mail part's bytes must be read but the step has no mail-part reader wired; inbound parts are not supported for this step", + ); + const textPieces: string[] = []; + const attachments: MessageAttachment[] = []; + for (const part of mail.parts) { + // A part is conversation body only when it is inline plain text. An + // attachment-disposition part (even a text/* one, e.g. an attached .txt), + // and any non-plain-text part (a text/html alternative, an image, an + // application/* payload), is delivered as an attachment so its bytes and + // filename survive rather than being folded into the turn. + const isBody = + part.disposition !== "attachment" && part.contentType === "text/plain"; + if (isBody) { + if (part.text !== undefined) { + textPieces.push(part.text); + continue; + } + if (mailPartReader === undefined) throw noReader(); + textPieces.push( + new TextDecoder("utf-8", { fatal: false }).decode( + await mailPartReader.read(part.ref), + ), + ); + continue; + } + if (mailPartReader === undefined) throw noReader(); + attachments.push({ + name: part.filename ?? part.contentType, + contentType: part.contentType, + data: await mailPartReader.read(part.ref), + }); + } + const content = textPieces.join("\n").trim(); + // Forward the mail's Message-ID / In-Reply-To / References for threading, but + // only the well-formed RFC 2822 identifiers. Inbound mail can carry a + // headerless-derived (sha256) or malformed Message-Id -- a valid claim-check + // key but not a valid identifier -- and passing it to createInboundMessage + // would throw and fail the step. When the messageId is omitted here, + // createInboundMessage synthesizes a valid one; such mail cannot thread. + const validReferences = mail.headers.references?.filter(isMessageId) ?? []; + return createInboundMessage({ + from: safeAddr(mail.headers.from, "trigger@local"), + to: safeAddr(mail.headers.to[0], "agent@local"), + ...(mail.headers.subject !== undefined + ? { subject: mail.headers.subject } + : {}), + ...(isMessageId(mail.headers.messageId) + ? { messageId: mail.headers.messageId } + : {}), + ...(mail.headers.inReplyTo !== undefined && + isMessageId(mail.headers.inReplyTo) + ? { inReplyTo: mail.headers.inReplyTo } + : {}), + ...(validReferences.length > 0 ? { references: validReferences } : {}), + ...(content.length > 0 ? { content } : {}), + ...(attachments.length > 0 ? { attachments } : {}), + interchangeType: "conversation.message", + }); +} + /** * Encode the step's resolved `input` as the synthetic inbound message * content. The workflow runtime resolves `input` from the step's input diff --git a/vendor/intx/workflow-host/src/adapters/substrate-mailbox-store.ts b/vendor/intx/workflow-host/src/adapters/substrate-mailbox-store.ts new file mode 100644 index 000000000..8e75a7df1 --- /dev/null +++ b/vendor/intx/workflow-host/src/adapters/substrate-mailbox-store.ts @@ -0,0 +1,563 @@ +// Workflow-run-substrate backing for the `@intx/mailbox` `MailboxStore`. +// +// The `MailboxStore` mutation surface is SYNCHRONOUS, but the workflow-run +// substrate is an async git store. This backing follows the shape of an IMAP +// client with a local cache: the async `createSubstrateMailboxStore` factory +// loads the committed `mailbox/INBOX/` METADATA (`index.json`) into an +// in-memory mirror on open, exposes the synchronous mutation surface over that +// mirror, and persists the current state back to the substrate through +// `flush`. Callers mutate synchronously (append / addFlags / removeFlags / +// remove) and `await flush()` at a boundary. +// +// The per-message raw RFC 2822 bytes are NOT loaded on open and are NOT held +// resident: `readRaw(uid)` reads a message's `.eml` blob on demand from +// the committed-read snapshot the open pinned. The only raw this backing keeps +// in memory is that of a message appended-but-not-yet-flushed; a successful +// `flush` drops it. So the resident footprint of a long-lived warm mailbox is +// bounded to metadata regardless of how much mail it has accumulated. +// +// On-disk layout, a top-level subtree of the workflow-run repo (one mailbox per +// deployment repo): +// +// mailbox/INBOX/index.json committed metadata: uidValidity, the +// uid/modseq counters, one entry per live +// message (uid, modseq, flags, pre-parsed +// envelope), and the expunged-uid tombstones +// QRESYNC answers `vanished` from. +// mailbox/INBOX/.eml the verbatim raw RFC 2822 bytes of each live +// message, so `fetchFull` verifies signatures +// byte-exactly. Write-once per uid. +// +// Reads resolve from the committed substrate (`openCommittedReads`), never the +// lagging working tree, matching the sibling `mail-part-store` reader. Writes +// go through `writeTreeDelta`: `index.json` changes on every mutation and is +// always put; each `.eml` is immutable, so a flush puts only the blobs +// appended since the last successful flush and deletes only those whose +// message was removed since then, and the substrate carries every untouched +// `.eml` forward by object id. This keeps a flush O(delta) rather than +// O(mailbox), so a long-lived warm conversational mailbox does not re-hash its +// whole history on each append or flag change. The committed subtree the delta +// leaves behind is byte-identical in shape to a full rewrite of the same live +// set. The kind handler's push-time validation of this subtree is owned +// separately by the hub replication layer; this module owns the on-disk shape +// it validates. + +import { type } from "arktype"; +import type { + Principal, + RepoId, + RepoStore as SubstrateRepoStore, +} from "@intx/hub-sessions/substrate"; +import type { + MailboxStore, + StoredEnvelope, + StoredMessage, +} from "@intx/mailbox"; + +/** Top-level subtree of the workflow-run repo that holds the mailbox. */ +export const MAILBOX_PREFIX = "mailbox"; +/** The single mailbox this backing persists, an IMAP INBOX. */ +export const MAILBOX_INBOX_DIR = "INBOX"; +/** Committed metadata blob name directly under `mailbox/INBOX/`. */ +export const MAILBOX_INDEX_FILE = "index.json"; +/** Suffix of a per-message raw-bytes blob (`.eml`). */ +export const MAILBOX_EML_SUFFIX = ".eml"; + +/** + * The `mailbox/INBOX/` prefix, ending in `/` as + * `writeTreePreservingPrefix` requires. Every blob this backing writes is a + * direct child of it. + */ +export const MAILBOX_INBOX_PREFIX = `${MAILBOX_PREFIX}/${MAILBOX_INBOX_DIR}/`; + +/** Relative directory path of the INBOX, for `CommittedReads.listDir`. */ +const MAILBOX_INBOX_DIR_PATH = `${MAILBOX_PREFIX}/${MAILBOX_INBOX_DIR}`; + +/** Current on-disk schema version of `index.json`. */ +const INDEX_VERSION = 1; + +const decoder = new TextDecoder(); +const encoder = new TextEncoder(); + +/** + * On-disk envelope shape. Mirrors `StoredEnvelope` but serializes `date` as an + * ISO string and the three nullable header fields as `string | null` (JSON has + * no `undefined`); the loader maps `null` back to `undefined`. + */ +const StoredEnvelopeJson = type({ + messageId: "string", + from: "string", + to: "string[]", + subject: "string", + date: "string", + inReplyTo: "string | null", + references: "string[]", + interchangeType: "string | null", + interchangeCorrelationId: "string | null", +}); + +/** + * On-disk `index.json` shape. Validated on every open: the committed tree is + * durable but external to this process, so it is parsed at the boundary rather + * than trusted. `expunged` records the uid and the modseq at which each + * message vanished so a QRESYNC `sync` can answer `vanished` since a client's + * known modseq. + */ +const MailboxIndexJson = type({ + version: `${INDEX_VERSION}`, + uidValidity: "number >= 0", + uidNext: "number >= 1", + highestModSeq: "number >= 0", + messages: type({ + uid: "number >= 1", + modseq: "number >= 1", + flags: "string[]", + envelope: StoredEnvelopeJson, + }).array(), + expunged: type({ + uid: "number >= 1", + modseq: "number >= 1", + }).array(), +}); + +type MailboxIndexJson = typeof MailboxIndexJson.infer; + +/** A message the client no longer holds, with the modseq at which it vanished. */ +type ExpungedRecord = { uid: number; modseq: number }; + +/** + * The client's last-known synchronization state, per QRESYNC (RFC 7162). A + * mismatched `uidValidity` forces a full resync; otherwise `highestModSeq` + * bounds the changed / vanished deltas. + */ +export type MailboxSyncKnownState = { + uidValidity: number; + highestModSeq: number; +}; + +/** + * The result of a QRESYNC `sync`. `resync: true` signals the client's + * `uidValidity` no longer matches the mailbox, so it must discard its cache + * and take the full `messages` snapshot. `resync: false` carries the deltas + * since the client's `highestModSeq`: `changed` is every live message whose + * modseq advanced past it (new arrivals and flag changes alike), and + * `vanished` is every uid expunged past it. + */ +export type MailboxSyncResult = + | { + resync: true; + uidValidity: number; + uidNext: number; + highestModSeq: number; + messages: readonly StoredMessage[]; + } + | { + resync: false; + uidValidity: number; + uidNext: number; + highestModSeq: number; + changed: readonly StoredMessage[]; + vanished: readonly number[]; + }; + +/** + * A `MailboxStore` whose state is durable in the workflow-run substrate. The + * synchronous `MailboxStore` surface reads and mutates an in-memory mirror; + * `flush` persists that mirror to `mailbox/INBOX/`; `sync` answers a QRESYNC + * delta against a client's known state. + */ +export interface SubstrateMailboxStore extends MailboxStore { + /** True when a mutation has occurred that `flush` has not yet persisted. */ + readonly pendingWrites: boolean; + /** + * Persist the current mirror to the substrate through a delta write: + * `index.json` (always), the `.eml` blobs appended since the last + * successful flush, and deletions for the blobs whose message was removed + * since then. Every untouched `.eml` is carried forward by object id. A + * no-op when no mutation is pending. + */ + flush(): Promise; + /** Compute the QRESYNC delta between the mailbox and a client's known state. */ + sync(known: MailboxSyncKnownState): MailboxSyncResult; +} + +export type SubstrateMailboxStoreOpts = { + substrate: SubstrateRepoStore; + repoId: RepoId; + principal: Principal; + ref: string; +}; + +function serializeEnvelope(envelope: StoredEnvelope) { + return { + messageId: envelope.messageId, + from: envelope.from, + to: envelope.to, + subject: envelope.subject, + date: envelope.date.toISOString(), + inReplyTo: envelope.inReplyTo ?? null, + references: envelope.references, + interchangeType: envelope.interchangeType ?? null, + interchangeCorrelationId: envelope.interchangeCorrelationId ?? null, + }; +} + +function deserializeEnvelope( + raw: MailboxIndexJson["messages"][number]["envelope"], +): StoredEnvelope { + return { + messageId: raw.messageId, + from: raw.from, + to: raw.to, + subject: raw.subject, + date: new Date(raw.date), + inReplyTo: raw.inReplyTo === null ? undefined : raw.inReplyTo, + references: raw.references, + interchangeType: + raw.interchangeType === null ? undefined : raw.interchangeType, + interchangeCorrelationId: + raw.interchangeCorrelationId === null + ? undefined + : raw.interchangeCorrelationId, + }; +} + +/** The `.eml` blob name for a message. */ +function emlName(uid: number): string { + return `${String(uid)}${MAILBOX_EML_SUFFIX}`; +} + +/** + * A pinned committed-read snapshot of the repo the store opened against, the + * source `readRaw` resolves a live message's `.eml` from. `null` when the + * repo, ref, or subtree did not exist at open. + */ +type CommittedReads = Awaited< + ReturnType +>; + +type LoadedState = { + uidValidity: number; + uidNext: number; + highestModSeq: number; + messages: StoredMessage[]; + expunged: ExpungedRecord[]; + /** + * The pinned committed-read snapshot opened at load, retained so `readRaw` + * can resolve a message's `.eml` blob on demand without re-opening. + */ + reads: CommittedReads; + /** The `.eml` object id per committed uid, for `readRaw`. */ + oidByUid: Map; +}; + +/** + * Load the committed `mailbox/INBOX/` METADATA into an in-memory state, or the + * empty state (a fresh `uidValidity`) when the repo, the ref, or the subtree + * does not yet exist. Only `index.json` is read; the per-message `.eml` + * blobs stay on disk and are read lazily by `readRaw`, so an open's resident + * footprint is bounded to metadata regardless of mailbox size. The committed + * read snapshot and the uid->oid map are retained so `readRaw` resolves a + * blob against the same pinned commit the open observed. Every read resolves + * against the committed object store, so an open observes committed state even + * when the working tree lags. + */ +async function loadCommittedState( + opts: SubstrateMailboxStoreOpts, +): Promise { + const empty = (reads: CommittedReads): LoadedState => ({ + uidValidity: Date.now(), + uidNext: 1, + highestModSeq: 0, + messages: [], + expunged: [], + reads, + oidByUid: new Map(), + }); + + const reads = await opts.substrate.openCommittedReads( + opts.principal, + opts.repoId, + opts.ref, + ); + if (reads === null) return empty(reads); + + const entries = await reads.listDir(MAILBOX_INBOX_DIR_PATH); + const indexEntry = entries.find( + (e) => e.name === MAILBOX_INDEX_FILE && e.type === "blob", + ); + if (indexEntry === undefined) return empty(reads); + + const indexBytes = await reads.readBlobByOid(indexEntry.oid); + let parsedJson: unknown; + try { + parsedJson = JSON.parse(decoder.decode(indexBytes)); + } catch (cause) { + throw new Error( + `substrate mailbox store: ${MAILBOX_INBOX_PREFIX}${MAILBOX_INDEX_FILE} is not valid JSON`, + { cause }, + ); + } + const index = MailboxIndexJson(parsedJson); + if (index instanceof type.errors) { + throw new Error( + `substrate mailbox store: invalid ${MAILBOX_INBOX_PREFIX}${MAILBOX_INDEX_FILE}: ${index.summary}`, + ); + } + + const emlByName = new Map( + entries + .filter((e) => e.type === "blob" && e.name.endsWith(MAILBOX_EML_SUFFIX)) + .map((e) => [e.name, e.oid]), + ); + + const messages: StoredMessage[] = []; + const oidByUid = new Map(); + for (const entry of index.messages) { + const oid = emlByName.get(emlName(entry.uid)); + if (oid === undefined) { + throw new Error( + `substrate mailbox store: index references message uid ${String( + entry.uid, + )} but ${MAILBOX_INBOX_PREFIX}${emlName(entry.uid)} is absent`, + ); + } + // The blob's presence is asserted by its object id; its bytes are not read + // here -- `readRaw` reads them on demand. + oidByUid.set(entry.uid, oid); + messages.push({ + uid: entry.uid, + modseq: entry.modseq, + flags: new Set(entry.flags), + envelope: deserializeEnvelope(entry.envelope), + }); + } + + return { + uidValidity: index.uidValidity, + uidNext: index.uidNext, + highestModSeq: index.highestModSeq, + messages, + expunged: index.expunged.map((e) => ({ uid: e.uid, modseq: e.modseq })), + reads, + oidByUid, + }; +} + +/** + * Create a workflow-run-substrate-backed `MailboxStore`. Loads the committed + * `mailbox/INBOX/` subtree into an in-memory mirror, then serves the + * synchronous `MailboxStore` surface over that mirror. Mutations stay in + * memory until `flush` persists them. + */ +export async function createSubstrateMailboxStore( + opts: SubstrateMailboxStoreOpts, +): Promise { + const state = await loadCommittedState(opts); + + const messages = state.messages; + const expunged = state.expunged; + const uidValidity = state.uidValidity; + const reads = state.reads; + const oidByUid = state.oidByUid; + let uidCounter = state.uidNext; + // The next modseq to assign. `highestModSeq` is the largest assigned, so the + // next is one past it; a fresh mailbox (highestModSeq 0) starts at 1. + let modseqCounter = state.highestModSeq + 1; + let dirty = false; + + // Delta tracking for `flush`. `index.json` changes on every mutation, so it + // is put unconditionally; each `.eml` is immutable and written once, so + // a flush need only put the blobs appended since the last successful flush + // and delete the blobs whose message was removed since then. The removed set + // clears on a successful flush; a flush that throws leaves it intact so the + // next flush re-attempts the same delta. + // + // `pendingRawByUid` holds the raw bytes of appended-but-not-yet-flushed + // messages -- the only raw this backing keeps resident. It doubles as the + // "appended since flush" set: `flush` puts each entry's blob, then drops it + // so a flushed message's bytes leave memory and are read from disk on demand. + const pendingRawByUid = new Map(); + const removedSinceFlush = new Set(); + + function find(uid: number): StoredMessage | undefined { + return messages.find((m) => m.uid === uid); + } + + function require(uid: number): StoredMessage { + const msg = find(uid); + if (msg === undefined) { + throw new Error(`Message UID ${String(uid)} not found`); + } + return msg; + } + + async function flush(): Promise { + if (!dirty) return; + + const index = { + version: INDEX_VERSION, + uidValidity, + uidNext: uidCounter, + highestModSeq: modseqCounter - 1, + messages: messages.map((m) => ({ + uid: m.uid, + modseq: m.modseq, + flags: Array.from(m.flags), + envelope: serializeEnvelope(m.envelope), + })), + expunged: expunged.map((e) => ({ uid: e.uid, modseq: e.modseq })), + }; + + // `index.json` is put on every flush. Each `.eml` is immutable, so + // only the blobs appended since the last successful flush are put and only + // those whose message was removed are deleted; every other `.eml` is + // carried forward by object id, so the flush never re-hashes the mailbox's + // whole history. + const puts: Record = { + [`${MAILBOX_INBOX_PREFIX}${MAILBOX_INDEX_FILE}`]: encoder.encode( + JSON.stringify(index), + ), + }; + const flushedUids: number[] = []; + for (const [uid, raw] of pendingRawByUid) { + puts[`${MAILBOX_INBOX_PREFIX}${emlName(uid)}`] = raw; + flushedUids.push(uid); + } + const deletes = Array.from( + removedSinceFlush, + (uid) => `${MAILBOX_INBOX_PREFIX}${emlName(uid)}`, + ); + + await opts.substrate.writeTreeDelta(opts.principal, opts.repoId, opts.ref, { + computeDelta: async () => ({ puts, deletes }), + changedPathPrefixes: new Set([MAILBOX_INBOX_PREFIX]), + message: `persist mailbox INBOX (${String(messages.length)} message(s))`, + }); + // The appended blobs are now committed, so their raw leaves memory: a later + // `readRaw` reads them from disk. Their object ids are not recorded here + // (the pinned committed-read snapshot predates this commit), so `readRaw` + // resolves a post-open append only while its raw is still pending; the + // long-lived writer never reads its own appends back, and every reader + // opens a fresh snapshot that sees the committed blob. + for (const uid of flushedUids) { + pendingRawByUid.delete(uid); + } + removedSinceFlush.clear(); + dirty = false; + } + + function sync(known: MailboxSyncKnownState): MailboxSyncResult { + const highestModSeq = modseqCounter - 1; + if (known.uidValidity !== uidValidity) { + return { + resync: true, + uidValidity, + uidNext: uidCounter, + highestModSeq, + messages: messages.slice(), + }; + } + const changed = messages + .filter((m) => m.modseq > known.highestModSeq) + .sort((a, b) => a.uid - b.uid); + const vanished = expunged + .filter((e) => e.modseq > known.highestModSeq) + .map((e) => e.uid) + .sort((a, b) => a - b); + return { + resync: false, + uidValidity, + uidNext: uidCounter, + highestModSeq, + changed, + vanished, + }; + } + + return { + uidValidity, + get uidNext() { + return uidCounter; + }, + get highestModSeq() { + return modseqCounter - 1; + }, + get messages() { + return messages; + }, + get pendingWrites() { + return dirty; + }, + append(raw, envelope, flags) { + const uid = uidCounter++; + const modseq = modseqCounter++; + messages.push({ uid, modseq, flags: new Set(flags), envelope }); + pendingRawByUid.set(uid, raw); + dirty = true; + return uid; + }, + async readRaw(uid) { + if (find(uid) === undefined) { + throw new Error(`Message UID ${String(uid)} not found`); + } + // An appended-but-not-yet-flushed message keeps its raw in memory; a + // flushed or previously-committed message reads its blob from the pinned + // committed-read snapshot on demand. + const pending = pendingRawByUid.get(uid); + if (pending !== undefined) return pending; + const oid = oidByUid.get(uid); + if (oid === undefined || reads === null) { + throw new Error( + `substrate mailbox store: no committed blob for message uid ${String( + uid, + )}; its raw bytes are not resolvable from this snapshot`, + ); + } + return reads.readBlobByOid(oid); + }, + find, + addFlags(uid, flags) { + const msg = require(uid); + for (const flag of flags) { + msg.flags.add(flag); + } + msg.modseq = modseqCounter++; + dirty = true; + return msg; + }, + removeFlags(uid, flags) { + const msg = require(uid); + for (const flag of flags) { + msg.flags.delete(flag); + } + msg.modseq = modseqCounter++; + dirty = true; + return msg; + }, + remove(uid) { + const idx = messages.findIndex((m) => m.uid === uid); + if (idx === -1) { + throw new Error(`Message UID ${String(uid)} not found`); + } + messages.splice(idx, 1); + // Record the expunge with a fresh modseq so a QRESYNC `sync` can report + // this uid as `vanished` to a client whose known modseq predates it. The + // in-memory reference backing does not advance modseq on remove; this + // backing does, because it must answer QRESYNC across reopens. + expunged.push({ uid, modseq: modseqCounter++ }); + // A message appended and removed within the same flush window was never + // committed, so its `.eml` must be neither put nor deleted: drop its + // pending raw. Otherwise the blob is already committed and the next flush + // deletes it. + if (pendingRawByUid.has(uid)) { + pendingRawByUid.delete(uid); + } else { + removedSinceFlush.add(uid); + } + dirty = true; + }, + flush, + sync, + }; +} diff --git a/vendor/intx/workflow-host/src/child/child-mailbox-reader.ts b/vendor/intx/workflow-host/src/child/child-mailbox-reader.ts new file mode 100644 index 000000000..cffa670a2 --- /dev/null +++ b/vendor/intx/workflow-host/src/child/child-mailbox-reader.ts @@ -0,0 +1,40 @@ +// Child-side substrate mailbox reader (INBOUND half of mailbox ownership, +// design §3b). +// +// The supervisor commits an arrived message to the deployment's workflow-run +// substrate mailbox (`mailbox/INBOX/`), then fires a `mailbox.notify` control +// frame. A step agent's supervisor-backed transport answers the IMAP read +// surface (`search`, `fetchHeaders`, `fetchFull`, ...) by opening a fresh +// committed snapshot of that mailbox through this reader. `open` re-reads the +// committed subtree on every call, so a snapshot taken after a `mailbox.notify` +// observes the message the supervisor just committed. +// +// Modeled on the sibling `createMailPartReader` wiring: the same per-deployment +// substrate handles (substrate, repoId, principal, workflow-run ref), bound +// once so the transport reads without re-threading them. `createSubstrateMailboxStore` +// loads committed state on open and is async, so `open` returns a promise. + +import { + createSubstrateMailboxStore, + type SubstrateMailboxStore, + type SubstrateMailboxStoreOpts, +} from "../adapters/substrate-mailbox-store"; + +export interface ChildMailboxReader { + /** + * Open a fresh committed snapshot of the deployment's substrate INBOX. Each + * call re-reads the committed `mailbox/INBOX/` subtree, so a snapshot opened + * after a `mailbox.notify` observes the newly committed message. + */ + open(): Promise; +} + +export function createChildMailboxReader( + opts: SubstrateMailboxStoreOpts, +): ChildMailboxReader { + return { + open() { + return createSubstrateMailboxStore(opts); + }, + }; +} diff --git a/vendor/intx/workflow-host/src/child/from-process-env.ts b/vendor/intx/workflow-host/src/child/from-process-env.ts index aaa44cafa..efb2d26a8 100644 --- a/vendor/intx/workflow-host/src/child/from-process-env.ts +++ b/vendor/intx/workflow-host/src/child/from-process-env.ts @@ -35,6 +35,10 @@ import { createChildOutboundMailBridge, type ChildOutboundMailBridge, } from "./outbound-mail-bridge"; +import { + createChildMailboxMutationBridge, + type ChildMailboxMutationBridge, +} from "./mailbox-mutation-bridge"; import { createControlChannelSender, type FrameWriter, @@ -103,6 +107,17 @@ export interface SubstrateFactoryEnv { * agent's signature without the child ever holding the agent's key. */ readonly outboundMailBridge: ChildOutboundMailBridge; + /** + * Child-side IPC bridge over the upstream control channel for the + * INBOUND half of mailbox ownership (§3b). The substrate factory uses + * this to construct the supervisor-backed `MessageTransport`'s write + * surface: `setFlags` / `clearFlags` / `expunge` call `bridge.submit`, + * which emits a `mailbox.mutate.request` upstream frame and resolves + * once the supervisor's matching `mailbox.mutate.response` lands. The + * supervisor -- the sole mailbox writer -- applies the mutation to its + * owned store, so the child never flushes the run ref itself. + */ + readonly mailboxMutationBridge: ChildMailboxMutationBridge; } /** @@ -199,11 +214,15 @@ export async function runWorkflowChildFromProcessEnv( const outboundMailBridge = createChildOutboundMailBridge({ upstreamSender, }); + const mailboxMutationBridge = createChildMailboxMutationBridge({ + upstreamSender, + }); const bindings = await factory({ spawn, substrateConfig, substrateWriteBridge, outboundMailBridge, + mailboxMutationBridge, }); return runWorkflowChild({ env: spawn, @@ -221,6 +240,7 @@ export async function runWorkflowChildFromProcessEnv( upstreamSender, substrateWriteBridge, outboundMailBridge, + mailboxMutationBridge, }); } diff --git a/vendor/intx/workflow-host/src/child/index.ts b/vendor/intx/workflow-host/src/child/index.ts index fa747b023..7c38ac0c1 100644 --- a/vendor/intx/workflow-host/src/child/index.ts +++ b/vendor/intx/workflow-host/src/child/index.ts @@ -1,5 +1,4 @@ export { - buildRuntimeEnv, createCredentialsBackedAuthorize, hashGrants, runWorkflowChild, @@ -28,7 +27,28 @@ export { type CreateChildOutboundMailBridgeOpts, } from "./outbound-mail-bridge"; -export { createSupervisorBackedTransport } from "./supervisor-backed-transport"; +export { + createChildMailboxMutationBridge, + type ChildMailboxMutationBridge, + type CreateChildMailboxMutationBridgeOpts, + type MailboxMutation, + type MailboxMutationResult, +} from "./mailbox-mutation-bridge"; + +export { + createSupervisorBackedTransport, + type SupervisorBackedTransportInbound, +} from "./supervisor-backed-transport"; + +export { + createMailboxWatchRegistry, + type MailboxWatchRegistry, +} from "./mailbox-watch-registry"; + +export { + createChildMailboxReader, + type ChildMailboxReader, +} from "./child-mailbox-reader"; export { createProxyWorkflowRunRepoStore, diff --git a/vendor/intx/workflow-host/src/child/mailbox-mutation-bridge.ts b/vendor/intx/workflow-host/src/child/mailbox-mutation-bridge.ts new file mode 100644 index 000000000..9a9cb2136 --- /dev/null +++ b/vendor/intx/workflow-host/src/child/mailbox-mutation-bridge.ts @@ -0,0 +1,201 @@ +// Child-side mailbox-mutation bridge (INBOUND half of mailbox ownership, +// §3b). +// +// The supervisor is the sole mail owner: it holds the long-lived +// substrate mailbox store and is the only writer to the workflow-run +// ref. A step agent reads its INBOX locally (the supervisor-backed +// transport's read surface opens fresh committed snapshots), but every +// MUTATION of the mailbox -- flag writes (`\Seen`, `\Deleted`, ...) and +// `expunge` -- routes up to the supervisor through this bridge rather +// than being flushed from the child. A second writer flushing the same +// ref from the child would race the supervisor's in-memory mirror and +// break uid / modseq monotonicity, so the child never writes the +// mailbox directly. +// +// Lifecycle of one mutation: +// +// 1. The agent's mail tool (flag or expunge) calls the +// supervisor-backed transport's `setFlags` / `clearFlags` / +// `expunge`. The transport calls `bridge.submit(mutation)`. +// 2. `submit` mints a `requestId`, registers a pending awaiter, and +// emits `mailbox.mutate.request` upstream carrying the op and its +// operands. +// 3. The supervisor applies the op to its owned mailbox store, +// flushes, and replies with `mailbox.mutate.response`. The reply is +// sent only after the flush, so the child's next committed read +// observes the mutation (the same flush-before-signal ordering +// `mailbox.notify` relies on). +// 4. The bridge resolves / rejects the pending awaiter; the +// transport method returns to the mail tool. A supervisor-side +// failure (unknown uid, substrate fault) surfaces as a rejection so +// the agent's mail-tool call fails loudly rather than silently +// dropping the mutation. + +import { getLogger } from "@intx/log"; + +import type { + ControlChannelSender, + ControlPayload, +} from "../ipc/control-channel"; + +const logger = getLogger(["workflow-host", "child", "mailbox-mutation-bridge"]); + +/** + * A mailbox mutation the child asks the supervisor to apply. A flag + * mutation carries the target `uid` and the `flags` to add or remove; an + * `expunge` sweeps every `\Deleted` message in the mailbox and so carries + * neither. + */ +export type MailboxMutation = + | { + runId: string; + mailbox: string; + op: "addFlags" | "removeFlags"; + uid: number; + flags: string[]; + } + | { + runId: string; + mailbox: string; + op: "expunge"; + }; + +/** + * The supervisor's applied-mutation result. `expungedUids` is present + * only for an `expunge` and lists the uids the sweep removed, so the + * agent tool can report how many messages it consumed. + */ +export type MailboxMutationResult = { + expungedUids?: number[]; +}; + +/** + * Bridge surface the child's supervisor-backed transport reaches into. + * `submit` sends a `mailbox.mutate.request` upstream and resolves once + * the supervisor's matching `mailbox.mutate.response` lands. + * `handleResult` is the receiver-side entry point the child's control + * loop invokes when the downstream `mailbox.mutate.response` frame + * arrives. `cancelAll` is the cleanup hook the control loop invokes on + * any exit path so a pending mutation does not leak an awaiter when the + * supervisor has torn the IPC down. + */ +export interface ChildMailboxMutationBridge { + submit(mutation: MailboxMutation): Promise; + handleResult( + data: Extract["data"], + ): void; + cancelAll(reason: string): void; + readonly pendingCount: number; +} + +export interface CreateChildMailboxMutationBridgeOpts { + upstreamSender: ControlChannelSender; + /** + * Optional `requestId` allocator. Production wires a per-instance + * monotonic counter plus a random suffix; tests inject a + * deterministic factory so the upstream frame's `requestId` is + * predictable. + */ + allocateRequestId?: () => string; +} + +type PendingEntry = { + resolve: (value: MailboxMutationResult) => void; + reject: (err: Error) => void; +}; + +/** + * Construct the child-side mailbox-mutation bridge. Pending mutations + * live in a map keyed by `requestId`; the bridge resolves the awaiter + * when the supervisor's matching `mailbox.mutate.response` lands. + */ +export function createChildMailboxMutationBridge( + opts: CreateChildMailboxMutationBridgeOpts, +): ChildMailboxMutationBridge { + const pending = new Map(); + const allocate = opts.allocateRequestId ?? defaultRequestIdAllocator(); + + return { + get pendingCount() { + return pending.size; + }, + async submit(mutation: MailboxMutation): Promise { + const requestId = allocate(); + const resultPromise = new Promise( + (resolve, reject) => { + pending.set(requestId, { resolve, reject }); + }, + ); + const data = + mutation.op === "expunge" + ? { + requestId, + runId: mutation.runId, + mailbox: mutation.mailbox, + op: mutation.op, + } + : { + requestId, + runId: mutation.runId, + mailbox: mutation.mailbox, + op: mutation.op, + uid: mutation.uid, + flags: mutation.flags, + }; + try { + await opts.upstreamSender.send({ + type: "mailbox.mutate.request", + data, + }); + } catch (cause) { + pending.delete(requestId); + const reason = cause instanceof Error ? cause.message : String(cause); + throw new Error( + `workflow-child mailbox mutation: upstream send failed for requestId ${requestId}: ${reason}`, + { cause }, + ); + } + return resultPromise; + }, + handleResult(data) { + const entry = pending.get(data.requestId); + if (entry === undefined) { + logger.warn`mailbox.mutate.response landed with no pending entry; requestId=${data.requestId} dropped`; + return; + } + pending.delete(data.requestId); + if (data.result.ok) { + const result: MailboxMutationResult = {}; + if (data.result.expungedUids !== undefined) { + result.expungedUids = data.result.expungedUids; + } + entry.resolve(result); + return; + } + entry.reject( + new Error( + `workflow-child mailbox mutation (requestId=${data.requestId}) rejected by supervisor: ${data.result.reason}`, + ), + ); + }, + cancelAll(reason: string) { + for (const [requestId, entry] of pending) { + entry.reject( + new Error( + `workflow-child mailbox mutation (requestId=${requestId}) cancelled: ${reason}`, + ), + ); + } + pending.clear(); + }, + }; +} + +function defaultRequestIdAllocator(): () => string { + let counter = 0; + return () => { + counter += 1; + const rand = Math.random().toString(36).slice(2, 10); + return `mm-${String(counter)}-${rand}`; + }; +} diff --git a/vendor/intx/workflow-host/src/child/mailbox-watch-registry.ts b/vendor/intx/workflow-host/src/child/mailbox-watch-registry.ts new file mode 100644 index 000000000..94be54ca8 --- /dev/null +++ b/vendor/intx/workflow-host/src/child/mailbox-watch-registry.ts @@ -0,0 +1,76 @@ +// Child-side mailbox watch registry (INBOUND half of mailbox ownership, +// design §3b). +// +// The supervisor is the sole mail owner: it commits an arrived message to the +// workflow-run substrate mailbox and fires a `mailbox.notify` control frame. +// The child's control loop routes that frame to this registry's `fire`, which +// delivers a typed `exists` `MailboxEvent` to every callback registered for the +// mailbox through `watch`. The step agent's supervisor-backed transport +// implements `MessageTransport.watch` over this registry, so `mail_wait` +// unblocks when new mail lands -- decoupled from the FIFO trigger dispatch that +// resolves a run's first input. +// +// Delivery is ASYNCHRONOUS. A `fire` never invokes a callback synchronously on +// the delivering call stack: it schedules each callback on a microtask, per the +// IMAP IDLE contract the `MailboxEvent` watcher models (MESSAGE.md § Real-Time +// Notification). Delivery re-checks registration at the microtask, so a watcher +// that unsubscribes between `fire` and delivery observes no event. + +import type { MailboxEvent, Unsubscribe } from "@intx/types/runtime"; + +export interface MailboxWatchRegistry { + /** + * Register a callback for a mailbox. Returns an `Unsubscribe` that removes + * it; after unsubscribe the callback observes no further events, including + * one whose `fire` preceded the unsubscribe but whose asynchronous delivery + * had not yet run. + */ + watch(mailbox: string, callback: (event: MailboxEvent) => void): Unsubscribe; + /** + * Deliver a `MailboxEvent` to every callback currently registered for the + * mailbox, each on its own microtask. A no-op when no callback is registered + * for the mailbox. + */ + fire(mailbox: string, event: MailboxEvent): void; +} + +export function createMailboxWatchRegistry(): MailboxWatchRegistry { + const watchers = new Map void>>(); + + return { + watch(mailbox, callback) { + let set = watchers.get(mailbox); + if (set === undefined) { + set = new Set(); + watchers.set(mailbox, set); + } + set.add(callback); + let active = true; + return () => { + // Idempotent: a double-unsubscribe must not remove a same-identity + // callback a later `watch` re-registered. + if (!active) return; + active = false; + const current = watchers.get(mailbox); + if (current === undefined) return; + current.delete(callback); + if (current.size === 0) watchers.delete(mailbox); + }; + }, + fire(mailbox, event) { + const set = watchers.get(mailbox); + if (set === undefined) return; + // Snapshot the callbacks registered at fire time, then deliver each on + // its own microtask so no callback runs synchronously on this call + // stack. Re-check membership at delivery so a callback unsubscribed + // between now and its microtask does not receive the event. + for (const callback of [...set]) { + queueMicrotask(() => { + const current = watchers.get(mailbox); + if (current === undefined || !current.has(callback)) return; + callback(event); + }); + } + }, + }; +} diff --git a/vendor/intx/workflow-host/src/child/outbound-mail-bridge.ts b/vendor/intx/workflow-host/src/child/outbound-mail-bridge.ts index 3f39228a9..ac78f421a 100644 --- a/vendor/intx/workflow-host/src/child/outbound-mail-bridge.ts +++ b/vendor/intx/workflow-host/src/child/outbound-mail-bridge.ts @@ -173,6 +173,7 @@ function projectOutboundMessage( if (message.payload !== undefined) payload.payload = message.payload; if (message.summary !== undefined) payload.summary = message.summary; if (message.inReplyTo !== undefined) payload.inReplyTo = message.inReplyTo; + if (message.references !== undefined) payload.references = message.references; if (message.correlationId !== undefined) { payload.correlationId = message.correlationId; } diff --git a/vendor/intx/workflow-host/src/child/run-child.ts b/vendor/intx/workflow-host/src/child/run-child.ts index c1939c592..e9a2b7db8 100644 --- a/vendor/intx/workflow-host/src/child/run-child.ts +++ b/vendor/intx/workflow-host/src/child/run-child.ts @@ -51,14 +51,13 @@ import { getLogger } from "@intx/log"; import { generateKeyPair } from "@intx/crypto"; -import { base64Decode, hexEncode } from "@intx/types"; +import { hexEncode } from "@intx/types"; import type { Principal, RepoId, RepoStore as SubstrateRepoStore, } from "@intx/hub-sessions/substrate"; -import { readProcessingEntry } from "@intx/hub-sessions/substrate"; import type { DirectorRegistry } from "@intx/agent"; import { rewriteInlineOnTriggerBodies, @@ -76,36 +75,33 @@ import type { StepInvoker, SpawnChildWorkflow, SpawnSuspendableChild, + LoopFnRegistry, WorkflowAuthorizeFn, WorkflowDefinition, WorkflowPark, WorkflowRun, WorkflowRuntimeEnv, } from "@intx/workflow"; -import type { LoopFnRegistry } from "@intx/workflow"; import { baseStepId, + createDefaultActionInvoker, + createInMemoryEffectLedger, createLoopIteration, emptyState, runtimeRun, } from "@intx/workflow"; -import { - createActionHandlerRegistry, - createLoopFnRegistry, - createWorkflowActionInvoker, - createWorkflowRunEffectLedger, -} from "@corbits/workflow-host-actions"; import { createWorkflowHostDrainController, type WorkflowHostDrainController, } from "../drain-controller"; -import type { InferenceSource } from "@intx/types/runtime"; +import type { InferenceSource, MailPartReader } from "@intx/types/runtime"; import type { CredentialDelivery } from "@intx/types/sidecar"; import { createWorkflowRunRepoStore } from "../adapters/repo-store"; import { createWorkflowRunBlobSubstrate } from "../adapters/blob-substrate"; +import { createMailPartReader } from "../adapters/mail-part-store"; import type { HostSpawnSuspendableChild, RunSuspendableChild, @@ -126,20 +122,26 @@ import { type NdjsonReader, type NdjsonWriter, } from "../ipc/index"; +import { runBodyThenCleanup } from "../run-body-then-cleanup"; import { createWorkflowHostSignalChannel } from "../seams/signal-channel"; -import { extractConversationText } from "../conversation-text"; import type { CredentialsSnapshot } from "../supervisor/credentials"; import { hashGrants } from "../supervisor/credentials"; import type { SpawnTimeEnv } from "./env-bootstrap"; import { loadVerifiedWorkflowDefinitionFromClosure } from "./verified-definition-loader"; -import { loadWorkflowDirectorRegistryFromClosure } from "../workflow-definition-loader"; +import { + loadWorkflowActionHandlersFromClosure, + loadWorkflowDirectorRegistryFromClosure, + loadWorkflowLoopFnsFromClosure, +} from "../workflow-definition-loader"; import { discoverInFlightRuns } from "./self-discovery"; import { collectParkedApprovalCorrelations, type LoadParkedApproval, } from "./parked-correlations"; import type { ChildOutboundMailBridge } from "./outbound-mail-bridge"; +import type { ChildMailboxMutationBridge } from "./mailbox-mutation-bridge"; +import type { MailboxWatchRegistry } from "./mailbox-watch-registry"; import { createWarmAgentCache, type WarmAgentCache } from "./warm-agent-cache"; const logger = getLogger(["workflow-host", "child"]); @@ -253,9 +255,9 @@ export function createCredentialsBackedAuthorize( * head collapse for onTrigger body steps (CL-6448): the snapshot is * keyed by the PARENT deployment's stepOrder, so a body step's own id * (`reply`) never appears in it. For a single-step deployment the sole - * entry IS the deployment's grant set — the same head/step collapse + * entry IS the deployment's grant set -- the same head/step collapse * `resolveStepAddress` applies when the body's tools materialize from - * the head deploy tree — so a missed lookup resolves to that sole + * the head deploy tree -- so a missed lookup resolves to that sole * entry. A multi-step deployment gets no collapse: an unknown stepId * against several entries is ambiguous and stays a miss. */ @@ -328,6 +330,7 @@ export type ChildStepInvoker = ( warmCache: WarmAgentCache | undefined, sourcesRef: SourcesSnapshotRef, credentialWiring: CredentialWiring, + mailPartReader: MailPartReader, ) => Promise; /** @@ -433,6 +436,19 @@ export interface RunWorkflowChildBindings { * invocation step settles as a terminal failure, the pre-recovery behavior. */ readParkedApprovalOps?: ReadParkedApprovalOps; + /** + * Mailbox watch registry backing the warm agent's `mail_wait` (INBOUND half + * of mailbox ownership, §3b). The host's substrate factory builds ONE + * instance at child boot, shares it with the step agent's supervisor-backed + * transport (whose `watch` registers callbacks into it), and exposes it here + * so the control loop routes each `mailbox.notify` frame to the same + * registry's `fire`. Optional: a deploy that wires no mail surface (and the + * recursive child-workflow adapter) omits it, and an inbound `mailbox.notify` + * frame is then logged and dropped. A test may instead inject a registry + * directly through `RunWorkflowChildOpts.mailboxWatchRegistry`, which takes + * precedence. + */ + mailboxWatchRegistry?: MailboxWatchRegistry; /** Optional clock override; production wires `() => new Date()`. */ clock?: () => Date; /** Optional id generator override; production wires a monotonic one. */ @@ -475,32 +491,6 @@ export interface RunWorkflowChildBindings { privateKey: Uint8Array; publicKey: Uint8Array; }>; - /** - * CL-6325 (`invokeAction` bind -- see docs/revendor-inventory.md - * "CL-6325: invokeAction bind" and VENDORED.md's - * `vendor/intx/workflow-host` row; MUST be re-applied after the - * concurrent `vendor/intx` re-pin regenerates this file). - * Resolve an action step's `handler` ref to a host `ActionHandler`. - * Awaited ONCE per child, right after the definition re-verify, with - * the resolved `WorkflowDefinition` and the live `CredentialWiring` -- - * so the app-owned registry (`apps/sidecar/src/action-tool-handler.ts`) - * can eagerly materialize every action step's tool closure (failing a - * broken deploy at establish, not mid-run) and scope credentials - * through the same per-step grant wiring agent steps use. Optional so - * a deployment with no `action` steps, and every existing test - * bindings object, need not wire it; absent, the fail-closed - * `createActionHandlerRegistry({})` default refuses every ref loudly. - */ - resolveActionHandler?: (args: { - definition: WorkflowDefinition; - credentialWiring: CredentialWiring; - }) => Promise<(ref: string) => ActionHandler>; - /** - * CL-6325: resolve a loop's `while`/`carry` string refs to pure - * functions. Optional; absent, the fail-closed - * `createLoopFnRegistry({})` default refuses every ref loudly. - */ - loopFns?: LoopFnRegistry; } export interface RunWorkflowChildOpts { @@ -563,6 +553,33 @@ export interface RunWorkflowChildOpts { * but no agent on the child side asked for an outbound send. */ outboundMailBridge?: ChildOutboundMailBridge; + /** + * Optional mailbox-mutation bridge (INBOUND half of mailbox ownership, + * §3b). The step agent's mail tools mutate the INBOX -- flag writes and + * `expunge` -- through a transport whose write methods route through + * this bridge: it emits a `mailbox.mutate.request` upstream control + * frame and resolves once the supervisor's matching + * `mailbox.mutate.response` lands. The control loop routes the + * downstream response frame to the bridge's `handleResult` and invokes + * `cancelAll` on any exit path so a pending mutation does not leak an + * awaiter after the supervisor tears the IPC down. When omitted, + * inbound `mailbox.mutate.response` frames are logged at warn-level and + * dropped -- the wire shape is well-formed but no agent on the child + * side asked for a mutation. + */ + mailboxMutationBridge?: ChildMailboxMutationBridge; + /** + * Optional mailbox watch registry (INBOUND half of mailbox ownership, + * design §3b). The supervisor -- the sole mail owner -- commits an arrived + * message to the workflow-run substrate mailbox and fires a `mailbox.notify` + * control frame; the control loop routes that frame to this registry's + * `fire`, which delivers a typed `exists` `MailboxEvent` to the callbacks the + * step agent's supervisor-backed transport registered through `watch` + * (backing `mail_wait`). When omitted, an inbound `mailbox.notify` frame is + * logged at warn-level and dropped -- the wire shape is well-formed but no + * watcher on the child side asked for inbound events. + */ + mailboxWatchRegistry?: MailboxWatchRegistry; } /** @@ -712,20 +729,35 @@ export async function runWorkflowChild( packageDir: opts.env.closurePackageDir, }); - // CL-6325: resolve the action-handler and loop-fn registries ONCE per - // child, against the re-verified definition and the live credential - // wiring, so the app seam can eagerly materialize every action step's - // tool closure at establish. Absent bindings fall to the fail-closed - // empty registries: an `action` (or `loop`) step that nonetheless runs - // fails loudly at its ref resolve, never silently. - const resolveActionHandler = - opts.bindings.resolveActionHandler !== undefined - ? await opts.bindings.resolveActionHandler({ - definition, - credentialWiring, - }) - : createActionHandlerRegistry({}); - const loopFns = opts.bindings.loopFns ?? createLoopFnRegistry({}); + // Loop `while`/`carry` functions resolve from the pinned closure's + // `interchange.loops` module, loaded alongside the directors and OUTSIDE the + // definition-hash re-verify for the same reason: the approved hash pins each + // ref string and the closure's SRI pins the module bytes. Resolve every loop + // ref reachable from the definition (its own loop bodies, and the lifted + // onTrigger/childWorkflow bodies, which share this same registry at runtime) + // eagerly here, so a deployment that declares a loop whose fn the closure + // does not export fails at establish rather than mid-run. + const loopFns = await loadWorkflowLoopFnsFromClosure({ + packageDir: opts.env.closurePackageDir, + }); + eagerlyResolveLoopFns( + [definition, ...bodiesMap.values(), ...childBodiesMap.values()], + loopFns, + ); + + // Action handlers resolve from the pinned closure's `interchange.actions` + // module, on the same terms as loop fns. Resolve every action handler ref + // reachable from the definition eagerly here (recursing into loop bodies, + // where an action body is the common case), so a deployment that declares an + // action whose handler the closure does not export fails at establish rather + // than mid-run. + const actionResolver = await loadWorkflowActionHandlersFromClosure({ + packageDir: opts.env.closurePackageDir, + }); + eagerlyResolveActionHandlers( + [definition, ...bodiesMap.values(), ...childBodiesMap.values()], + actionResolver, + ); // Suspendable-child (onTrigger body) resolver, selected ONCE per deployment: // the bodies map is immutable and the per-run `onEvent` is injected later in @@ -759,6 +791,12 @@ export async function runWorkflowChild( runSuspendableChild: executor, authorize, credentialWiring, + mailPartReader: createMailPartReader({ + substrate: opts.bindings.substrate, + repoId: opts.bindings.workflowRunRepoId, + principal: opts.bindings.principal, + ref: opts.bindings.workflowRunRef, + }), }); } @@ -855,6 +893,8 @@ export async function runWorkflowChild( directors, suspendableChildHost, spawnChild, + loopFns, + actionResolver, clock, newId, drainController, @@ -867,8 +907,6 @@ export async function runWorkflowChild( }); }, upstreamSender, - resolveActionHandler, - loopFns, }); const handle = runtimeRun(definition, env, { runId: run.runId, @@ -938,7 +976,16 @@ export async function runWorkflowChild( }, }); - try { + // Resolve the mailbox watch registry the control loop routes `mailbox.notify` + // frames to. Production wires it on the bindings (the substrate factory builds + // one instance and shares it with the warm agent's supervisor-backed + // transport); a test may inject one directly through the opts, which wins. + // Both absent leaves inbound `mailbox.notify` frames logged and dropped -- a + // deploy with no wired mail surface. + const mailboxWatchRegistry = + opts.mailboxWatchRegistry ?? opts.bindings.mailboxWatchRegistry; + + const runControlLoop = async (): Promise => { for await (const payload of iter) { if ( await handleControlPayload(payload, { @@ -951,6 +998,8 @@ export async function runWorkflowChild( directors, suspendableChildHost, spawnChild, + loopFns, + actionResolver, clock, newId, eventSender, @@ -962,14 +1011,18 @@ export async function runWorkflowChild( sourcesRef, credentialMaterialRef, credentialWiring, - resolveActionHandler, - loopFns, ...(opts.substrateWriteBridge !== undefined ? { substrateWriteBridge: opts.substrateWriteBridge } : {}), ...(opts.outboundMailBridge !== undefined ? { outboundMailBridge: opts.outboundMailBridge } : {}), + ...(opts.mailboxMutationBridge !== undefined + ? { mailboxMutationBridge: opts.mailboxMutationBridge } + : {}), + ...(mailboxWatchRegistry !== undefined + ? { mailboxWatchRegistry } + : {}), }) ) { // shutdown received; the shutdown case already cancelled any @@ -977,7 +1030,9 @@ export async function runWorkflowChild( break; } } - } finally { + }; + + const cleanupControlLoop = async (): Promise => { // Any exit path -- clean (iterator end), dirty (thrown error), // shutdown (already cancelled, repeat is a no-op on an empty map) // -- cancels every still-pending substrate write so the runtime @@ -994,6 +1049,15 @@ export async function runWorkflowChild( if (opts.outboundMailBridge !== undefined) { opts.outboundMailBridge.cancelAll("workflow-child control loop exited"); } + // Same contract for mailbox mutations: a step agent's flag or + // `expunge` still awaiting the supervisor's `mailbox.mutate.response` + // when the control loop exits must surface a structured rejection + // rather than hang on a torn-down channel. + if (opts.mailboxMutationBridge !== undefined) { + opts.mailboxMutationBridge.cancelAll( + "workflow-child control loop exited", + ); + } // Evict the warm-agent cache (design §3b) on every exit path: // graceful (shutdown frame -> iterator end), dirty (thrown error), // or the control channel closing. Eviction runs the wrapped @@ -1005,7 +1069,18 @@ export async function runWorkflowChild( if (warmCache !== undefined) { await warmCache.evictAll("workflow-child control loop exited"); } - } + }; + + // Run the control loop, then always run the cleanup above. A failing + // eviction (the wrapped agent close rejects when a plugin/LSP disposer + // fails) surfaces on a clean exit, but must not mask a control-loop + // error already unwinding -- so it is logged, not rethrown, in that case. + await runBodyThenCleanup( + runControlLoop, + cleanupControlLoop, + (cause) => + logger.error`workflow-child: warm-agent eviction failed while unwinding a control-loop error; surfacing the control-loop error, eviction failure: ${cause instanceof Error ? cause.message : String(cause)}`, + ); return { resumedRunIds, @@ -1031,6 +1106,8 @@ async function handleControlPayload( directors: DirectorRegistry; suspendableChildHost: HostSpawnSuspendableChild | undefined; spawnChild: SpawnChildWorkflow; + loopFns: LoopFnRegistry; + actionResolver: (ref: string) => ActionHandler; clock: () => Date; newId: (prefix: string) => string; eventSender: ReturnType; @@ -1042,10 +1119,10 @@ async function handleControlPayload( sourcesRef: SourcesSnapshotRef; credentialMaterialRef: CredentialMaterialRef; credentialWiring: CredentialWiring; - resolveActionHandler: (ref: string) => ActionHandler; - loopFns: LoopFnRegistry; substrateWriteBridge?: SubstrateWriteResponseSink; outboundMailBridge?: ChildOutboundMailBridge; + mailboxMutationBridge?: ChildMailboxMutationBridge; + mailboxWatchRegistry?: MailboxWatchRegistry; }, ): Promise { switch (payload.type) { @@ -1069,23 +1146,15 @@ async function handleControlPayload( ctx.triggeredRunIds.push(payload.data.runId); return false; } - // Resolve the inbound mail bytes for this messageId from the - // claim-check processing entry the supervisor created when it - // dequeued the message. The bytes become the run's trigger - // payload; the one-step workflow's first step defaults its input - // selector to `trigger.payload` (defineWorkflow's default-input - // convention), so the step input resolves to the inbound message - // and `agent.send` receives it. A missing or unreadable entry - // surfaces loudly -- the run cannot proceed without its input, - // and silently running the agent with empty input would mask a - // real mailbox-ownership failure. - const triggerPayload = await resolveTriggerPayload({ - substrate: ctx.bindings.substrate, - principal: ctx.bindings.principal, - workflowRunRepoId: ctx.bindings.workflowRunRepoId, - mailboxAddress: ctx.env.mailboxAddress, - messageId: payload.data.messageId, - }); + // The supervisor resolved the inbound mail to the run's input (the + // conversation text plus references to attachment bytes it committed to + // the workflow-run substrate) and shipped it in the frame. It becomes + // the run's trigger payload; the one-step workflow's first step defaults + // its input selector to `trigger.payload` (defineWorkflow's default-input + // convention), so the step input resolves to the inbound message and + // `agent.send` receives it once its attachment references are resolved to + // bytes at send time. + const triggerPayload = payload.data.payload; const env = buildRuntimeEnv({ runId: payload.data.runId, bindings: ctx.bindings, @@ -1094,6 +1163,8 @@ async function handleControlPayload( directors: ctx.directors, suspendableChildHost: ctx.suspendableChildHost, spawnChild: ctx.spawnChild, + loopFns: ctx.loopFns, + actionResolver: ctx.actionResolver, clock: ctx.clock, newId: ctx.newId, drainController: ctx.drainController, @@ -1106,8 +1177,6 @@ async function handleControlPayload( }); }, upstreamSender: ctx.upstreamSender, - resolveActionHandler: ctx.resolveActionHandler, - loopFns: ctx.loopFns, }); const handle: WorkflowRun = runtimeRun(ctx.definition, env, { runId: payload.data.runId, @@ -1375,6 +1444,45 @@ async function handleControlPayload( ctx.outboundMailBridge.handleResult(payload.data); return false; } + case "mailbox.notify": { + // Route the supervisor's new-mail notification to the child's watch + // registry so a step agent's `watch`/`mail_wait` observes the arrival. + // A notify that lands without a registry means no watcher on the child + // side asked for inbound events; log and drop rather than throwing so + // the runtime keeps progressing (mirrors the `outbound.result` arm). + if (ctx.mailboxWatchRegistry === undefined) { + logger.warn`workflow-child mailbox.notify received without a watch registry wired; mailbox=${payload.data.mailbox} uid=${String(payload.data.uid)} dropped`; + return false; + } + ctx.mailboxWatchRegistry.fire(payload.data.mailbox, { + type: "exists", + uid: payload.data.uid, + headers: payload.data.headers, + }); + return false; + } + case "mailbox.mutate.request": { + // `mailbox.mutate.request` is the child->supervisor mailbox-mutation + // request frame; receiving one on the child's downstream side is a + // protocol violation in the same shape as a downstream + // `outbound.message`. + throw new Error( + "workflow-child received a `mailbox.mutate.request` frame on its inbound control channel; this is a child-only upstream payload", + ); + } + case "mailbox.mutate.response": { + // Route the supervisor's applied-mutation result to the + // mailbox-mutation bridge if one is wired. A response that lands + // without an active bridge means a stale supervisor frame for which + // no awaiter exists; log and drop rather than throwing so the + // runtime keeps progressing (mirrors the `outbound.result` arm). + if (ctx.mailboxMutationBridge === undefined) { + logger.warn`workflow-child mailbox.mutate.response received without a bridge wired; requestId=${payload.data.requestId} dropped`; + return false; + } + ctx.mailboxMutationBridge.handleResult(payload.data); + return false; + } case "substrate.merge.request": { // Route the request to the substrate-write bridge if one is // wired. A request that lands without an active bridge means a @@ -1447,12 +1555,58 @@ async function handleControlPayload( * `BlobSubstrate` and `SignalChannel` because both are per-run by * shape; the substrate handle and per-deployment `RepoStore` adapter * are shared across runs. - * - * Exported (CL-6325 delta) so a host's runtime-env-level probe can - * exercise the production `invokeAction`/`effects`/`loopFns` bind - * without standing up the full control-channel harness. */ -export function buildRuntimeEnv(args: { +/** + * Force-resolve every loop `while`/`carry` ref reachable from these definitions + * against the registry, so a missing loop fn surfaces at establish rather than + * when the loop is first driven mid-run. Recurses into a loop's inline body (a + * nested loop resolves against the same shared registry). The caller passes the + * lifted onTrigger/childWorkflow bodies separately, since those are `{ ref }` in + * the top-level definition and this walk does not descend into them. + */ +function eagerlyResolveLoopFns( + definitions: readonly WorkflowDefinition[], + loopFns: LoopFnRegistry, +): void { + const visit = (def: WorkflowDefinition): void => { + for (const step of Object.values(def.steps)) { + if (step.kind === "loop") { + // Each call throws (fail closed) if the ref names no export, or an + // export that is not a function. + loopFns(step.while); + loopFns(step.carry); + visit(step.body); + } + } + }; + for (const def of definitions) visit(def); +} + +/** + * Force-resolve every `action` handler ref reachable from these definitions + * against the resolver, so a missing action handler surfaces at establish + * rather than when the action is first invoked mid-run. Recurses into loop + * bodies (an action body is the common loop shape). The caller passes the + * lifted onTrigger/childWorkflow bodies separately, as with loop fns. + */ +function eagerlyResolveActionHandlers( + definitions: readonly WorkflowDefinition[], + actionResolver: (ref: string) => ActionHandler, +): void { + const visit = (def: WorkflowDefinition): void => { + for (const step of Object.values(def.steps)) { + if (step.kind === "action") { + // Throws (fail closed) if the handler names no export, or a non-function. + actionResolver(step.handler); + } else if (step.kind === "loop") { + visit(step.body); + } + } + }; + for (const def of definitions) visit(def); +} + +function buildRuntimeEnv(args: { runId: string; bindings: RunWorkflowChildBindings; runtimeRepoStore: ReturnType; @@ -1460,6 +1614,8 @@ export function buildRuntimeEnv(args: { directors: DirectorRegistry; suspendableChildHost: HostSpawnSuspendableChild | undefined; spawnChild: SpawnChildWorkflow; + loopFns: LoopFnRegistry; + actionResolver: (ref: string) => ActionHandler; clock: () => Date; newId: (prefix: string) => string; drainController: DrainController; @@ -1468,15 +1624,6 @@ export function buildRuntimeEnv(args: { credentialWiring: CredentialWiring; onEvent: (event: EventPayload) => void; upstreamSender: ControlChannelSender; - /** - * CL-6325 (`invokeAction`/`loopFns` bind, re-apply after the concurrent - * `vendor/intx` re-pin -- see `RunWorkflowChildBindings.resolveActionHandler`). - * Both registries are resolved once per child in `runWorkflowChild`; - * this per-run builder binds them into the env alongside the per-run - * effect ledger. - */ - resolveActionHandler: (ref: string) => ActionHandler; - loopFns: LoopFnRegistry; }): WorkflowRuntimeEnv { const signalChannel = createWorkflowHostSignalChannel({ repoStore: args.bindings.substrate, @@ -1495,6 +1642,17 @@ export function buildRuntimeEnv(args: { runId: args.runId, ref: args.bindings.workflowRunRef, }); + // Reader for inbound-mail parts, a sibling of `blobs` over the same + // workflow-run repo. The step invoker resolves a `Mail` part's `ref` to its + // bytes through it at `agent.send` time; the supervisor committed the bytes + // before the trigger. Deployment-scoped (the ref encodes the owning run), so + // one reader resolves any run's parts. + const mailPartReader = createMailPartReader({ + substrate: args.bindings.substrate, + repoId: args.bindings.workflowRunRepoId, + principal: args.bindings.principal, + ref: args.bindings.workflowRunRef, + }); // Wrap the step invoker so every `InferenceEvent` the harness emits // funnels through the per-run `onEvent` closure, which forwards // the event up the HMAC-authenticated event channel. The wrap is @@ -1510,6 +1668,7 @@ export function buildRuntimeEnv(args: { args.warmCache, args.sourcesRef, args.credentialWiring, + mailPartReader, ); }; // Adapt the host binding (which takes the run's `onEvent` sink) down to the @@ -1523,24 +1682,6 @@ export function buildRuntimeEnv(args: { hostSuspendable === undefined ? undefined : (spawnInput) => hostSuspendable(spawnInput, args.onEvent); - // CL-6325: `invokeAction`/`effects`/`loopFns` bind. Mirrors - // gtm-workbench's run-child.ts -- wired unconditionally: the registries - // are fail-closed, so an action-free deployment resolves nothing and an - // undeclared ref fails its run loudly. MUST be re-applied after the - // concurrent `vendor/intx` re-pin regenerates this file (see - // docs/revendor-inventory.md). - const effects = createWorkflowRunEffectLedger({ - substrate: args.bindings.substrate, - repoId: args.bindings.workflowRunRepoId, - principal: args.bindings.principal, - runId: args.runId, - ref: args.bindings.workflowRunRef, - }); - const invokeAction = createWorkflowActionInvoker({ - authorize: args.authorize, - effects, - resolveHandler: args.resolveActionHandler, - }); const env: WorkflowRuntimeEnv = { repoStore: args.runtimeRepoStore, scheduler: args.bindings.scheduler, @@ -1550,6 +1691,10 @@ export function buildRuntimeEnv(args: { authorize: args.authorize, invokeStep, spawnChild: args.spawnChild, + // Resolve a loop's `while`/`carry` refs against the closure's loop module. + // Every ref was force-resolved at establish, so a lookup here cannot fail + // for a definition that passed startup. + loopFns: args.loopFns, // Wire the suspendable-child seam only when the host supplied it; a child // that never runs an onTrigger section omits the binding, and the runtime // body fails loud if a workflow reaches a section the env did not wire. @@ -1571,15 +1716,30 @@ export function buildRuntimeEnv(args: { ...(args.bindings.readParkedApprovalOps !== undefined ? { readParkedApprovalOps: args.bindings.readParkedApprovalOps } : {}), - // CL-6325: bind, re-apply after the concurrent re-pin. - effects, - invokeAction, - loopFns: args.loopFns, }; - // Wired after construction because the loop-iteration runner closes - // over the env it belongs to, so each iteration's child run shares - // the parent's repoStore, blobs, and effect ledger. + // Run one loop iteration as a child run against the shared store. Assigned + // AFTER env construction because it closes over `env`, so each iteration's + // child run shares this run's repoStore + blobs (mirrors runLocal). env.runLoopIteration = createLoopIteration(env); + + // Action handlers run against a per-run effect ledger. The ledger is + // IN-MEMORY, and that is correct -- not a shortcut -- on the deployed store: + // appends are immediate-durable single-ref commits, `runAction` flushes + // StepStarted durably before the effect, and the runtime never re-invokes a + // crashed action (a mid-action crash settles the step failed; a loop-body + // action leaves a non-empty child log that fails the iteration loud rather + // than re-running). So the ledger is never consulted across a crash; its + // cross-crash exactly-once rests on that store-consistency invariant, which + // the store layer owns. A durable ledger here would re-enforce a constraint + // a lower layer already guarantees. Within a single invocation the ledger + // still dedups a handler that performs the same effect twice. + const effects = createInMemoryEffectLedger(); + env.effects = effects; + env.invokeAction = createDefaultActionInvoker( + args.authorize, + effects, + args.actionResolver, + ); return env; } @@ -1751,47 +1911,6 @@ function reclaimRunStorageIfCold(opts: { }); } -/** - * Resolve the run's trigger payload from the inbound mail message the - * supervisor moved to the claim-check processing queue. Reads the - * processing entry by messageId (a read-only snapshot of the - * `refs/heads/events` tip that cannot race the supervisor's - * `markConsumed` write), decodes the inlined raw MIME bytes, and - * extracts the conversation text the agent's `agent.send` receives. - * - * Defensive: a missing processing entry, an entry with no inlined - * bytes, or unparseable mail all throw. The run cannot proceed without - * its input, and a placeholder would mask a mailbox-ownership failure. - */ -async function resolveTriggerPayload(args: { - substrate: SubstrateRepoStore; - principal: Principal; - workflowRunRepoId: RepoId; - mailboxAddress: string; - messageId: string; -}): Promise { - const entry = await readProcessingEntry( - args.substrate, - args.principal, - args.workflowRunRepoId, - args.mailboxAddress, - args.messageId, - ); - if (entry === null) { - throw new Error( - `workflow-child trigger.fire: no claim-check processing entry for messageId ${args.messageId} at ${args.mailboxAddress}; the run has no input to deliver to the agent`, - ); - } - const rawMessageBase64 = entry.envelope.rawMessage; - if (rawMessageBase64 === undefined) { - throw new Error( - `workflow-child trigger.fire: processing entry for messageId ${args.messageId} carries no inlined rawMessage; the supervisor must inline the inbound mail bytes for the child to deliver them as the step input`, - ); - } - const raw = base64Decode(rawMessageBase64); - return extractConversationText(raw, args.messageId); -} - function defaultClock(): Date { return new Date(); } diff --git a/vendor/intx/workflow-host/src/child/supervisor-backed-transport.ts b/vendor/intx/workflow-host/src/child/supervisor-backed-transport.ts index 6738dc690..527ef1a0e 100644 --- a/vendor/intx/workflow-host/src/child/supervisor-backed-transport.ts +++ b/vendor/intx/workflow-host/src/child/supervisor-backed-transport.ts @@ -1,28 +1,51 @@ // Supervisor-backed `MessageTransport` for a unified-host step agent -// (OUTBOUND half of mailbox ownership, §3a). +// (both halves of mailbox ownership, §3a OUTBOUND and §3b INBOUND). // // Under the unified host the supervisor is the sole mail owner: it holds // the durable inbox and the host transport against which the agent's // address is registered with its signing key. The step agent therefore -// does NOT subscribe its own transport for inbound mail (the supervisor -// delivers inputs via the step path) and does NOT hold a signing key to -// send outbound mail. Its mail tools are backed by this transport: +// does NOT hold a signing key to send outbound mail, and it does NOT own +// the host-side inbox directly. Its mail tools are backed by this +// transport: // -// - INBOUND is a no-op. `watch` returns a no-op unsubscribe and never -// fires; the supervisor delivers the agent's input as the step -// input, not through the agent's own mailbox. The IMAP read surface -// (`search`, `fetchFull`, `fetchHeaders`, ...) throws: the agent -// owns no mailbox in the unified host, so a read against one is a -// programming error, surfaced loudly rather than returning a -// silently-empty result that would hide the missing inbound surface. -// - OUTBOUND (`send` / `append`) routes through the supervisor over -// the control IPC via the outbound-mail bridge. The supervisor -// performs the actual signed send through the host transport, so the -// outbound mail carries the agent's signature with full parity to -// the in-process path. The agent never holds the key. +// - INBOUND is a functional local IMAP read surface once the sidecar +// wires it (the `inbound` constructor argument). The supervisor +// commits an arrived message to the deployment's workflow-run +// substrate mailbox (`mailbox/INBOX/`) and fires a `mailbox.notify` +// control frame. The read surface (`search`, `thread`, +// `fetchHeaders`, `fetchStructure`, `fetchPart`, `fetchFull`, `sync`, +// `getMailboxStatus`) answers by opening a fresh committed snapshot of +// that mailbox through the child mailbox reader and running the +// `@intx/mailbox` pure query functions over it -- local, no hub or +// IPC round-trip. `watch` registers into the child watch registry, so +// `mail_wait` unblocks when the routed `mailbox.notify` fires. The +// WRITE methods (`setFlags` / `clearFlags` / `expunge`) do NOT touch +// the local read surface: they route up to the supervisor through the +// mailbox-mutation bridge (a `mailbox.mutate.request` frame), which +// applies the mutation to the supervisor's owned store and replies. +// The child never flushes the run ref, so it never races the +// supervisor's mirror. The agent owns only the `INBOX`, so every +// inbound method rejects a request for any other mailbox rather than +// silently serving `INBOX` under the wrong name. When the sidecar +// constructs the transport without the `inbound` argument, the inbound +// methods throw a clear "not wired" error rather than answer against a +// missing surface. +// - OUTBOUND (`send`) routes through the supervisor over the control +// IPC via the outbound-mail bridge. The supervisor performs the +// actual signed send through the host transport, so the outbound mail +// carries the agent's signature with full parity to the in-process +// path. The agent never holds the key. +// +// A handful of methods stay unsupported and throw: they act on a resource +// the unified-host agent does not own. `append` and the mailbox-management +// methods (`listMailboxes` / `createMailbox` / `deleteMailbox`) target a +// mailbox the agent does not own; `move` / `copy` need a second mailbox it +// does not own; and the distribution-list methods are unimplemented across +// every transport. import type { BodyStructure, + CryptoProvider, InboundMessage, ListInfo, Mailbox, @@ -41,25 +64,108 @@ import type { Unsubscribe, } from "@intx/types/runtime"; +import { + executeSearch, + executeThread, + fetchFull as doFetchFull, + fetchHeaders as doFetchHeaders, + fetchPart as doFetchPart, + fetchStructure as doFetchStructure, +} from "@intx/mailbox"; + +import { deriveWorkflowRunId } from "@intx/types"; + +import { MAILBOX_INBOX_DIR } from "../adapters/substrate-mailbox-store"; +import type { ChildMailboxReader } from "./child-mailbox-reader"; +import type { ChildMailboxMutationBridge } from "./mailbox-mutation-bridge"; +import type { MailboxWatchRegistry } from "./mailbox-watch-registry"; import type { ChildOutboundMailBridge } from "./outbound-mail-bridge"; +/** + * The dependencies backing the transport's whole inbox capability: the local + * IMAP READ surface (`reader` / `watchRegistry` / `getCrypto`) that resolves + * against the deployment's substrate mailbox, plus the routed-WRITE channel + * (`mutationBridge`) that carries flag writes and expunge up to the supervisor. + * The sidecar wires the bundle only for a build that owns an inbound mailbox + * (the warm agent); a build without it has no inbox and every inbound method + * throws a clear "not wired" error. Reads are local; writes route upstream -- + * the child never flushes the run ref, so it never races the supervisor. + */ +export interface SupervisorBackedTransportInbound { + /** + * Opens a fresh committed snapshot of the deployment's substrate `INBOX`. + * Every inbound read opens a new snapshot, so a read taken after a + * `mailbox.notify` -- or after a routed write the supervisor flushed before + * replying -- observes the committed state. + */ + reader: ChildMailboxReader; + /** + * The registry the child's control loop fires `mailbox.notify` into. It must + * be the same instance `runWorkflowChild` routes the frame to, so a `watch` + * installed here observes the supervisor's notification. + */ + watchRegistry: MailboxWatchRegistry; + /** + * Resolve a sender address to its `CryptoProvider` so `fetchFull` can verify + * the message signature. Returns `undefined` when no key is known for the + * sender, in which case the signature status is reported as `unknown`. + */ + getCrypto: (fromAddress: string) => CryptoProvider | undefined; + /** + * The upstream channel the write methods route through. `setFlags` / + * `clearFlags` / `expunge` call `mutationBridge.submit`, which emits a + * `mailbox.mutate.request` and resolves once the supervisor applies the + * mutation to its owned store and replies. Bundled with the read surface + * because the write methods and the reads share one presence condition: + * this agent owns an inbox, or it owns none. + */ + mutationBridge: ChildMailboxMutationBridge; +} + /** * Construct a `MessageTransport` whose outbound side routes through the - * supervisor (via `bridge`) and whose inbound side is inert. `address` - * is the agent's mail address; the supervisor signs the outbound mail as - * this address through the host transport, so it must be the address the - * host registered the agent's `CryptoProvider` against. + * supervisor (via `bridge`) and whose inbound side is a local IMAP read + * surface over `inbound`. `address` is the agent's mail address; the + * supervisor signs the outbound mail as this address through the host + * transport, so it must be the address the host registered the agent's + * `CryptoProvider` against. When `inbound` is omitted, the inbound methods + * throw a clear "not wired" error; the sidecar supplies it once the child's + * mailbox reader and watch registry are threaded through. */ export function createSupervisorBackedTransport( bridge: ChildOutboundMailBridge, address: string, + inbound?: SupervisorBackedTransportInbound, ): MessageTransport { - function inboundUnsupported(method: string): never { + function unsupported(method: string): never { throw new Error( - `supervisor-backed transport: ${method} is not supported for unified-host step agent ${address}; the supervisor owns the mailbox and delivers inbound mail as the step input`, + `supervisor-backed transport: ${method} is not supported for unified-host step agent ${address}; the supervisor owns the mailbox and the agent owns only its own ${MAILBOX_INBOX_DIR}`, ); } + // Return the wired inbound surface, or fail loud when the sidecar + // constructed the transport without it -- an inbound read against a missing + // surface is a wiring error, not a silently-empty result. + function requireInbound(method: string): SupervisorBackedTransportInbound { + if (inbound === undefined) { + throw new Error( + `supervisor-backed transport: ${method} needs the inbound surface, but it is not wired for unified-host step agent ${address}; the sidecar must construct the transport with its mailbox reader, watch registry, and crypto`, + ); + } + return inbound; + } + + // The unified-host agent owns exactly one mailbox, the substrate `INBOX` + // the reader opens. Reject any other name rather than serve `INBOX` under + // it, which would return the wrong mailbox's messages mislabeled. + function requireInbox(mailbox: string): void { + if (mailbox !== MAILBOX_INBOX_DIR) { + throw new Error( + `supervisor-backed transport: unified-host step agent ${address} owns only the "${MAILBOX_INBOX_DIR}" mailbox; "${mailbox}" is not available`, + ); + } + } + return { async send( message: OutboundMessage, @@ -77,82 +183,133 @@ export function createSupervisorBackedTransport( // `append` writes into a mailbox the agent owns; in the unified // host the agent owns none. The mail tools do not append (they // `send`), so a reachable `append` is a programming error. - return inboundUnsupported("append"); + return unsupported("append"); }, async listMailboxes(_signal?: AbortSignal): Promise { - return inboundUnsupported("listMailboxes"); + return unsupported("listMailboxes"); }, async createMailbox( _name: string, _signal?: AbortSignal, ): Promise { - return inboundUnsupported("createMailbox"); + return unsupported("createMailbox"); }, async deleteMailbox(_name: string, _signal?: AbortSignal): Promise { - return inboundUnsupported("deleteMailbox"); + return unsupported("deleteMailbox"); }, async getMailboxStatus( - _name: string, + name: string, _signal?: AbortSignal, ): Promise { - return inboundUnsupported("getMailboxStatus"); + const { reader } = requireInbound("getMailboxStatus"); + requireInbox(name); + const store = await reader.open(); + const unseen = store.messages.filter( + (m) => !m.flags.has("\\Seen"), + ).length; + return { + total: store.messages.length, + unseen, + recent: 0, + uidNext: store.uidNext, + uidValidity: store.uidValidity, + highestModSeq: store.highestModSeq, + }; }, async search( - _mailbox: string, - _query: SearchQuery, + mailbox: string, + query: SearchQuery, _signal?: AbortSignal, ): Promise { - return inboundUnsupported("search"); + const { reader } = requireInbound("search"); + requireInbox(mailbox); + const store = await reader.open(); + return await executeSearch(mailbox, store, query); }, async thread( - _mailbox: string, - _algorithm: "references" | "orderedsubject", - _query?: SearchQuery, + mailbox: string, + algorithm: "references" | "orderedsubject", + query?: SearchQuery, _signal?: AbortSignal, ): Promise { - return inboundUnsupported("thread"); + const { reader } = requireInbound("thread"); + requireInbox(mailbox); + const store = await reader.open(); + return await executeThread(mailbox, store, algorithm, query); }, async fetchHeaders( - _ref: MessageRef, + ref: MessageRef, _signal?: AbortSignal, ): Promise { - return inboundUnsupported("fetchHeaders"); + const { reader } = requireInbound("fetchHeaders"); + requireInbox(ref.mailbox); + const store = await reader.open(); + return await doFetchHeaders(ref, store); }, async fetchStructure( - _ref: MessageRef, + ref: MessageRef, _signal?: AbortSignal, ): Promise { - return inboundUnsupported("fetchStructure"); + const { reader } = requireInbound("fetchStructure"); + requireInbox(ref.mailbox); + const store = await reader.open(); + return await doFetchStructure(ref, store); }, async fetchPart( - _ref: MessageRef, - _partPath: string, + ref: MessageRef, + partPath: string, _signal?: AbortSignal, ): Promise { - return inboundUnsupported("fetchPart"); + const { reader } = requireInbound("fetchPart"); + requireInbox(ref.mailbox); + const store = await reader.open(); + return await doFetchPart(ref, partPath, store); }, async fetchFull( - _ref: MessageRef, + ref: MessageRef, _signal?: AbortSignal, ): Promise { - return inboundUnsupported("fetchFull"); + const { reader, getCrypto } = requireInbound("fetchFull"); + requireInbox(ref.mailbox); + const store = await reader.open(); + return await doFetchFull(ref, store, getCrypto); }, async setFlags( - _ref: MessageRef, - _flags: string[], + ref: MessageRef, + flags: string[], _signal?: AbortSignal, ): Promise { - return inboundUnsupported("setFlags"); + const { mutationBridge } = requireInbound("setFlags"); + requireInbox(ref.mailbox); + // Route the flag write to the supervisor -- the sole mailbox writer -- + // rather than flushing a second store against the run ref. `submit` + // resolves only after the supervisor flushes, so a subsequent read + // observes the flag. + await mutationBridge.submit({ + runId: deriveWorkflowRunId(address), + mailbox: ref.mailbox, + op: "addFlags", + uid: ref.uid, + flags, + }); }, async clearFlags( - _ref: MessageRef, - _flags: string[], + ref: MessageRef, + flags: string[], _signal?: AbortSignal, ): Promise { - return inboundUnsupported("clearFlags"); + const { mutationBridge } = requireInbound("clearFlags"); + requireInbox(ref.mailbox); + await mutationBridge.submit({ + runId: deriveWorkflowRunId(address), + mailbox: ref.mailbox, + op: "removeFlags", + uid: ref.uid, + flags, + }); }, async move( @@ -160,40 +317,88 @@ export function createSupervisorBackedTransport( _toMailbox: string, _signal?: AbortSignal, ): Promise { - return inboundUnsupported("move"); + return unsupported("move"); }, async copy( _ref: MessageRef, _toMailbox: string, _signal?: AbortSignal, ): Promise { - return inboundUnsupported("copy"); + return unsupported("copy"); }, async expunge( - _mailbox: string, + mailbox: string, _signal?: AbortSignal, ): Promise<{ expungedUids: number[] }> { - return inboundUnsupported("expunge"); + const { mutationBridge } = requireInbound("expunge"); + requireInbox(mailbox); + // Route to the supervisor, which sweeps every `\Deleted` message out of + // its owned INBOX and returns the swept uids. The expunged bytes survive + // in git history (a workflow-run repo's objects are never GC'd), so the + // replication check permits the deletion. A caller expunging after a + // `setFlags(\Deleted)` MUST await the two in sequence -- the supervisor + // applies mutations in arrival order, so an unawaited (concurrent) pair + // could let the sweep run before the flag is set and miss the message. + const result = await mutationBridge.submit({ + runId: deriveWorkflowRunId(address), + mailbox, + op: "expunge", + }); + return { expungedUids: result.expungedUids ?? [] }; }, watch( - _mailbox: string, - _callback: (event: MailboxEvent) => void, + mailbox: string, + callback: (event: MailboxEvent) => void, ): Unsubscribe { - // Inbound delivery is a no-op: the supervisor delivers the agent's - // input as the step input, not through the agent's mailbox. The - // watch never fires; return a no-op unsubscribe so a mail tool that - // installs a watch (mail_wait) does not throw at install time but - // also never observes a spurious event. - return () => undefined; + const { watchRegistry } = requireInbound("watch"); + requireInbox(mailbox); + // The supervisor -- the sole mail owner -- fires `mailbox.notify` into + // the registry when new mail lands; the registry delivers the typed + // event to this callback. `mail_wait` installs the watch and unblocks on + // the first delivery. + return watchRegistry.watch(mailbox, callback); }, async sync( - _mailbox: string, - _knownState: SyncState, + mailbox: string, + knownState: SyncState, _signal?: AbortSignal, ): Promise { - return inboundUnsupported("sync"); + const { reader } = requireInbound("sync"); + requireInbox(mailbox); + const store = await reader.open(); + const result = store.sync({ + uidValidity: knownState.uidValidity, + highestModSeq: knownState.highestModSeq, + }); + if (result.resync) { + return { + vanished: [], + changed: [], + newMessages: result.messages.map((m) => ({ uid: m.uid, mailbox })), + fullResyncRequired: true, + }; + } + // The backing reports every message whose modseq advanced past the + // client's known state as `changed`. Split it against the client's known + // `uidNext`: a uid at or beyond it is a new arrival, one below it is a + // flag change on a message the client already held. + const newMessages: MessageRef[] = []; + const changed: { uid: number; flags: string[] }[] = []; + for (const m of result.changed) { + if (m.uid >= knownState.uidNext) { + newMessages.push({ uid: m.uid, mailbox }); + } else { + changed.push({ uid: m.uid, flags: Array.from(m.flags) }); + } + } + return { + vanished: [...result.vanished], + changed, + newMessages, + fullResyncRequired: false, + }; }, async createList( @@ -201,27 +406,27 @@ export function createSupervisorBackedTransport( _name: string, _signal?: AbortSignal, ): Promise { - return inboundUnsupported("createList"); + return unsupported("createList"); }, async listMembers( _address: string, _signal?: AbortSignal, ): Promise { - return inboundUnsupported("listMembers"); + return unsupported("listMembers"); }, async subscribe( _listAddress: string, _subscriberAddress: string, _signal?: AbortSignal, ): Promise { - return inboundUnsupported("subscribe"); + return unsupported("subscribe"); }, async unsubscribe( _listAddress: string, _subscriberAddress: string, _signal?: AbortSignal, ): Promise { - return inboundUnsupported("unsubscribe"); + return unsupported("unsubscribe"); }, }; } diff --git a/vendor/intx/workflow-host/src/child/warm-agent-cache.ts b/vendor/intx/workflow-host/src/child/warm-agent-cache.ts index 6e5a7c69b..345be336a 100644 --- a/vendor/intx/workflow-host/src/child/warm-agent-cache.ts +++ b/vendor/intx/workflow-host/src/child/warm-agent-cache.ts @@ -51,15 +51,46 @@ export interface WarmEventSinkRef { current: ((event: InferenceEvent) => void) | null; } +/** + * Per-turn settle barrier the connector reply drain exposes to the warm + * step (design §3c durability). The step snapshots `replySeq()` before its + * `agent.send` and, for a turn that produced a reply, awaits + * `waitForReplyAfter(snapshot)` so the run parks -- and the supervisor + * consumes the inbound mail -- only after the reply is durably sent. This is + * the structural subset of the harness `ConnectorReplyDrain` the warm path + * needs; declaring it here keeps the workflow-host package independent of + * `@intx/harness`, while the drain the sidecar builds satisfies it. + */ +export type WarmReplySettlement = + | { readonly ok: true } + | { readonly ok: false; readonly cause: unknown }; + +export interface WarmReplyDrive { + /** Settles when the drain loop exits at eviction. Folded into `eventForward`. */ + readonly done: Promise; + /** Monotonic count of replies that have settled (sent-and-acked or failed). */ + replySeq(): number; + /** + * Resolve once the reply at index `n` has settled, with its outcome. A + * failure outcome (or a drain that tears down before reply `n` arrives) + * carries `ok: false` so the caller fails the turn rather than treating the + * reply as sent. + */ + waitForReplyAfter(n: number): Promise; +} + /** * One warm agent the step-invoker reuses across messages. The * `eventSinkRef` is rewritten per message; the `eventForward` promise - * settles when the agent's stream ends at `close()`. + * settles when the agent's stream ends at `close()`. `replyDrive` is the + * connector reply drain's per-turn barrier when the deployment drives + * threaded replies, or `null` for a warm deployment with no connector state. */ interface WarmEntry { readonly agent: Agent; readonly eventSinkRef: WarmEventSinkRef; readonly eventForward: Promise; + readonly replyDrive: WarmReplyDrive | null; } /** @@ -79,16 +110,28 @@ export interface WarmAgentCache { /** * Cache a freshly-built warm agent under `key`. The `eventSinkRef` is * the mutable sink the agent's stream forwarder reads; `eventForward` - * is the forwarder loop's settle promise. Throws if an entry already - * exists for `key` -- a double-build is a step-invoker bug, not a - * silent overwrite that would leak the prior agent's LSP subprocess. + * is the forwarder loop's settle promise. `replyDrive` is the connector + * reply drain's per-turn barrier, or `null` when the deployment drives no + * threaded replies. Throws if an entry already exists for `key` -- a + * double-build is a step-invoker bug, not a silent overwrite that would + * leak the prior agent's LSP subprocess. */ store( key: string, agent: Agent, eventSinkRef: WarmEventSinkRef, eventForward: Promise, + replyDrive: WarmReplyDrive | null, ): void; + /** + * Return the connector reply drain's per-turn barrier cached for `key`, or + * `null` when the deployment drives no threaded replies. The step-invoker + * fetches it on every message -- the drain is established once at the + * first-message build but each message's send must snapshot and await it. + * Throws when no entry exists for `key`: a barrier fetch before the warm + * agent is stored is a step-invoker sequencing bug. + */ + getReplyDrive(key: string): WarmReplyDrive | null; /** * Point the warm agent's stream forwarder at the active step's event * sink before its `agent.send`. Throws when no entry exists for @@ -145,13 +188,24 @@ export function createWarmAgentCache(): WarmAgentCache { agent: Agent, eventSinkRef: WarmEventSinkRef, eventForward: Promise, + replyDrive: WarmReplyDrive | null, ): void { if (entries.has(key)) { throw new Error( `warm-agent cache: an entry already exists for ${key}; the step-invoker must reuse the cached agent rather than rebuild it`, ); } - entries.set(key, { agent, eventSinkRef, eventForward }); + entries.set(key, { agent, eventSinkRef, eventForward, replyDrive }); + } + + function getReplyDrive(key: string): WarmReplyDrive | null { + const entry = entries.get(key); + if (entry === undefined) { + throw new Error( + `warm-agent cache: getReplyDrive for ${key} with no cached entry; the step-invoker must store the warm agent before fetching its reply barrier`, + ); + } + return entry.replyDrive; } function setEventSink( @@ -177,8 +231,26 @@ export function createWarmAgentCache(): WarmAgentCache { sources: InferenceSource[], defaultSource: string, ): void { + // Rotate every retained agent before surfacing any failure: one agent + // rejecting the rotation (an invalid source, or a closed agent racing + // eviction) must not skip the rest. Collect failures and throw them + // together. (Warm-keep is single-step today, so the cache holds 0 or 1 + // entry; this keeps the contract honest if warm-keep ever spans steps.) + const failures: unknown[] = []; for (const entry of entries.values()) { - entry.agent.setSources(sources, defaultSource); + try { + entry.agent.setSources(sources, defaultSource); + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + logger.error`warm-agent rotation: setSources failed: ${message}`; + failures.push(cause); + } + } + if (failures.length > 0) { + throw new AggregateError( + failures, + `warm-agent rotation: ${String(failures.length)} agent(s) rejected the source rotation`, + ); } } @@ -186,23 +258,27 @@ export function createWarmAgentCache(): WarmAgentCache { if (entries.size === 0) return; const toEvict = [...entries.values()]; entries.clear(); + // Close every entry before surfacing any failure. The wrapped close + // (see `createToolBearingAgentFactory`) runs the agent's own close and + // then the plugin + tool-bundle disposers, killing the LSP subprocess, + // and it rejects when a disposer fails. One entry's close rejecting + // must not strand the remaining entries' teardown -- that would leak + // exactly the LSP subprocesses warm-keep risks. Collect failures and + // throw them together once every agent has been closed and drained. + // (Warm-keep is single-step today, so the cache holds 0 or 1 entry; + // this keeps the contract honest if warm-keep ever spans steps.) + const failures: unknown[] = []; for (const entry of toEvict) { // Clear the sink first so any event emitted during the agent's // shutdown window is dropped rather than delivered to a per-run // channel the run-loop is tearing down. entry.eventSinkRef.current = null; try { - // The wrapped close (see `createToolBearingAgentFactory`) runs - // the agent's own close and then the plugin + tool-bundle - // disposers, killing the LSP subprocess. A close failure must - // surface, not be swallowed -- a leaked LSP subprocess is - // exactly the failure warm-keep risks -- so it propagates after - // we have drained what we can. await entry.agent.close(); } catch (cause) { const message = cause instanceof Error ? cause.message : String(cause); logger.error`warm-agent eviction (${reason}): agent.close failed: ${message}`; - throw cause instanceof Error ? cause : new Error(message); + failures.push(cause); } finally { // `agent.close()` terminates the stream iterator, so the // forwarder loop has ended (or is about to). Await it so no @@ -210,11 +286,18 @@ export function createWarmAgentCache(): WarmAgentCache { await entry.eventForward; } } + if (failures.length > 0) { + throw new AggregateError( + failures, + `warm-agent eviction (${reason}): ${String(failures.length)} agent(s) failed to close; an LSP subprocess may be leaked`, + ); + } } return { acquire, store, + getReplyDrive, setEventSink, clearEventSink, applySources, diff --git a/vendor/intx/workflow-host/src/conversation-text.test.ts b/vendor/intx/workflow-host/src/conversation-text.test.ts deleted file mode 100644 index 624585b7d..000000000 --- a/vendor/intx/workflow-host/src/conversation-text.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - extractConversationText, - hasConversationText, -} from "./conversation-text"; - -const CRLF = "\r\n"; - -// The exact shape @corbits/chat's `encodeParts` produces for an event-only -// send: a signed envelope whose mixed body carries an EMPTY text/plain part -// plus the event's JSON attachment. -function eventOnlyMail(): Uint8Array { - const inner = "----=_Part_inner"; - const outer = "----=_Part_outer"; - const raw = [ - "From: prn_1@alice.localhost", - "To: run_1@alice.localhost", - "MIME-Version: 1.0", - `Content-Type: multipart/signed; protocol="application/pgp-signature"; boundary="${outer}"`, - "Interchange-Type: conversation.message", - "", - `--${outer}`, - `Content-Type: multipart/mixed; boundary="${inner}"`, - "", - `--${inner}`, - "Content-Type: text/plain; charset=utf-8", - "Content-Transfer-Encoding: 7bit", - "", - "", - `--${inner}`, - "Content-Type: application/json", - "Content-Transfer-Encoding: 7bit", - 'Content-Disposition: attachment; filename="part-0.json"', - "", - '{"kind":"event","event":"workbench.agent-joined","data":{"address":"run_2@alice.localhost"}}', - `--${inner}--`, - "", - `--${outer}--`, - "", - ].join(CRLF); - return new TextEncoder().encode(raw); -} - -describe("hasConversationText", () => { - test("accepts a real turn", () => { - expect(hasConversationText("what's the status?")).toBe(true); - }); - - test("rejects an empty body", () => { - expect(hasConversationText("")).toBe(false); - }); - - test("rejects a whitespace-only body", () => { - expect(hasConversationText("\r\n \t\n")).toBe(false); - }); - - test("rejects what an event-only send extracts to", () => { - // The regression: this mail resumed a parked run with "" and killed it. - const text = extractConversationText(eventOnlyMail(), ""); - expect(hasConversationText(text)).toBe(false); - }); -}); diff --git a/vendor/intx/workflow-host/src/conversation-text.ts b/vendor/intx/workflow-host/src/conversation-text.ts deleted file mode 100644 index 922d61224..000000000 --- a/vendor/intx/workflow-host/src/conversation-text.ts +++ /dev/null @@ -1,86 +0,0 @@ -// Shared inbound-mail conversation-text extraction. Two call sites resolve a -// run's mail input to the text the agent actually sees, each at the layer that -// knows its bytes are inbound mail: the child's `resolveTriggerPayload` (turn -// 1, the trigger) reads the mail from the substrate, and the supervisor's -// dispatch loop (turn 2+, a mail delivered to a parked run as a signal) inlines -// it. Both must produce the SAME shape -- extracted body text, not the raw MIME -// envelope -- so the extractor lives here rather than in either caller. - -import { - extractPartByPath, - parseHeaderSection, - parseMimePart, -} from "@intx/mime"; - -/** - * Extract the conversation body text from a raw inbound MIME message. - * - * Three on-wire shapes are handled, matching every producer the mail - * bus accepts: - * 1. The Interchange assembler's `multipart/signed` envelope whose - * first part is a `multipart/mixed` body carrying the text at part - * path `1.1`. - * 2. A `multipart/signed` envelope wrapping a bare `text/plain` part - * (a sender that signs without the `multipart/mixed` wrapper); the - * text is at part path `1`. - * 3. A flat top-level `text/plain` message (no multipart structure at - * all); the body is the bytes after the header section. - * - * The top-level `Content-Type` selects the shape: only a `multipart/*` - * root walks into parts; anything else reads the single body directly. - * This mirrors the conversation branch of mail-memory's `fetchFull` - * while also tolerating the flat single-part case the in-process agent - * accepts, so a non-standard inbound mail still delivers its text to - * the agent rather than crashing the run. `messageId` is used only for - * error attribution. - */ -export function extractConversationText( - raw: Uint8Array, - messageId: string, -): string { - const { headers, bodyOffset } = parseHeaderSection(raw); - const rootMime = (headers.get("content-type") ?? "") - .split(";")[0] - ?.trim() - .toLowerCase(); - if (rootMime === undefined || !rootMime.startsWith("multipart/")) { - // Flat single-part message: the body is everything after the - // header section. - return new TextDecoder("utf-8", { fatal: false }).decode( - raw.subarray(bodyOffset), - ); - } - let part1: ReturnType; - try { - part1 = parseMimePart(extractPartByPath(raw, "1")); - } catch (cause) { - throw new Error( - `conversation-text: cannot parse inbound mail part 1 for messageId ${messageId}`, - { cause }, - ); - } - const part1Mime = (part1.contentType.split(";")[0] ?? "") - .trim() - .toLowerCase(); - const bodyBytes = part1Mime.startsWith("multipart/") - ? parseMimePart(extractPartByPath(raw, "1.1")).body - : part1.body; - return new TextDecoder("utf-8", { fatal: false }).decode(bodyBytes); -} - -/** - * Does an extracted conversation body carry text an agent can actually be - * sent? Whitespace-only counts as empty. - * - * Workbench-local (CL-6164). Mail whose `text/plain` body is empty is - * legitimate on the bus: an attachments-only `conversation.message` — a - * structured event send such as `workbench.agent-joined`, whose payload is a - * JSON part — extracts to `""`. Handing that `""` to `agent.send` is a - * guaranteed throw (`createInboundMessage: content, when provided, must be a - * non-empty string`), and on the turn-2 resume path that throw surfaces as a - * `StepFailed` with `retriesExhausted` and kills the run. So callers gate on - * this and drop such mail instead of delivering it. - */ -export function hasConversationText(text: string): boolean { - return text.trim() !== ""; -} diff --git a/vendor/intx/workflow-host/src/index.ts b/vendor/intx/workflow-host/src/index.ts index b7e3db5a1..c9ce6ed61 100644 --- a/vendor/intx/workflow-host/src/index.ts +++ b/vendor/intx/workflow-host/src/index.ts @@ -15,6 +15,18 @@ export { createWorkflowRunBlobSubstrate, type WorkflowRunBlobSubstrateOpts, } from "./adapters/blob-substrate"; +export { + createSubstrateMailboxStore, + MAILBOX_PREFIX, + MAILBOX_INBOX_DIR, + MAILBOX_INDEX_FILE, + MAILBOX_EML_SUFFIX, + MAILBOX_INBOX_PREFIX, + type SubstrateMailboxStore, + type SubstrateMailboxStoreOpts, + type MailboxSyncKnownState, + type MailboxSyncResult, +} from "./adapters/substrate-mailbox-store"; export { createWorkflowStepInvoker, type StepEnvBase, @@ -105,6 +117,7 @@ export { EventPayload, FrameEnvelope, IPC_CRYPTO, + MailboxNotifyHeaders, SourcesUpdatedData, MacedEnvelope, OutboundAttachmentPayload, @@ -136,20 +149,25 @@ export { export { EVENT_CHANNEL_FD, + createChildMailboxReader, + createChildMailboxMutationBridge, createChildOutboundMailBridge, createChildSubstrateWriteBridge, createCredentialsBackedAuthorize, + createMailboxWatchRegistry, createProxyWorkflowRunRepoStore, createSupervisorBackedTransport, createWarmAgentCache, discoverInFlightRuns, - buildRuntimeEnv, parseSpawnTimeEnv, runWorkflowChild, runWorkflowChildFromProcessEnv, + type ChildMailboxMutationBridge, + type ChildMailboxReader, type ChildOutboundMailBridge, type ChildStepInvoker, type ChildSubstrateWriteBridge, + type CreateChildMailboxMutationBridgeOpts, type CreateChildOutboundMailBridgeOpts, type CreateChildSubstrateWriteBridgeOpts, type CreateProxyWorkflowRunRepoStoreOpts, @@ -160,6 +178,7 @@ export { type DrainController, type GrantEvaluator, type LoadParkedApproval, + type MailboxWatchRegistry, type RunWorkflowChildBindings, type RunWorkflowChildFromProcessEnvOpts, type RunWorkflowChildOpts, @@ -170,6 +189,7 @@ export { type SubstrateFactoryEnv, type SubstrateWriteRequest, type SubstrateWriteResponseSink, + type SupervisorBackedTransportInbound, type WarmAgentCache, type WarmEventSinkRef, } from "./child/index"; diff --git a/vendor/intx/workflow-host/src/ipc/control-channel.ts b/vendor/intx/workflow-host/src/ipc/control-channel.ts index fdfbae3ca..471e4c6ff 100644 --- a/vendor/intx/workflow-host/src/ipc/control-channel.ts +++ b/vendor/intx/workflow-host/src/ipc/control-channel.ts @@ -149,6 +149,7 @@ export const OutboundMessagePayload = type({ "summary?": "string", "attachments?": OutboundAttachmentPayload.array(), "inReplyTo?": "string", + "references?": "string[]", "correlationId?": "string", "sessionId?": "string", "tenantId?": "string", @@ -156,6 +157,41 @@ export const OutboundMessagePayload = type({ export type OutboundMessagePayload = typeof OutboundMessagePayload.infer; +/** + * Wire shape of the parsed `MessageHeaders` the supervisor rides inline on a + * `mailbox.notify` frame. Mirrors `@intx/types/runtime`'s `MessageHeaders` + * field-for-field so a child watcher gets the arrived message's envelope for + * its `exists` `MailboxEvent` without a substrate round-trip. The four + * unconditionally-dereferenced fields (`from`, `to`, `date`, `messageId`) are + * required; the rest are optional, matching the runtime type. + * + * Duplicated here as an arktype validator (rather than importing the TypeScript + * `MessageHeaders` type) so the IPC module validates the header block at the + * wire boundary, exactly as `OutboundMessagePayload` does for outbound mail. + */ +export const MailboxNotifyHeaders = type({ + from: "string", + to: "string[]", + "cc?": "string[]", + date: "string", + messageId: "string", + "inReplyTo?": "string", + "references?": "string[]", + "subject?": "string", + "listId?": "string", + "interchangeType?": InterchangeType, + "interchangeCorrelationId?": "string", + "interchangeTenantId?": "string", + "interchangeAgentId?": "string", + "interchangeSessionId?": "string", + "interchangeOfferingId?": "string", + "interchangeSchemaVersion?": "string", + "traceparent?": "string", + "tracestate?": "string", +}); + +export type MailboxNotifyHeaders = typeof MailboxNotifyHeaders.infer; + /** * Discriminated union of every control-channel payload kind. The * `type` discriminator namespaces the control-plane vocabulary so a @@ -170,6 +206,14 @@ export const ControlPayload = type( runId: "string", messageId: "string", receivedAt: "number", + // The run's inbound-mail input, resolved by the supervisor (the sole + // mail owner) before the frame: a decoded `Mail` (headers plus part + // descriptors that reference the part bytes committed to the workflow-run + // substrate). The child hands this straight to the runtime as the trigger + // payload. Refs, not raw mail bytes, ride here; the committed part files + // stay in the substrate. Typed `unknown` -- `Mail` is a nested structural + // type validated at the consumption boundary by `isMail`. + payload: "unknown", }, }, "|", @@ -182,9 +226,10 @@ export const ControlPayload = type( // The resume decision in FINAL form -- the child commits it as the // SignalReceived payload verbatim. Each sender owns any // provenance-specific preparation BEFORE this frame: the dispatch loop - // resolves an inbound mail to conversation text (like the turn-1 - // trigger), while `deliverSignal` ships a structured signal payload - // unchanged. Do NOT ship raw inbound mail bytes through here. + // resolves an inbound mail to a decoded `Mail` (headers plus part + // references, like the turn-1 trigger), while `deliverSignal` ships a + // structured signal payload unchanged -- so this field stays + // polymorphic. Do NOT ship raw inbound mail bytes through here. payload: "unknown", }, }, @@ -511,6 +556,86 @@ export const ControlPayload = type( data: { runIds: type("string > 0").array(), }, + }) + .or({ + // Supervisor-to-child one-way notification that new mail landed in a + // deployment mailbox (INBOUND half of mailbox ownership, §3b). One-way + // like `grants-updated`/`sources-updated`: no correlation id, no response. + // The supervisor -- the sole mail owner -- commits the arrived message to + // the workflow-run substrate mailbox, then fires this frame so the child's + // warm-agent `watch`/`mail_wait` observes the arrival decoupled from the + // FIFO trigger dispatch that resolves a run's first input. `headers` rides + // inline so a watcher gets the `exists` `MailboxEvent`'s envelope without a + // substrate round-trip. The child reads the latest committed mailbox state + // regardless, so the frame carries no commit pin. + type: "'mailbox.notify'", + data: { + runId: "string > 0", + mailbox: "string > 0", + uid: "number >= 1", + headers: MailboxNotifyHeaders, + }, + }) + .or({ + // Child-initiated mailbox-mutation request (INBOUND half of mailbox + // ownership, §3b). The supervisor is the sole writer to the + // workflow-run mailbox: a step agent reads its INBOX locally but + // every mutation -- flag writes and `expunge` -- routes up here so + // the supervisor applies it to its owned store. A child flushing the + // same ref would race the supervisor's in-memory mirror and break + // uid / modseq monotonicity. + // + // `data` is discriminated on `op`: an `addFlags` / `removeFlags` + // carries the target `uid` and the `flags` to change, so the wire + // boundary rejects a flag frame that omits them; an `expunge` sweeps + // every `\Deleted` message in the mailbox and the child constructs it + // with neither. `requestId` correlates the supervisor's + // `mailbox.mutate.response` reply. + type: "'mailbox.mutate.request'", + data: type( + { + requestId: "string > 0", + runId: "string > 0", + mailbox: "string > 0", + op: "'addFlags' | 'removeFlags'", + uid: "number >= 1", + flags: "string[]", + }, + "|", + { + requestId: "string > 0", + runId: "string > 0", + mailbox: "string > 0", + op: "'expunge'", + }, + ), + }) + .or({ + // Supervisor's terminal reply to a child's `mailbox.mutate.request`. + // The `requestId` echoes the child's correlation id so the child's + // pending mail-tool awaiter resolves. The reply is sent only after + // the supervisor flushes the mutation, so the child's next committed + // read observes it -- the same flush-before-signal ordering + // `mailbox.notify` relies on. A successful `expunge` carries the + // `expungedUids` it swept so the agent tool can report the count; a + // flag write carries no operand echo. A failed mutation (unknown + // uid, substrate fault) surfaces a structured `{ ok: false, reason }` + // the child's bridge rethrows so the mail-tool call fails loudly. + type: "'mailbox.mutate.response'", + data: { + requestId: "string > 0", + result: type( + { + ok: "true", + "expungedUids?": "number[]", + }, + "|", + { + ok: "false", + reason: "string > 0", + }, + ), + }, }); export type ControlPayload = typeof ControlPayload.infer; diff --git a/vendor/intx/workflow-host/src/ipc/index.ts b/vendor/intx/workflow-host/src/ipc/index.ts index ddc7bcf00..ab849e6b0 100644 --- a/vendor/intx/workflow-host/src/ipc/index.ts +++ b/vendor/intx/workflow-host/src/ipc/index.ts @@ -140,6 +140,7 @@ export { ControlPayload, + MailboxNotifyHeaders, OutboundAttachmentPayload, OutboundMessagePayload, SourcesUpdatedData, diff --git a/vendor/intx/workflow-host/src/run-body-then-cleanup.ts b/vendor/intx/workflow-host/src/run-body-then-cleanup.ts new file mode 100644 index 000000000..7a12dd403 --- /dev/null +++ b/vendor/intx/workflow-host/src/run-body-then-cleanup.ts @@ -0,0 +1,42 @@ +/** + * Run `body`, then always run `cleanup`, without letting a `cleanup` + * failure mask a `body` failure. + * + * When `body` throws, `cleanup` still runs and a `cleanup` failure is + * handed to `onCleanupErrorAfterBodyError` (to log) and then dropped, so + * the original `body` error is what propagates. When `body` succeeds, a + * `cleanup` failure propagates -- there is no primary error to protect, so + * a failing teardown is the error worth surfacing. + * + * This exists so teardown in a `finally` (agent close, warm-cache + * eviction) can surface its own failure without a `throw` inside a + * `finally` block, which `no-unsafe-finally` forbids precisely because it + * silently swallows the in-flight exception -- the masking bug this guards + * against. + */ +export async function runBodyThenCleanup( + body: () => Promise, + cleanup: () => Promise, + onCleanupErrorAfterBodyError: (cause: unknown) => void, +): Promise { + let outcome: { ok: true; value: T } | { ok: false; error: unknown }; + try { + outcome = { ok: true, value: await body() }; + } catch (error) { + outcome = { ok: false, error }; + } + + try { + await cleanup(); + } catch (cleanupError) { + if (outcome.ok) { + throw cleanupError; + } + onCleanupErrorAfterBodyError(cleanupError); + } + + if (!outcome.ok) { + throw outcome.error; + } + return outcome.value; +} diff --git a/vendor/intx/workflow-host/src/supervisor/run-event-compaction.ts b/vendor/intx/workflow-host/src/supervisor/run-event-compaction.ts index 38ea9fe69..6ea89527e 100644 --- a/vendor/intx/workflow-host/src/supervisor/run-event-compaction.ts +++ b/vendor/intx/workflow-host/src/supervisor/run-event-compaction.ts @@ -14,6 +14,7 @@ import type { WorkflowRunSupervisorPrincipal, } from "@intx/hub-sessions/substrate"; import { + classifyTerminalEvent, WORKFLOW_RUN_EVENTS_FILE, encodeCombinedEventLog, } from "@intx/hub-sessions/substrate"; @@ -23,11 +24,6 @@ import { SUPERVISOR_PRINCIPAL_KIND } from "./cancel-signing"; const RUNS_PREFIX = "runs"; const EVENTS_DIR = "events"; const EVENT_FILENAME_RE = /^(0|[1-9][0-9]*)\.json$/; -const TERMINAL_EVENT_TYPES = new Set([ - "RunCompleted", - "RunFailed", - "RunCancelled", -]); export type CompactRunEventsOpts = { /** Substrate handle the supervisor writes through. */ @@ -51,8 +47,8 @@ export type CompactRunEventsOpts = { * Idempotent and terminal-only: a run already sealed (no `events/` subtree) * or one whose latest event is not terminal is left untouched, so the call * is safe to repeat. The live caller invokes it once per run, right after the - * run terminates; a bounded recovery sweep that would retry the fold for a run - * whose compaction a crash interrupted is not yet implemented. + * run terminates; `recoverInterruptedCompactions` re-runs it for a run whose + * fold a crash interrupted before it could seal. * * The combined file is the verbatim byte concatenation of the per-event * blobs in seq order (`encodeCombinedEventLog`), the exact shape the @@ -100,7 +96,10 @@ export async function compactRunEvents( return { compacted: false }; } const lastType = (parsed as { type?: unknown }).type; - if (typeof lastType !== "string" || !TERMINAL_EVENT_TYPES.has(lastType)) { + if ( + typeof lastType !== "string" || + !classifyTerminalEvent(lastType).terminal + ) { return { compacted: false }; } diff --git a/vendor/intx/workflow-host/src/supervisor/run-event-recovery.ts b/vendor/intx/workflow-host/src/supervisor/run-event-recovery.ts new file mode 100644 index 000000000..e51dfd032 --- /dev/null +++ b/vendor/intx/workflow-host/src/supervisor/run-event-recovery.ts @@ -0,0 +1,68 @@ +// Bounded recovery for run-event compaction folds a crash interrupted. +// +// When a run terminates, the supervisor fires `compactRunEvents` in the +// background. A crash between the terminal commit and that fold leaves the +// run terminal but still in per-event form, and the terminal signal never +// fires again for it. At the next spawn the boot scan proposes those runs; +// this sweep re-runs the idempotent fold for each so the leaked per-event +// file count is reclaimed. + +import type { + RepoId, + RepoStore as SubstrateRepoStore, +} from "@intx/hub-sessions/substrate"; + +import { compactRunEvents } from "./run-event-compaction"; + +export type RecoverInterruptedCompactionsOpts = { + /** Substrate handle the supervisor writes through. */ + substrate: SubstrateRepoStore; + /** Workflow-run repo for this deployment. */ + repoId: RepoId; + /** Events ref the workflow-run repo writes to. */ + ref: string; + /** Anchor run id used to construct the supervisor principal. */ + anchorRunId: string; + /** Runs the boot scan proposes as terminal-but-per-event. */ + pendingSealRunIds: readonly string[]; +}; + +/** A run whose recovery fold threw, paired with the failure cause. */ +export type RecoveryFoldFailure = { runId: string; message: string }; + +/** + * Re-seal runs a crash left terminal but still in per-event form, by re-running + * the idempotent `compactRunEvents` for each proposed run. `compactRunEvents` + * is authoritative: it no-ops a run that is already sealed or whose latest + * event is not terminal, so a stale or mistaken proposal is a harmless no-op. + * + * Folds run serially. Every fold contends the same per-repo write lock that + * live dispatch also takes, so folding one run at a time drains the backlog + * without a thundering herd on that lock. One run's failure is caught so it + * cannot abort the rest; the failed run id and its cause are returned -- not + * logged here -- so the caller owns how to surface the aggregate. + */ +export async function recoverInterruptedCompactions( + opts: RecoverInterruptedCompactionsOpts, +): Promise<{ sealed: number; failed: RecoveryFoldFailure[] }> { + let sealed = 0; + const failed: RecoveryFoldFailure[] = []; + for (const runId of opts.pendingSealRunIds) { + try { + const { compacted } = await compactRunEvents({ + substrate: opts.substrate, + repoId: opts.repoId, + ref: opts.ref, + anchorRunId: opts.anchorRunId, + runId, + }); + if (compacted) sealed += 1; + } catch (cause) { + failed.push({ + runId, + message: cause instanceof Error ? cause.message : String(cause), + }); + } + } + return { sealed, failed }; +} diff --git a/vendor/intx/workflow-host/src/supervisor/supervisor.ts b/vendor/intx/workflow-host/src/supervisor/supervisor.ts index dfa2578d8..fe52871a9 100644 --- a/vendor/intx/workflow-host/src/supervisor/supervisor.ts +++ b/vendor/intx/workflow-host/src/supervisor/supervisor.ts @@ -75,8 +75,11 @@ import { RepoId, type CredentialDelivery } from "@intx/types/sidecar"; import type { ApprovalSnapshot, InferenceSource, + Mail, + MessageHeaders, OutboundMessage, } from "@intx/types/runtime"; +import type { StoredEnvelope } from "@intx/mailbox"; import type { CancelOrigin } from "@intx/workflow"; import { @@ -99,10 +102,14 @@ import { commitCancelRequested } from "./cancel-signing"; import { commitRunFailed } from "./terminal-commit"; import { buildChildSpawnEnv } from "./spawn-env"; import { compactRunEvents } from "./run-event-compaction"; +import { recoverInterruptedCompactions } from "./run-event-recovery"; +import { decodeMail } from "@intx/mime"; +import { commitMail, InvalidMailError } from "../adapters/mail-part-store"; import { - extractConversationText, - hasConversationText, -} from "../conversation-text"; + createSubstrateMailboxStore, + MAILBOX_INBOX_DIR, + type SubstrateMailboxStore, +} from "../adapters/substrate-mailbox-store"; import { createDrainTimeoutAccumulator, DEFAULT_DRAIN_TIMEOUT_MS, @@ -142,6 +149,20 @@ import { const logger = getLogger(["workflow-host", "supervisor"]); +/** IMAP system flag marking a dispatched mailbox entry as read. */ +const MAILBOX_FLAG_SEEN = "\\Seen"; +/** + * Interchange keyword flag marking a mailbox entry the supervisor has dispatched + * as a workflow turn (a `trigger.fire` or `signal.deliver`). + */ +const MAILBOX_FLAG_PROCESSED = "$Processed"; +/** + * IMAP system flag marking a mailbox entry for expunge. The warm agent sets it + * (via `mail_flag`) to consume a processed message; a subsequent `expunge` + * sweeps every entry carrying it out of the live INBOX. + */ +const MAILBOX_FLAG_DELETED = "\\Deleted"; + /** * Default crash-loop bound: the supervisor stops respawning and latches * the deployment once the workflow-process child exits unexpectedly this @@ -279,6 +300,19 @@ export interface WorkflowSupervisor { * credential is delivered by omitting its material so the child evicts it. */ deliverCredentials(opts: DeliverCredentialsOpts): Promise; + /** + * Refresh a live run's grant floor mid-run by re-reading its durable + * `runs//grants.json` and pushing it as a `grants-updated` frame. The + * enforcement path for a standing (`scope: "always"`) approval that lowers a + * tool's `ask` to `allow` in that file. Unlike `deliverSignal`/ + * `deliverSources`, a refresh for a non-live child is normal, so this + * NO-OPS (`skipped`) instead of throwing, and a send failure to a live child + * is logged loudly but stays non-fatal -- the durable file governs the next + * barrier/respawn. It pushes only that file's contents, never caller-supplied + * grants, so it can only tighten or refresh a floor. Returns whether a live + * push happened. + */ + deliverGrants(runId: string): Promise<"pushed" | "skipped">; /** * Re-register every correlation the child is currently parked on by * querying it for its parked correlations and re-emitting each through @@ -810,12 +844,21 @@ export function createWorkflowSupervisor( // (spawn handshake, recycle reap, shutdown) owns teardown. function onChildCrash(reason: string): void { if (state.phase === "running") { - logger.error`workflow-process channel crash on live cohort; forcing child down to respawn: {reason}`; + logger.error`workflow-process channel crash on live cohort; forcing child down to respawn: ${reason}`; state.handle.kill(); return; } - logger.error`workflow-process channel crash: {reason}`; - void shutdownInternal({ reason }); + logger.error`workflow-process channel crash: ${reason}`; + // Only a live, registered supervisor driven down by a channel crash is a + // self-termination the host must reclaim, and that is `recycling`: + // `running` took the kill branch above (its exit reaches the crash-loop + // latch, which carries its own flag), `starting` is the pre-registration + // initial spawn handshake whose failure the deploy unwind owns, and + // `stopping` is a teardown already in flight (a host `shutdown()`, or a + // self-termination already firing). An allowlist, not a denylist, so a + // future phase defaults to no self-terminate rather than a spurious one. + const selfTerminated = state.phase === "recycling"; + void shutdownInternal({ reason, selfTerminated }); } // Prune crash timestamps older than the sliding window relative to `nowMs`. @@ -971,6 +1014,7 @@ export function createWorkflowSupervisor( await shutdownInternal({ reason: `crash-loop: ${reason}`, terminalPhase: "crash-looping", + selfTerminated: true, }); // Commit the RunFailed tombstone AFTER teardown: shutdownInternal has // quiesced the drain accumulators (stop + await disposed), so the @@ -1045,6 +1089,170 @@ export function createWorkflowSupervisor( armStableRunResetTimer(childGeneration); } + // Eager per-run mailbox (§3b inbound). On arrival the supervisor commits each + // fresh inbound message into the deployment's substrate-backed INBOX and fires + // a one-way `mailbox.notify` to the child, so the warm agent's `watch` / + // `mail_wait` observes the arrival mid-turn -- decoupled from the FIFO claim- + // check dispatch that resolves a run's step input. The supervisor is the sole + // mailbox writer; the store is its long-lived in-memory mirror over the + // committed `mailbox/INBOX/` subtree, constructed lazily on the first arrival. + const mailboxWritePrincipal: WorkflowRunSupervisorPrincipal = { + kind: "supervisor", + anchorRunId: bindings.anchorRunId, + }; + let mailboxStore: SubstrateMailboxStore | null = null; + // Claim-check messageId -> assigned mailbox uid, so a message dispatched as a + // turn can be flagged \Seen/$Processed by uid. In-memory only: a missing entry + // (a restart, an arrival whose eager commit failed, or an already-processed + // message whose entry was pruned) skips the flag mark, which is a cosmetic + // IMAP flag, never a delivery guarantee. `markMailboxProcessed` prunes an + // entry once its mark completes, so the map holds only messages awaiting the + // flag mark rather than growing for the deployment's life. + const mailboxUidByMessageId = new Map(); + // Serializes every mailbox mutation (lazy construction, the arrival + // append+flush, the dispatch flag mark) so concurrent arrivals and a fire-and- + // forget flag mark never interleave against the shared in-memory mirror. + let mailboxTail: Promise = Promise.resolve(); + function runMailboxExclusive(fn: () => Promise): Promise { + const run = mailboxTail.then(fn, fn); + mailboxTail = run.then( + () => undefined, + () => undefined, + ); + return run; + } + async function getMailboxStore(): Promise { + if (mailboxStore === null) { + mailboxStore = await createSubstrateMailboxStore({ + substrate: bindings.repoStore, + repoId: bindings.workflowRunRepoId, + principal: mailboxWritePrincipal, + ref: bindings.workflowRunRef, + }); + } + return mailboxStore; + } + + function storedEnvelopeFromHeaders( + headers: MessageHeaders, + receivedAt: number, + ): StoredEnvelope { + // The Date header is unvalidated external input; fall back to the arrival + // time when it is absent or unparseable so the store's `toISOString` + // serialization cannot throw on an Invalid Date. + const parsed = new Date(headers.date); + const date = Number.isNaN(parsed.getTime()) ? new Date(receivedAt) : parsed; + return { + messageId: headers.messageId, + from: headers.from, + to: headers.to, + subject: headers.subject ?? "", + date, + inReplyTo: headers.inReplyTo, + references: headers.references ?? [], + interchangeType: headers.interchangeType, + interchangeCorrelationId: headers.interchangeCorrelationId, + }; + } + + /** + * Eager-commit one freshly-arrived inbound message into the deployment's + * substrate mailbox, then notify the child. Runs on the mail-arrival path, + * before and independent of FIFO dispatch, so the warm agent's `mail_wait` + * observes the message mid-turn. Best-effort: the claim-check inbox is the + * durable delivery contract, so a decode or substrate fault here is logged + * loudly and never withholds the mail's ack -- the message still reaches the + * agent as its turn's step input via `trigger.fire`. The `mailbox.notify` is + * sent only AFTER the append is flushed, so the child reads committed state. + */ + async function commitInboundToMailbox( + messageId: string, + rawMessage: Uint8Array, + receivedAt: number, + ): Promise { + try { + await runMailboxExclusive(async () => { + // The caller gates on a fresh `enqueued` outcome, so a redelivery never + // reaches here; this guard is belt-and-suspenders against a double + // append of the same messageId. + if (mailboxUidByMessageId.has(messageId)) return; + let decoded: ReturnType; + try { + decoded = decodeMail(rawMessage); + } catch (cause) { + const message = + cause instanceof Error ? cause.message : String(cause); + logger.error`eager mailbox commit: dropping undecodable inbound mail ${messageId}: ${message}`; + return; + } + const store = await getMailboxStore(); + const uid = store.append( + rawMessage, + storedEnvelopeFromHeaders(decoded.headers, receivedAt), + [], + ); + mailboxUidByMessageId.set(messageId, uid); + await store.flush(); + const commit = await bindings.repoStore.resolveRef( + mailboxWritePrincipal, + bindings.workflowRunRepoId, + bindings.workflowRunRef, + ); + if (commit === null) { + logger.error`eager mailbox commit: ${bindings.workflowRunRef} did not resolve after flush; skipping mailbox.notify for ${messageId}`; + return; + } + const sender = activeControlSender(); + if (sender === null) { + logger.info`eager mailbox commit: no active control sender; committed ${messageId} as uid ${String(uid)} without mailbox.notify`; + return; + } + await sender.send({ + type: "mailbox.notify", + data: { + runId: deriveWorkflowRunId(bindings.deploymentMailAddress), + mailbox: MAILBOX_INBOX_DIR, + uid, + headers: decoded.headers, + }, + }); + }); + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + logger.error`eager mailbox commit failed for ${messageId}; mail still delivered via claim-check dispatch: ${message}`; + } + } + + /** + * Flag a dispatched message's mailbox entry \Seen/$Processed. Fire-and-forget + * off the dispatch critical path: the flag is a cosmetic IMAP marker, so a + * missing uid (no eager mailbox entry) or a substrate fault is logged and + * dropped, never failing the turn. + */ + function markMailboxProcessed(messageId: string): void { + const uid = mailboxUidByMessageId.get(messageId); + if (uid === undefined) return; + void runMailboxExclusive(async () => { + try { + const store = await getMailboxStore(); + if (store.find(uid) === undefined) return; + store.addFlags(uid, [MAILBOX_FLAG_SEEN, MAILBOX_FLAG_PROCESSED]); + await store.flush(); + } finally { + // The id->uid mapping exists only to flag this message once. After the + // mark runs (or the message is already gone), the entry is dead weight, + // so drop it to bound the map over a long-lived conversational mailbox. + // Redelivery dedup is owned by the durable inbox index, not this map. + // The delete runs inside the exclusive section so it never interleaves + // with the arrival path's `has(messageId)` check. + mailboxUidByMessageId.delete(messageId); + } + }).catch((cause) => { + const message = cause instanceof Error ? cause.message : String(cause); + logger.warn`mailbox flag mark failed for ${messageId} (uid ${String(uid)}): ${message}`; + }); + } + // Resolves once the inbound mail is durably accepted (its inbox write landed // or the message was already durably present); rejects when it was not (a // phase where the deployment is not accepting mail, a transient enqueue @@ -1133,6 +1341,11 @@ export function createWorkflowSupervisor( // the same messageId already drives dispatch. This resolves for both // outcomes: both mean the bytes are durably accounted for, so both ack. if (outcome.outcome === "enqueued") { + // Eager-commit the fresh message into the per-run mailbox and notify the + // child BEFORE waking dispatch, so the warm agent's mail_wait can observe + // it committed. Non-fatal by contract: the enqueue above already secured + // the durable delivery, so this never withholds the ack. + await commitInboundToMailbox(messageId, rawMessage, receivedAt); wakeDispatch(); } else { // A redelivery of a message already durably present: the ack still @@ -1209,6 +1422,19 @@ export function createWorkflowSupervisor( }); continue; } + if (payload.type === "mailbox.mutate.request") { + // INBOUND half of mailbox ownership (§3b). The child asked the + // supervisor -- the sole mailbox writer -- to apply a flag write or + // expunge. Run it off the iterator's loop so the iterator keeps + // draining while the store flushes; the handler owns the + // `mailbox.mutate.response` reply that resolves the child's awaiter. + void handleMailboxMutation(payload.data).catch((cause) => { + const message = + cause instanceof Error ? cause.message : String(cause); + logger.error`mailbox.mutate.request handler crashed: ${message}`; + }); + continue; + } if (payload.type === "terminal.event") { // The workflow-process child mirrors every terminal-run commit // over the control IPC. Fan it out to the COHORT'S broadcaster @@ -1597,6 +1823,112 @@ export function createWorkflowSupervisor( } } + /** + * Apply a child-requested mailbox mutation to the owned store (INBOUND + * half of mailbox ownership, §3b). The supervisor is the sole writer to + * the workflow-run mailbox; the child never flushes it. A flag write + * (`addFlags` / `removeFlags`) targets one uid; an `expunge` sweeps every + * `\Deleted` message out of the live INBOX. The mutation is applied under + * `runMailboxExclusive` and flushed before the reply, so the child's next + * committed read observes it -- the flush-before-signal ordering + * `commitInboundToMailbox` uses. A failure (unknown uid, wrong mailbox, + * substrate fault) surfaces back as a structured `{ ok: false, reason }` + * so the agent's mail-tool call fails loudly rather than dropping the + * mutation silently. + */ + async function handleMailboxMutation( + data: Extract["data"], + ): Promise { + // Capture the sender once. Re-fetching after the flush could return a + // successor cohort's sender and misroute the reply to the wrong child + // (see the substrate-write handler's note). A null sender means the + // supervisor is mid-recycle or tearing down: there is nothing to reply + // on, so drop and warn -- the child's read end is closing alongside, so + // its pending awaiter is rejected by the control loop's `cancelAll`. + const controlSender = activeControlSender(); + if (controlSender === null) { + logger.warn`mailbox.mutate.request received outside running phase; requestId=${data.requestId} dropped (child awaiter will fail on pipe close)`; + return; + } + // The supervisor owns exactly one mailbox, the substrate INBOX. Reject a + // request for any other name rather than silently mutate INBOX under it, + // which would be a wrong-target durable write reported as success. The + // frame carries an unconstrained mailbox string, so this is validated + // here at the owning layer, not trusted from the child transport. + if (data.mailbox !== MAILBOX_INBOX_DIR) { + await controlSender.send({ + type: "mailbox.mutate.response", + data: { + requestId: data.requestId, + result: { + ok: false, + reason: `unknown mailbox "${data.mailbox}"; only ${MAILBOX_INBOX_DIR} is writable`, + }, + }, + }); + return; + } + try { + const expungedUids = await runMailboxExclusive(async () => { + const store = await getMailboxStore(); + if (data.op === "expunge") { + // Snapshot the \Deleted uids before removing: `store.messages` is + // the live array, so `.filter().map()` materializes the targets + // before any `remove` splices it. The whole sweep runs + // synchronously under the lock, so no snapshotted uid can vanish + // before its `remove`. + const uids = store.messages + .filter((m) => m.flags.has(MAILBOX_FLAG_DELETED)) + .map((m) => m.uid); + for (const uid of uids) { + store.remove(uid); + // Bound the id->uid map: drop any entry now pointing at a removed + // uid. Not load-bearing -- `markMailboxProcessed` guards with + // `find` -- but keeps the map from retaining dead uids. + for (const [messageId, mappedUid] of mailboxUidByMessageId) { + if (mappedUid === uid) mailboxUidByMessageId.delete(messageId); + } + } + await store.flush(); + return uids; + } + if (data.op === "addFlags") { + store.addFlags(data.uid, data.flags); + } else { + store.removeFlags(data.uid, data.flags); + } + await store.flush(); + return undefined; + }); + await controlSender.send({ + type: "mailbox.mutate.response", + data: { + requestId: data.requestId, + result: + expungedUids === undefined + ? { ok: true } + : { ok: true, expungedUids }, + }, + }); + } catch (cause) { + // Reply on the same captured sender. If this send itself throws (a + // broken pipe), it propagates to the pump's `.catch`, and the child's + // awaiter is rejected by the control loop's `cancelAll` -- the backstop + // `handleOutboundMessage` also relies on. Accepted window: a mutation + // can flush durably while its reply is undeliverable, so the agent tool + // errors on a mutation that landed. This is inherent to apply-then-reply + // across a teardown boundary and identical to `handleOutboundMessage`. + const reason = cause instanceof Error ? cause.message : String(cause); + await controlSender.send({ + type: "mailbox.mutate.response", + data: { + requestId: data.requestId, + result: { ok: false, reason }, + }, + }); + } + } + async function handleSubstrateWriteRequest( data: Extract["data"], ): Promise { @@ -1939,6 +2271,7 @@ export function createWorkflowSupervisor( terminalBroadcaster: createTerminalBroadcaster(), dispatchLoop: null, replayDone: null, + sweepDone: null, }; // Everything from here to the successful `return` runs with the state @@ -1973,10 +2306,15 @@ export function createWorkflowSupervisor( // first `dequeueToProcessing` so a fresh inbound mail that lands // during the replay window cannot ship ahead of the orphan once // the replay completes. - const replayDone = scanRunsForBoot( + // One scan of `runs/` feeds both spawn-time recovery consumers: the + // orphan replay (which gates dispatch) and the compaction sweep (which + // does not). Sharing the walk keeps recovery off a second O(total-runs) + // scan. + const scanDone = scanRunsForBoot( bindings.repoStore, bindings.workflowRunRepoId, - ) + ); + const replayDone = scanDone .then(({ ownedMessageIds }) => inboxPrimitives.replayProcessingToInbox( bindings.repoStore, @@ -2004,7 +2342,7 @@ export function createWorkflowSupervisor( // best-effort until that lands. const message = cause instanceof Error ? cause.message : String(cause); - logger.warn`replayProcessingToInbox on spawn failed: ${message}`; + logger.warn`boot recovery scan or processing replay failed on spawn: ${message}`; }); // Hold the replay promise on the active-state record so // `shutdownInternal` awaits its settlement before tearing the @@ -2013,6 +2351,40 @@ export function createWorkflowSupervisor( // the supervisor's exit. state.replayDone = replayDone; + // Re-seal runs a crash left terminal-but-per-event when their + // fire-and-forget fold never ran. Unlike the replay above, this must + // NOT gate dispatch: reclaiming leaked per-event files is housekeeping + // and cannot be allowed to delay the first dequeue. Best-effort, held + // on the active-state record so shutdown awaits its settlement (see the + // `sweepDone` field docstring for the teardown-latency tradeoff). + const sweepDone = scanDone + .then(({ pendingSealRunIds }) => + recoverInterruptedCompactions({ + substrate: bindings.repoStore, + repoId: bindings.workflowRunRepoId, + ref: bindings.workflowRunRef, + anchorRunId: bindings.anchorRunId, + pendingSealRunIds, + }), + ) + .then(({ sealed, failed }) => { + if (sealed > 0) { + logger.info`recovery sweep sealed ${String(sealed)} interrupted run(s)`; + } + if (failed.length > 0) { + const detail = failed + .map((f) => `${f.runId} (${f.message})`) + .join("; "); + logger.warn`recovery sweep left ${String(failed.length)} run(s) unsealed: ${detail}`; + } + }) + .catch((cause) => { + const message = + cause instanceof Error ? cause.message : String(cause); + logger.warn`boot recovery scan or compaction sweep failed on spawn: ${message}`; + }); + state.sweepDone = sweepDone; + bindings.mailBus.registerAddress(bindings.deploymentMailAddress); const mailUnsubscribe = bindings.mailBus.subscribeMailForAddress( bindings.deploymentMailAddress, @@ -2133,6 +2505,7 @@ export function createWorkflowSupervisor( terminalBroadcaster: startingPhaseBroadcaster, dispatchLoop, replayDone, + sweepDone, }; // Bump the generation and arm the exit-watcher atomically with the // running transition (no await between the swap above and this call) @@ -2316,15 +2689,17 @@ export function createWorkflowSupervisor( * Forward one dequeued inbox entry to the child as `trigger.fire` * and record its runId as in-flight. The runId is the local part of the * deployment's mail address (see `deriveWorkflowRunId`), identifying its one - * top-level run; the `messageId` rides alongside it so the child can - * recover the trigger's mail bytes by claim-check. The runId is the - * same value the dispatch loop waits on via `terminalEventSource`. + * top-level run. The resolved `Mail` (headers plus committed part references) + * rides in the frame as the run's trigger payload; the `messageId` + * accompanies it for correlation and audit. The runId is the same value the + * dispatch loop waits on via `terminalEventSource`. */ async function forwardDispatchedEntry( sender: ControlChannelSender, messageId: string, receivedAt: number, runId: string, + payload: Mail, ): Promise { await sender.send({ type: "trigger.fire", @@ -2332,12 +2707,86 @@ export function createWorkflowSupervisor( runId, messageId, receivedAt, + payload, }, }); cohortRunIds.add(runId); return runId; } + /** + * Resolve a dequeued inbound mail to the run's input: a decoded `Mail` + * (headers plus part descriptors that reference the part bytes committed to + * the workflow-run substrate). The supervisor is the sole mail owner and + * commits the parts here (a direct workflow-run write; the workflow child's + * control loop cannot do a synchronous proxied write without deadlock), so + * both turns share this one preparation site. + * + * The two failure modes are deliberately distinct: + * - A DETERMINISTIC input rejection -- missing bytes, unparseable MIME, or + * a messageId that cannot form a path segment -- returns `{ ok: false }` + * so the caller drops the mail. Replaying it would fail identically. + * - A TRANSIENT substrate write failure propagates (thrown), so the caller + * treats it as a dispatch fault and leaves the mail reclaimable rather + * than silently discarding it on an infrastructure hiccup. + */ + async function prepareMail( + envelope: { messageId: string; rawMessage?: string }, + runId: string, + ): Promise< + | { ok: true; mail: Mail } + | { ok: false; rejection: { code: string; message: string } } + > { + if (envelope.rawMessage === undefined) { + return { + ok: false, + rejection: { + code: "malformed_mail", + message: `inbound mail ${envelope.messageId} carries no rawMessage bytes`, + }, + }; + } + let decoded: ReturnType; + try { + decoded = decodeMail(base64Decode(envelope.rawMessage)); + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + return { + ok: false, + rejection: { + code: "malformed_mail", + message: `inbound mail ${envelope.messageId} could not be decoded: ${message}`, + }, + }; + } + const writePrincipal: WorkflowRunSupervisorPrincipal = { + kind: "supervisor", + anchorRunId: bindings.anchorRunId, + }; + try { + const mail = await commitMail( + { + substrate: bindings.repoStore, + repoId: bindings.workflowRunRepoId, + principal: writePrincipal, + runId, + ref: bindings.workflowRunRef, + }, + envelope.messageId, + decoded, + ); + return { ok: true, mail }; + } catch (cause) { + if (cause instanceof InvalidMailError) { + return { + ok: false, + rejection: { code: "malformed_mail", message: cause.message }, + }; + } + throw cause; + } + } + /** * Push the run's grants snapshot to the child ahead of its * `trigger.fire`. Returns `true` if the barrier FAILED (the caller must @@ -2541,49 +2990,24 @@ export function createWorkflowSupervisor( } const inputChannel = runInputChannels.get(runId); if (inputChannel !== undefined) { - // Resolve the inbound mail to conversation text HERE, the single - // site that knows this payload's provenance is mail, applying the - // SAME extraction the turn-1 trigger does (resolveTriggerPayload). - // The signal.deliver frame's payload is the resume decision in FINAL - // form; deliverSignal's structured signals ship their own payload - // unchanged. Done BEFORE minting the terminal watcher so a failure - // here cannot leak an un-finalized iterator. - let inputText: string; - try { - if (envelope.rawMessage === undefined) { - throw new Error("inbound mail carries no rawMessage bytes"); - } - inputText = extractConversationText( - base64Decode(envelope.rawMessage), - envelope.messageId, - ); - } catch (cause) { - // A malformed turn-2 mail cannot resume the parked agent. DROP it: - // log loudly and consume it (break to the post-loop markConsumed) - // rather than throwing -- a throw aborts the dispatch without - // consuming, and replay re-delivers the same poison mail forever. - // The run stays parked on its current correlation, ready for the - // next valid mail; one bad mail must not tear down a long-lived - // conversation. - const message = - cause instanceof Error ? cause.message : String(cause); - logger.error`signal.deliver for run ${runId}: dropping malformed inbound mail ${envelope.messageId}: ${message}`; - break; - } - if (!hasConversationText(inputText)) { - // Attachments-only mail (a structured event send whose text part - // is empty) has no turn to resume the parked agent with. DROP it - // for the same reason the malformed branch above does, and record - // a rejection so the consumed envelope says why. Delivering the - // empty string instead would throw inside `agent.send`, surface as - // a `StepFailed` with `retriesExhausted`, and kill a run whose - // only fault was being told that someone joined its bench - // (workbench-local, CL-6164). - rejection = { - code: "empty_conversation_content", - message: `Inbound mail ${envelope.messageId} carries no conversation text to resume run ${runId}`, - }; - logger.warn`signal.deliver for run ${runId}: dropping inbound mail ${envelope.messageId} with no conversation text`; + // Resolve the inbound mail to the run's input HERE, the single site + // that knows this payload's provenance is mail, applying the SAME + // preparation the turn-1 trigger does. The signal.deliver frame's + // payload is the resume decision in FINAL form -- a Mail (headers plus committed part references); deliverSignal's structured signals ship their own + // payload unchanged. Done BEFORE minting the terminal watcher so a + // failure here cannot leak an un-finalized iterator. + const prepared = await prepareMail(envelope, runId); + if (!prepared.ok) { + // A DETERMINISTICALLY malformed turn-2 mail cannot resume the + // parked agent. DROP it: log loudly and consume it (break to the + // post-loop markConsumed) rather than throwing -- replay would + // re-deliver the same poison mail forever. The run stays parked + // on its current correlation, ready for the next valid mail; one + // bad mail must not tear down a long-lived conversation. A + // TRANSIENT write failure is NOT caught here: `prepareMail` + // throws it, so it propagates as a dispatch fault and the mail + // stays reclaimable for retry. + logger.error`signal.deliver for run ${runId}: dropping malformed inbound mail ${envelope.messageId}: ${prepared.rejection.message}`; break; } // Mint the terminal watcher only now, after the payload resolved, so @@ -2599,7 +3023,7 @@ export function createWorkflowSupervisor( runId, signalName: signalName(inputChannel.correlationId), signalId: envelope.messageId, - payload: inputText, + payload: prepared.mail, }, }); // Invalidate the cached input channel: its correlation is now @@ -2610,6 +3034,9 @@ export function createWorkflowSupervisor( // delivering onto the stale channel. Routing hygiene only -- the // wait keys on the park-generation edge, not this level state. runInputChannels.delete(runId); + // The message was dispatched as a turn: mark its eager mailbox + // entry \Seen/$Processed. Fire-and-forget off the dispatch path. + markMailboxProcessed(envelope.messageId); // Durable-consume contract, mirroring the trigger.fire path: hold // markConsumed until the child has durably taken up the signal -- // the resumed run re-parks or reaches a terminal event. That gate @@ -2644,6 +3071,22 @@ export function createWorkflowSupervisor( break; } if (!cohortRunIds.has(runId)) { + // Resolve the inbound mail to the run's input before firing. A + // DETERMINISTICALLY malformed first trigger cannot start the run: + // record the rejection on the consumed entry and drop it (break to + // the post-loop markConsumed), since replay would fail identically. + // A TRANSIENT write failure instead propagates from + // `prepareMail` as a dispatch fault, leaving the mail + // reclaimable. Unlike a turn-2 parse failure (which leaves a live + // run parked), a malformed first trigger produces no run at all -- + // the rejection surfaces on the consumed entry, not as a RunFailed + // terminal event. + const prepared = await prepareMail(envelope, runId); + if (!prepared.ok) { + if (rejection === undefined) rejection = prepared.rejection; + logger.error`trigger.fire for run ${runId}: rejecting malformed inbound mail ${envelope.messageId}: ${prepared.rejection.message}`; + break; + } // Subscribe the terminal watcher BEFORE the trigger fires. The // broadcaster drops a notify that has no listener (its subscribe- // before-fire contract), so a terminal that lands while @@ -2657,14 +3100,22 @@ export function createWorkflowSupervisor( envelope.messageId, envelope.receivedAt, runId, + prepared.mail, ); + // The message was dispatched as a turn: mark its eager mailbox + // entry \Seen/$Processed. Fire-and-forget off the dispatch path. + markMailboxProcessed(envelope.messageId); - // Wait for the child to process this trigger before allowing + // Wait for the child to durably take up this trigger (RunStarted + // committed, then the run parks or terminates) before allowing // `markConsumed` to move the claim-check entry out of - // `processing/`. The child reads the trigger payload from that - // entry; racing `markConsumed` would delete the entry before the - // child resolves it. On cohort abort the wait returns and the - // post-loop guard skips markConsumed. + // `processing/`. The payload now rides the frame, so the child no + // longer reads it from the entry -- but the durable-consume + // contract still holds markConsumed until the run's uptake is + // committed, so a crash before RunStarted leaves the entry in + // processing/ for replayProcessingToInbox to re-deliver. On cohort + // abort the wait returns and the post-loop guard skips + // markConsumed. waitEntered = true; await waitForRunTerminalOrPark( iter, @@ -2964,6 +3415,12 @@ export function createWorkflowSupervisor( // shutdown); the crash-loop latch passes `crash-looping` so the terminal // state records why the deployment is down. terminalPhase?: "stopped" | "crash-looping"; + // True when the supervisor is driving ITSELF to a terminal phase (the + // crash-loop latch, a channel crash off `running`, a recycle failure) as + // opposed to the host requesting `shutdown()`. Gates the `onSelfTerminate` + // fire below. The terminal phase alone cannot carry this: a self-terminated + // and a host-requested teardown both land in `stopped`. + selfTerminated?: boolean; }): Promise { if ( state.phase === "idle" || @@ -3071,6 +3528,23 @@ export function createWorkflowSupervisor( path only waits for the substrate write to settle. */ }); } + if ( + (prior.phase === "starting" || + prior.phase === "running" || + prior.phase === "recycling") && + prior.sweepDone !== null + ) { + // Await the spawn-time compaction sweep before teardown so an + // in-flight fold's substrate commit does not outlive the supervisor + // and interleave with the next incarnation's boot. Teardown latency + // is bounded by the recovery backlog (see the `sweepDone` field + // docstring); a normal boot has zero or one pending fold. + await prior.sweepDone.catch(() => { + /* swallowed: the sweep's own catch already surfaces failures to + the supervisor's warn channel; the shutdown path only waits for + the in-flight fold's substrate commit to settle. */ + }); + } if (recyclePolicy !== null) { try { recyclePolicy.stop(); @@ -3151,6 +3625,25 @@ export function createWorkflowSupervisor( } state = { phase: opts.terminalPhase ?? "stopped" }; } + // Surface a self-termination to the host after the terminal transition is + // committed. The already-terminal early-return at the top dedups the common + // case, but it does NOT cover the `stopping` window, so two self-terminating + // callers interleaving through teardown can each fire (e.g. an onChildCrash + // during `recycling` plus the recycle-failure catch). The sink is therefore + // idempotent-required, not exactly-once; the reclaim it drives absorbs a + // repeat by design. Wrapped so a throwing sink cannot re-escape here and + // break the documented shutdown totality. + if (opts.selfTerminated === true) { + try { + bindings.onSelfTerminate?.({ + phase: opts.terminalPhase ?? "stopped", + reason: opts.reason, + }); + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + logger.warn`onSelfTerminate sink threw: ${message}`; + } + } logger.info`supervisor shutdown complete (${opts.reason})`; } @@ -3327,6 +3820,7 @@ export function createWorkflowSupervisor( terminalBroadcaster: prior.terminalBroadcaster, dispatchLoop: null, replayDone: null, + sweepDone: prior.sweepDone, }; let attempt: RecycleAttempt; try { @@ -3460,6 +3954,7 @@ export function createWorkflowSupervisor( terminalBroadcaster: newBroadcaster, dispatchLoop: newDispatchLoop, replayDone: null, + sweepDone: prior.sweepDone, }; // Bump the generation and arm the exit-watcher for the // respawned child atomically with this running transition, so @@ -3555,6 +4050,7 @@ export function createWorkflowSupervisor( logger.error`recycle failed; tearing supervisor down: ${message}`; await shutdownInternal({ reason: `recycle failed: ${message}`, + selfTerminated: true, }).catch((shutdownCause) => { const inner = shutdownCause instanceof Error @@ -3593,6 +4089,29 @@ export function createWorkflowSupervisor( `supervisor: deliverSignal called in phase ${state.phase}; expected starting/running`, ); } + // Refresh the run's grant floor on the SAME control channel immediately + // before the signal, so a standing ("always") approval resolved for a + // parked run lowers the floor for the resumed run's later calls. Ordering + // is structural: both frames ride this single seq-ordered FIFO, so the + // `grants-updated` is observed by the child ahead of the `signal.deliver` + // -- no dependence on hub-side dispatch timing. Best-effort by design; a + // failed refresh is non-fatal (the durable file still governs the next + // barrier), and it only re-reads that file, so a signal with no standing + // approval just re-pushes the unchanged floor. + await deliverGrants(opts.runId); + // `deliverGrants` awaits a substrate read, yielding the event loop. A + // crash/recycle can land in that window and swap `state` (its + // `controlSender` then points at the dying child). Re-assert the phase the + // pre-await guard checked, so the signal is never written into a recycling + // child's closing pipe; the caller retries once the recycle completes. The + // phase is read through the full union type because the pre-await guard + // control-flow-narrowed `state`, which the yield may have invalidated. + const phaseAfterRefresh: SupervisorState["phase"] = state.phase; + if (phaseAfterRefresh !== "running" && phaseAfterRefresh !== "starting") { + throw new Error( + `supervisor: deliverSignal raced a recycle in phase ${phaseAfterRefresh}; expected starting/running`, + ); + } await state.controlSender.send({ type: "signal.deliver", data: { @@ -3644,6 +4163,56 @@ export function createWorkflowSupervisor( }); } + /** + * Refresh a live run's grant floor mid-run: re-read this run's durable + * `runs//grants.json` (via `onRunStart`, the same read the pre-trigger + * barrier uses) and push it to the child as a `grants-updated` frame. The + * enforcement path for a standing (`scope: "always"`) approval, which lowers + * a tool's `ask` to `allow` in that file: the barrier only runs before a + * trigger/signal dispatch, so a run already executing (or being resumed + * without a fresh barrier) needs this to observe the change now. + * + * Distinct from `pushRunGrants` on two axes, both deliberate: + * - It NEVER synthesizes a `RunFailed`. A refresh for a run whose child is + * not live is normal (the durable file already carries the change and the + * next barrier or respawn re-reads it), so it no-ops (`skipped`) rather + * than failing the run, and a send failure to a live child is logged + * loudly but stays non-fatal (the file still wins at the next barrier). + * - It only ever pushes the durable file's contents through `onRunStart`; it + * accepts no caller-supplied grants, so it can only tighten or refresh a + * floor, never inject one a deploy did not approve. + */ + async function deliverGrants(runId: string): Promise<"pushed" | "skipped"> { + if (bindings.onRunStart === undefined) return "skipped"; + if (state.phase !== "running" && state.phase !== "starting") { + return "skipped"; + } + try { + const snapshot = await bindings.onRunStart({ + runId, + anchorRunId: bindings.anchorRunId, + }); + await state.controlSender.send({ + type: "grants-updated", + data: { + snapshot: { + steps: snapshot.steps.map((s) => ({ + stepId: s.stepId, + address: s.address, + grants: [...s.grants], + contentHash: s.contentHash, + })), + }, + }, + }); + return "pushed"; + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + logger.error`deliverGrants refresh failed for run ${runId}; the durable grants file still governs the next barrier/respawn: ${message}`; + return "skipped"; + } + } + function getCredentialsSnapshot(): CredentialsSnapshot | null { if (state.phase === "starting" || state.phase === "running") { return state.credentialsSnapshot; @@ -3660,6 +4229,7 @@ export function createWorkflowSupervisor( deliverSignal, deliverSources, deliverCredentials, + deliverGrants, reEmitParkedCorrelations, getCredentialsSnapshot, }; @@ -3731,6 +4301,23 @@ type ActiveState = { * `installNewChild` transitions back to `running`. */ replayDone: Promise | null; + /** + * Settles when the spawn-time compaction recovery sweep resolves (or + * rejects, swallowed via the supervisor's warn log). Tracked on the + * active-state record so `shutdownInternal` awaits its settlement before + * tearing the bindings down. Unlike `replayDone`, the dispatch loop does + * NOT borrow this promise: re-sealing interrupted folds is housekeeping and + * must not gate the first dequeue. The recycle-path ActiveState carries + * `prior.sweepDone` forward -- the sweep runs only at spawn, never on + * recycle -- so the last incarnation still awaits the original sweep. + * + * Awaiting full settlement couples teardown latency to the recovery + * backlog: the sweep is O(pending) serial substrate commits. This is + * acceptable because each fold is an idempotent atomic commit, so a fold + * abandoned at shutdown is simply re-proposed on the next boot; a normal + * boot has zero or one pending run. + */ + sweepDone: Promise | null; }; type SpawnContext = { @@ -3869,6 +4456,7 @@ function outboundMessageFromPayload( if (payload.payload !== undefined) message.payload = payload.payload; if (payload.summary !== undefined) message.summary = payload.summary; if (payload.inReplyTo !== undefined) message.inReplyTo = payload.inReplyTo; + if (payload.references !== undefined) message.references = payload.references; if (payload.correlationId !== undefined) { message.correlationId = payload.correlationId; } diff --git a/vendor/intx/workflow-host/src/supervisor/types.ts b/vendor/intx/workflow-host/src/supervisor/types.ts index 4c030be45..928ac57f2 100644 --- a/vendor/intx/workflow-host/src/supervisor/types.ts +++ b/vendor/intx/workflow-host/src/supervisor/types.ts @@ -315,6 +315,36 @@ export interface WorkflowSupervisorBindings { * or abort a re-emit partway through the parked set. */ onSuspensionRegister?: (registration: SuspensionRegistration) => void; + /** + * Self-termination sink. The supervisor invokes it when it reaches a terminal + * phase on its own -- the crash-loop latch (`crash-looping`), a channel crash + * while recycling, or a recycle failure (both `stopped`) -- but NOT when the + * host drives it down through the public `shutdown()`, and NOT for a failure + * of the initial spawn handshake (that is the deploy's to unwind). Production + * wires this to the sidecar so it reclaims the deployment address (drops the + * supervisor from its active map and releases the address's routing state) + * and the address becomes redeployable without a manual undeploy. + * + * The handler MUST be idempotent. Firing is not exactly-once: two + * self-terminating callers interleaving through teardown (e.g. a channel + * crash while recycling plus the recycle-failure catch) can each fire. The + * sidecar's reclaim absorbs a repeat because its `activeSupervisors.has` + * guard makes the second run a no-op. + * + * Unlike `onSuspensionRegister`, this sink does NOT share the same + * log-and-continue contract on the host side. A missed suspension has an + * independent recovery path (`reEmitParkedCorrelations`); a missed reclaim + * does not -- the address stays stranded until an operator undeploys. So + * the host's handler is engineered to be total, and a failure there is + * logged loudly rather than swallowed. The supervisor still invokes this + * best-effort (a throwing sink cannot break the terminal transition), but a + * host that copies `onSuspensionRegister`'s quiet-swallow semantics onto its + * reclaim handler reintroduces the stranding bug. + */ + onSelfTerminate?: (info: { + phase: "stopped" | "crash-looping"; + reason: string; + }) => void; /** * Per-run grants source the dispatch loop consults before it forwards a * `trigger.fire`. Unlike `onSuspensionRegister` (best-effort, fire-and- diff --git a/vendor/intx/workflow-host/src/workflow-definition-loader.ts b/vendor/intx/workflow-host/src/workflow-definition-loader.ts index 572ea1aac..63619bb4b 100644 --- a/vendor/intx/workflow-host/src/workflow-definition-loader.ts +++ b/vendor/intx/workflow-host/src/workflow-definition-loader.ts @@ -36,6 +36,7 @@ import { import { PackageJSON, isContainedEntryPath } from "@intx/types/package-json"; import { workflowDefinitionEnvelopeSchema } from "@intx/hub-sessions/substrate"; import type { WorkflowDefinition } from "@intx/workflow/definition"; +import type { ActionHandler, LoopFn, LoopFnRegistry } from "@intx/workflow"; const logger = getLogger(["workflow-host", "definition-loader"]); @@ -208,6 +209,175 @@ export async function loadWorkflowDirectorRegistryFromClosure( return createWorkflowDirectorRegistry(loaded); } +export interface LoadWorkflowLoopFnsFromClosureArgs { + /** + * Directory of the materialized workflow package within the closure -- + * the same directory `loadWorkflowDefinitionFromClosure` reads. + */ + readonly packageDir: string; + /** See `LoadWorkflowDefinitionFromClosureArgs.importCacheKey`. */ + readonly importCacheKey?: string; + /** Test seam for dynamic import; see the definition loader's variant. */ + readonly importModule?: (importUrl: string) => Promise; +} + +/** + * Compose the `LoopFnRegistry` for a workflow closure from the closure + * package's OWN `interchange.loops` module. A `loop` primitive's `while` and + * `carry` refs resolve by EXPORT NAME against that module's exports. + * + * Unlike directors there is NO built-in default: a package with no + * `interchange.loops` field composes to an EMPTY registry that throws on any + * ref lookup. A workflow that declares a `loop` but ships no loops module thus + * fails closed when its refs are resolved (eagerly, at establish); a workflow + * with no `loop` primitive never resolves a ref, so an absent field is valid + * there. Loading OUTSIDE the definition-hash re-verify is safe: the approved + * hash pins each ref string, and the closure's SRI pins the module bytes. + * + * @throws (from the returned registry) if a requested ref names no export, or + * names an export that is not a function. + * @throws if the loops entry path escapes the package or cannot be imported. + */ +export async function loadWorkflowLoopFnsFromClosure( + args: LoadWorkflowLoopFnsFromClosureArgs, +): Promise { + const importModule = + args.importModule ?? ((url: string) => import(url) as Promise); + + const pkgJson = await readPackageJSON(args.packageDir); + const entryRel = pkgJson.interchange?.loops; + if (entryRel === undefined) { + // No loops module. A workflow with no loop primitive never calls this; one + // that declares a loop fails closed here when its ref is resolved. + return (ref: string): LoopFn => { + throw new Error( + `loop fn ${JSON.stringify(ref)} was requested, but the workflow package at ${args.packageDir} declares no interchange.loops module`, + ); + }; + } + + const entryAbs = await resolveContainedEntry( + args.packageDir, + entryRel, + "interchange.loops", + ); + + const importUrl = + args.importCacheKey === undefined + ? pathToFileURL(entryAbs).href + : `${pathToFileURL(entryAbs).href}?importCacheKey=${encodeURIComponent(args.importCacheKey)}`; + + let mod: unknown; + try { + mod = await importModule(importUrl); + } catch (cause) { + throw new Error( + `failed to import interchange.loops entry ${JSON.stringify(entryRel)} for workflow package at ${args.packageDir}`, + { cause }, + ); + } + if (mod === null || typeof mod !== "object") { + throw new Error( + `interchange.loops entry ${JSON.stringify(entryRel)} for workflow package at ${args.packageDir} did not evaluate to a module object`, + ); + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- module namespace object: loop fns resolve by export name + const loopModule = mod as Record; + logger.debug`loaded interchange.loops module from ${args.packageDir}`; + return (ref: string): LoopFn => { + const fn = loopModule[ref]; + if (typeof fn !== "function") { + throw new Error( + `interchange.loops entry ${JSON.stringify(entryRel)} for workflow package at ${args.packageDir} exports no loop fn named ${JSON.stringify(ref)}`, + ); + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- resolved by export name; the loop runtime applies it as a pure (childOutput, carryState) fn + return fn as LoopFn; + }; +} + +export interface LoadWorkflowActionHandlersFromClosureArgs { + /** Directory of the materialized workflow package within the closure. */ + readonly packageDir: string; + /** See `LoadWorkflowDefinitionFromClosureArgs.importCacheKey`. */ + readonly importCacheKey?: string; + /** Test seam for dynamic import; see the definition loader's variant. */ + readonly importModule?: (importUrl: string) => Promise; +} + +/** + * Compose the action-handler resolver for a workflow closure from the closure + * package's OWN `interchange.actions` module. An `action` primitive's `handler` + * ref resolves by EXPORT NAME against that module's exports. + * + * Mirrors {@link loadWorkflowLoopFnsFromClosure}: there is NO built-in default, + * so a package with no `interchange.actions` field composes to a resolver that + * throws on any lookup. A workflow that declares an `action` but ships no + * actions module fails closed when its handler is resolved (eagerly, at + * establish); a workflow with no `action` primitive never resolves a handler. + * Loading OUTSIDE the definition-hash re-verify is safe: the approved hash pins + * each handler ref string, and the closure's SRI pins the module bytes. + * + * @throws (from the returned resolver) if a requested ref names no export, or an + * export that is not a function. + * @throws if the actions entry path escapes the package or cannot be imported. + */ +export async function loadWorkflowActionHandlersFromClosure( + args: LoadWorkflowActionHandlersFromClosureArgs, +): Promise<(ref: string) => ActionHandler> { + const importModule = + args.importModule ?? ((url: string) => import(url) as Promise); + + const pkgJson = await readPackageJSON(args.packageDir); + const entryRel = pkgJson.interchange?.actions; + if (entryRel === undefined) { + return (ref: string): ActionHandler => { + throw new Error( + `action handler ${JSON.stringify(ref)} was requested, but the workflow package at ${args.packageDir} declares no interchange.actions module`, + ); + }; + } + + const entryAbs = await resolveContainedEntry( + args.packageDir, + entryRel, + "interchange.actions", + ); + + const importUrl = + args.importCacheKey === undefined + ? pathToFileURL(entryAbs).href + : `${pathToFileURL(entryAbs).href}?importCacheKey=${encodeURIComponent(args.importCacheKey)}`; + + let mod: unknown; + try { + mod = await importModule(importUrl); + } catch (cause) { + throw new Error( + `failed to import interchange.actions entry ${JSON.stringify(entryRel)} for workflow package at ${args.packageDir}`, + { cause }, + ); + } + if (mod === null || typeof mod !== "object") { + throw new Error( + `interchange.actions entry ${JSON.stringify(entryRel)} for workflow package at ${args.packageDir} did not evaluate to a module object`, + ); + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- module namespace object: action handlers resolve by export name + const actionModule = mod as Record; + logger.debug`loaded interchange.actions module from ${args.packageDir}`; + return (ref: string): ActionHandler => { + const fn = actionModule[ref]; + if (typeof fn !== "function") { + throw new Error( + `interchange.actions entry ${JSON.stringify(entryRel)} for workflow package at ${args.packageDir} exports no action handler named ${JSON.stringify(ref)}`, + ); + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- resolved by export name; invoked as an ActionHandler (input, ctx, signal) by createDefaultActionInvoker + return fn as ActionHandler; + }; +} + export interface LoadWorkflowPluginsFromClosureArgs { /** * Directory of the materialized workflow package within the closure -- diff --git a/vendor/intx/workflow-host/tsconfig.json b/vendor/intx/workflow-host/tsconfig.json index d984862c9..dbbb0384b 100644 --- a/vendor/intx/workflow-host/tsconfig.json +++ b/vendor/intx/workflow-host/tsconfig.json @@ -1,11 +1,11 @@ { "extends": "../tsconfig.base.json", + "include": [ + "src/**/*.ts" + ], "compilerOptions": { "types": [ "bun" ] - }, - "include": [ - "src/**/*.ts" - ] + } }